From 5d1af4e909ccf462bfca746c834e663f592f81ac Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 15:57:46 -0500 Subject: [PATCH 1/9] feat: serve historical upload variants and replay transforms on restore --- docs/upload/overview.mdx | 16 +- docs/upload/transformers.mdx | 2 +- .../operations/local/restoreVersion.ts | 2 + .../collections/operations/restoreVersion.ts | 44 +- .../payload/src/uploads/checkFileAccess.ts | 1 + .../payload/src/uploads/endpoints/getFile.ts | 18 +- .../fileVersioning/resolveHistoricalFile.ts | 145 ++++- .../src/uploads/fileVersioning/restore.ts | 29 +- .../fileVersioning/restoreRequestTime.ts | 272 ++++++++ .../fileVersioning/storedFiles.spec.ts | 17 + .../src/uploads/fileVersioning/storedFiles.ts | 13 +- .../payload/src/uploads/generateFileData.ts | 71 +- .../src/uploads/requestTimeFileData.ts | 80 +++ .../transformers/handleDynamicFileRequest.ts | 38 +- .../src/hooks/afterChange.ts | 7 +- .../collections/VersionedCloudMedia.ts | 15 + .../collections/VersionedDynamicMedia.ts | 20 + test/upload-dynamic/config.ts | 11 +- test/upload-dynamic/history.int.spec.ts | 387 +++++++++++ test/upload-dynamic/payload-types.ts | 263 +++++++- test/upload-dynamic/restore.int.spec.ts | 604 ++++++++++++++++++ test/upload-dynamic/shared.ts | 3 + test/upload-dynamic/transformers.ts | 28 +- 23 files changed, 1963 insertions(+), 123 deletions(-) create mode 100644 packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts create mode 100644 packages/payload/src/uploads/requestTimeFileData.ts create mode 100644 test/upload-dynamic/collections/VersionedCloudMedia.ts create mode 100644 test/upload-dynamic/collections/VersionedDynamicMedia.ts create mode 100644 test/upload-dynamic/history.int.spec.ts create mode 100644 test/upload-dynamic/restore.int.spec.ts diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 18a777852ec..247b009f71f 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -342,7 +342,21 @@ Existing uploads are upgraded as they are edited; no bulk file migration is requ SQL projects need a schema migration; see the [v4 migration guide](../migration-guide/v4). -To read a stored historical file through Payload's file endpoint, include `?version=`. Version reads generate these URLs for the main file, original, stored variants, and thumbnail. The requested filename and storage prefix must belong to that version, and both parent-document read access and version read access are enforced. Without `version`, the endpoint resolves current published files and the latest draft, subject to collection read access; filenames found only in history return 404. Historical requests serve the saved bytes without rerunning transformations. Files built only at request time are not available through historical URLs; their retained originals remain stored files. Replaying saved transforms for those files on restore is not supported yet. Public URLs supplied directly by a storage provider remain direct. +### Historical file URLs + +To read a historical file through Payload's file endpoint, include `?version=`. Version reads generate these URLs for the main file, original, variants, and thumbnail. The requested filename and storage prefix must belong to that version, and both parent-document read access and version read access are enforced. Without `version`, the endpoint resolves current published files and the latest draft, subject to collection read access; filenames found only in history return 404. + +Stored historical files and originals serve their saved bytes without rerunning transformations, including when the URL contains resize parameters. A file built at request time instead uses that version's retained original and saved `_transforms` with the current compatible transformer pipeline. It does not use the current document's source or edits. Request overrides compose after the saved state and require transform access, as on current file requests. Reading a historical file never saves new files or changes the version. + +A request-time historical file needs a compatible transformer. A request-time size also needs its name registered in the current configuration. If either is unavailable, the request fails instead of serving the current file or an untransformed original. Stored historical sizes remain readable even if their names are no longer configured. Public URLs supplied directly by a storage provider remain direct. + +### Restoring files + +Restoring a version preserves its saved content and file bytes, and saves the restored state as a new version. Storage references and URLs can change when Payload archives an outgoing file. Stored-only versions reuse their saved output bytes. + +When the selected version includes a file built at request time, Payload resolves its retained original and saved `_transforms` through the current configuration. The restored output can remain request-time or become stored if an upload transformer now applies the saved edits. Current size configuration determines the restored variants. Output bytes and URLs can differ when transformer code or configuration has changed. + +Unsupported required transforms fail before file changes. If replay or saving fails, Payload cleans up staged files through the existing file-operation lifecycle. Without database transactions, a failure after the document is written can leave the restored document saved; cleanup keeps files that it still references. Pass `draft: true` when restoring to create a draft without replacing the published document. ## Disabling Local Upload Storage diff --git a/docs/upload/transformers.mdx b/docs/upload/transformers.mdx index a53d5408242..4d013a039d7 100644 --- a/docs/upload/transformers.mdx +++ b/docs/upload/transformers.mdx @@ -348,7 +348,7 @@ Request-time sizes follow the same [saved-key rules](#applying-saved-_transforms Request parameters never change `_transforms`. To save a change, update the document. -Historical requests (`?version=`) serve stored bytes only. They do not build request-time files from a version's saved transforms. Historical request-time serving and replaying those transforms on restore are not supported yet. +Historical requests (`?version=`) build request-time files from the selected version's original and saved `_transforms`. The handler receives that version's document snapshot, and the current compatible pipeline applies its saved state and any request overrides. Stored historical files and originals continue to serve saved bytes without transformations. Restoring a version with request-time entries resolves the saved original and edits through the current configuration; stored-only versions reuse saved output. See [Historical file URLs](./overview#historical-file-urls) and [Restoring files](./overview#restoring-files). #### Handing off to an image service diff --git a/packages/payload/src/collections/operations/local/restoreVersion.ts b/packages/payload/src/collections/operations/local/restoreVersion.ts index 8d9d3432cac..2fd8c806933 100644 --- a/packages/payload/src/collections/operations/local/restoreVersion.ts +++ b/packages/payload/src/collections/operations/local/restoreVersion.ts @@ -75,6 +75,7 @@ export async function restoreVersionLocal( id, collection: collectionSlug, depth, + draft, overrideAccess = false, populate, select, @@ -95,6 +96,7 @@ export async function restoreVersionLocal( id, collection, depth, + draft, overrideAccess, payload, populate, diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index 9e5786282cf..e77780c6fc6 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -23,7 +23,7 @@ import { completeFileOperationScope, shareFileOperationScope, } from '../../uploads/fileVersioning/fileOperationManager.js' -import { runStoredFileRestore } from '../../uploads/fileVersioning/restore.js' +import { runFileRestore } from '../../uploads/fileVersioning/restore.js' import { restoreUploadDataFromDocument } from '../../uploads/sanitizeUploadData.js' import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' @@ -68,12 +68,35 @@ export const restoreVersionOperation = async < draft: draftArg = false, overrideAccess = false, populate, - req, + req: incomingReq, req: { fallbackLocale, locale, payload }, select: incomingSelect, showHiddenFields, } = args const hasFileOperationScope = Boolean(collectionConfig.upload) + const req = hasFileOperationScope + ? isolateObjectProperty(incomingReq, ['context', 'file', 'payloadUploadSizes', 'query']) + : incomingReq + + if (hasFileOperationScope) { + // A nested restore must not consume the enclosing upload's bytes or storage receipts. + req.file = undefined + req.payloadUploadSizes = undefined + req.query = { ...incomingReq.query } + delete req.query.uploadEdits + req.context = { ...incomingReq.context } + for (const key of [ + '_payloadCloudStorage', + '_payloadManagedCloudMetadata', + '_payloadManagedCloudStorage', + '_payloadVerifiedProviderOriginal', + 'skipCloudStorage', + ]) { + delete req.context[key] + } + shareFileOperationScope({ owner: incomingReq, req }) + args = { ...args, req } + } if (hasFileOperationScope) { beginFileOperationScope({ req }) @@ -337,6 +360,9 @@ export const restoreVersionOperation = async < const writeRestoredVersion = async (restored: JsonObject) => { if (collectionConfig.upload) { result = restoreUploadDataFromDocument(result, restored, { clearMissing: true }) + if (req.context._payloadManagedCloudStorage) { + data = restoreUploadDataFromDocument(data, restored, { clearMissing: true }) + } } if (!draftArg) { result = await req.payload.db.updateOne({ @@ -344,7 +370,6 @@ export const restoreVersionOperation = async < collection: collectionConfig.slug, data: result, req: reqWithValidationLocale, - select, }) } @@ -363,7 +388,7 @@ export const restoreVersionOperation = async < } result = collectionConfig.upload - ? await runStoredFileRestore({ + ? await runFileRestore({ id: parentDocID, collection: collectionConfig, current: prevDocWithLocales, @@ -415,10 +440,16 @@ export const restoreVersionOperation = async < // afterChange - Fields // ///////////////////////////////////// + // Adapter metadata is saved by its normal afterChange hook. It needs the full upload data + // and draft status even when the caller's response selection omits those fields. + const managedAfterChangeData = req.context._payloadManagedCloudStorage + ? { ...data, _status: draftArg ? 'draft' : data._status } + : undefined + result = await afterChange({ collection: collectionConfig, context: req.context, - data: result, + data: managedAfterChangeData ?? result, doc: result, global: null, operation: 'update', @@ -436,12 +467,13 @@ export const restoreVersionOperation = async < (await hook({ collection: collectionConfig, context: req.context, - data: result, + data: managedAfterChangeData ?? result, doc: result, operation: 'update', overrideAccess, previousDoc: prevDocWithLocales, req, + select, })) || result } } diff --git a/packages/payload/src/uploads/checkFileAccess.ts b/packages/payload/src/uploads/checkFileAccess.ts index a5a9091e2d0..3757f5b35af 100644 --- a/packages/payload/src/uploads/checkFileAccess.ts +++ b/packages/payload/src/uploads/checkFileAccess.ts @@ -50,6 +50,7 @@ export const checkFileAccess = async ({ }) : await req.payload.db.findOne({ collection: config.slug, + locale: req.locale ?? undefined, req, where: { and: [{ id: { equals: documentID } }, ...constraints] }, }) diff --git a/packages/payload/src/uploads/endpoints/getFile.ts b/packages/payload/src/uploads/endpoints/getFile.ts index f0c06b94456..8d211bc2b2f 100644 --- a/packages/payload/src/uploads/endpoints/getFile.ts +++ b/packages/payload/src/uploads/endpoints/getFile.ts @@ -19,9 +19,10 @@ import { streamFile } from '../fetchAPI-stream-file/index.js' import { resolveHistoricalFile } from '../fileVersioning/resolveHistoricalFile.js' import { getFileTypeFallback } from '../getFileTypeFallback.js' import { getFileExtension, isXmlMimeType } from '../getFileTypeIdentity.js' +import { isOriginalFileRequest } from '../isOriginalFileRequest.js' import { parseRangeHeader } from '../parseRangeHeader.js' import { handleDynamicFileRequest } from '../transformers/handleDynamicFileRequest.js' -import { resolveUploadDocument } from '../transformers/resolveUploadDocument.js' +import { getRequestedFile, resolveUploadDocument } from '../transformers/resolveUploadDocument.js' import { uploadContentSecurityPolicy } from '../uploadContentSecurityPolicy.js' export const getFileHandler: PayloadHandler = async (req) => { @@ -44,11 +45,26 @@ export const getFileHandler: PayloadHandler = async (req) => { const historical = await resolveHistoricalFile({ collection, filename, + isOriginal, prefix, req, versionID, }) + if ( + !isOriginalFileRequest({ doc: historical, filename, isOriginal }) && + getRequestedFile({ document: historical, filename }).isBuiltAtRequest + ) { + return handleDynamicFileRequest({ + collection, + filename, + historicalDocument: historical, + isOriginal, + prefix, + req, + }) + } + return retrieveFileResponse({ collection, doc: historical, diff --git a/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts b/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts index 69e3d1858cd..984da3c4906 100644 --- a/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts +++ b/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts @@ -1,9 +1,22 @@ -import type { Collection, TypeWithID } from '../../collections/config/types.js' +import type { Collection } from '../../collections/config/types.js' import type { JsonObject, PayloadRequest } from '../../types/index.js' +import type { ResolvedUploadDocument } from '../transformers/resolveUploadDocument.js' +import { executeAccess } from '../../auth/executeAccess.js' +import { combineQueries } from '../../database/combineQueries.js' +import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' import { Forbidden } from '../../errors/Forbidden.js' import { NotFound } from '../../errors/NotFound.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' +import { + isInheritedReadVersionsAccess, + withInheritedReadVersionsParentID, +} from '../../versions/isInheritedReadVersionsAccess.js' import { checkFileAccess } from '../checkFileAccess.js' +import { isOriginalFileRequest } from '../isOriginalFileRequest.js' +import { isRequestTimeFile } from '../isRequestTimeFile.js' +import { withFileTransformAccessContext } from '../transformers/withFileTransformAccessContext.js' import { collectStoredFiles, withLegacyCloudUploadFileData } from './storedFiles.js' type StoredVersion = { @@ -12,20 +25,22 @@ type StoredVersion = { version: JsonObject } -/** Finds an exact saved object reference, including sizes removed from the current config. */ +/** Resolves a version's exact file entry after checking parent and version read access. */ export const resolveHistoricalFile = async ({ collection, filename, + isOriginal, prefix, req, versionID, }: { collection: Collection filename: string + isOriginal?: boolean prefix?: string req: PayloadRequest versionID: string -}): Promise => { +}): Promise => { const { docs } = await req.payload.db.findVersions({ collection: collection.config.slug, limit: 1, @@ -35,25 +50,71 @@ export const resolveHistoricalFile = async ({ const matched = docs[0] // Check both policies before exposing a missing version or a filename mismatch. - await checkFileAccess({ collection, documentID: matched?.parent, filename, req }) - const authorized = await req.payload.findVersionByID({ - id: versionID, + const entry = matched && getHistoricalEntry({ doc: matched.version, filename, isOriginal }) + const checkParent = ({ isTransform }: { isTransform: boolean }) => + withFileTransformAccessContext({ + callback: () => checkFileAccess({ collection, documentID: matched?.parent, filename, req }), + isTransform, + req, + }) + + try { + await checkParent({ isTransform: false }) + } catch (error) { + if (!(error instanceof Forbidden) || !isRequestTimeFile({ entry: entry?.value })) { + throw error + } + // The request pipeline will check its final access mode before executing any stage. + await checkParent({ isTransform: true }) + } + const versionAccess = await executeAccess( + { id: versionID, slug: collection.config.slug, req }, + isInheritedReadVersionsAccess(collection.config.access.readVersions) + ? (accessArgs) => + collection.config.access.readVersions( + withInheritedReadVersionsParentID(accessArgs, matched?.parent), + ) + : collection.config.access.readVersions, + ) + const where = appendNonTrashedFilter({ + deletedAtPath: 'version.deletedAt', + enableTrash: collection.config.trash, + where: combineQueries({ id: { equals: versionID } }, versionAccess), + }) + + sanitizeWhereQuery({ + fields: buildVersionCollectionFields(req.payload.config, collection.config, true), + payload: req.payload, + where, + }) + + const { docs: authorizedVersions } = await req.payload.db.findVersions({ collection: collection.config.slug, - depth: 0, - overrideAccess: false, + limit: 1, + locale: req.locale ?? undefined, req, - showHiddenFields: true, + where, }) - if (!authorized) { - throw new Forbidden(req.t) + if (!authorizedVersions[0]) { + if (typeof versionAccess === 'object') { + throw new Forbidden(req.t) + } + throw new NotFound(req.t) } if (!matched) { throw new NotFound(req.t) } - const historical = await resolveVersionFile({ collection, filename, prefix, req, row: matched }) + const historical = await resolveVersionFile({ + collection, + filename, + isOriginal, + prefix, + req, + row: matched, + }) if (!historical) { throw new NotFound(req.t) @@ -65,39 +126,67 @@ export const resolveHistoricalFile = async ({ const resolveVersionFile = async ({ collection, filename, + isOriginal, prefix, req, row, }: { collection: Collection filename: string + isOriginal?: boolean prefix?: string req: PayloadRequest row: StoredVersion -}): Promise => { +}): Promise => { const saved = await withLegacyCloudUploadFileData({ collection: collection.config, doc: row.version, req, }) - const hasFile = collectStoredFiles({ collection: collection.config, doc: saved, req }).some( - (file) => - file.roles.some((role) => { - const representation = - role.type === 'size' - ? (saved.variants as Record | undefined)?.[role.sizeKey] - : role.type === 'original' - ? (saved.original as JsonObject | undefined) - : saved - return ( - representation?.filename === filename && (!prefix || representation.prefix === prefix) - ) - }), - ) + const entry = getHistoricalEntry({ doc: saved, filename, isOriginal }) + + if (!entry || (prefix !== undefined && (entry.value.prefix ?? '') !== prefix)) { + return + } + + const storedFiles = collectStoredFiles({ collection: collection.config, doc: saved, req }) + const hasFile = isRequestTimeFile({ entry: entry.value }) + ? storedFiles.some((file) => file.roles.some((role) => role.type === 'original')) + : storedFiles.some((file) => + file.roles.some((role) => + entry.type === 'size' + ? role.type === 'size' && role.sizeKey === entry.sizeKey + : role.type === entry.type, + ), + ) if (!hasFile) { return } - return { ...saved, id: row.parent } + return { ...saved, id: row.parent } as ResolvedUploadDocument +} + +function getHistoricalEntry({ + doc, + filename, + isOriginal, +}: { + doc: JsonObject + filename: string + isOriginal?: boolean +}): { sizeKey?: string; type: 'default' | 'original' | 'size'; value: JsonObject } | undefined { + if (isOriginalFileRequest({ doc, filename, isOriginal })) { + return { type: 'original', value: doc.original as JsonObject } + } + if (doc.filename === filename) { + return { type: 'default', value: doc } + } + for (const [sizeKey, value] of Object.entries( + (doc.variants ?? {}) as Record, + )) { + if (value?.filename === filename) { + return { type: 'size', sizeKey, value } + } + } } diff --git a/packages/payload/src/uploads/fileVersioning/restore.ts b/packages/payload/src/uploads/fileVersioning/restore.ts index 8312fd97f1b..cc8e8ce5cdb 100644 --- a/packages/payload/src/uploads/fileVersioning/restore.ts +++ b/packages/payload/src/uploads/fileVersioning/restore.ts @@ -7,17 +7,36 @@ import type { JsonObject, PayloadRequest } from '../../types/index.js' import type { StoredFileList } from './types.js' import { APIError } from '../../errors/APIError.js' +import { isRequestTimeFile } from '../isRequestTimeFile.js' import { archiveOutgoingLocalFiles, replaceStoredFileReferences } from './archive.js' import { removeUnreferencedStagedObjects, scheduleUnreferencedFileCleanup } from './cleanup.js' import { runFileOperationPlan } from './fileOperationManager.js' import { copyLocalFile } from './localStorage.js' import { getArchivedFilename } from './naming.js' +import { runRequestTimeFileRestore } from './restoreRequestTime.js' import { collectStoredFiles, getStoredFileIdentity, withLegacyCloudUploadFileData, } from './storedFiles.js' +export type FileRestoreArgs = { + collection: SanitizedCollectionConfig + current: JsonObject + id: number | string + req: PayloadRequest + selected: JsonObject + write: (restored: JsonObject) => Promise +} + +/** Replay virtual outputs with today's pipeline; saved stored outputs remain reusable. */ +export const runFileRestore = async (args: FileRestoreArgs): Promise => { + const entries = [args.selected, ...Object.values(args.selected.variants ?? {})] + return entries.some((entry) => isRequestTimeFile({ entry: entry as JsonObject })) + ? runRequestTimeFileRestore({ ...args, copyStoredFiles: runStoredFileRestore }) + : runStoredFileRestore(args) +} + /** Copies the selected stored files before making that version current. */ export const runStoredFileRestore = async ({ id, @@ -26,14 +45,7 @@ export const runStoredFileRestore = async ({ req, selected, write, -}: { - collection: SanitizedCollectionConfig - current: JsonObject - id: number | string - req: PayloadRequest - selected: JsonObject - write: (restored: JsonObject) => Promise -}): Promise => { +}: FileRestoreArgs): Promise => { const stored = await withLegacyCloudUploadFileData({ collection, doc: selected, @@ -65,6 +77,7 @@ export const runStoredFileRestore = async ({ ? selectedVariants[sizeKey] : { _objectKey: null, + _stored: null, filename: null, filesize: null, height: null, diff --git a/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts b/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts new file mode 100644 index 00000000000..4fa6af7d12b --- /dev/null +++ b/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts @@ -0,0 +1,272 @@ +import type { TypeWithID } from '../../collections/config/types.js' +import type { Document, JsonObject } from '../../types/index.js' +import type { OriginalFileData } from '../types.js' +import type { FileRestoreArgs } from './restore.js' + +import { APIError } from '../../errors/APIError.js' +import { ValidationError } from '../../errors/ValidationError.js' +import { deepCopyObject } from '../../utilities/deepCopyObject.js' +import { deepFreeze } from '../../utilities/deepFreeze.js' +import { isolateObjectProperty } from '../../utilities/isolateObjectProperty.js' +import { retrieveFileResponse } from '../endpoints/getFile.js' +import { generateFileData } from '../generateFileData.js' +import { generateFilePathOrURL } from '../generateFilePathOrURL.js' +import { shouldRequestOriginal } from '../isOriginalFileRequest.js' +import { isRequestTimeFile } from '../isRequestTimeFile.js' +import { getRequestTimeDefaultFileData, getRequestTimeVariants } from '../requestTimeFileData.js' +import { + canBuildAtRequest, + getStagesThatRun, + planUploadPipeline, + resolveTransformKeyCoverage, +} from '../transformState/planUpload.js' +import { getTransformStateError } from '../transformState/validateTransformState.js' +import { runLocalFileUpdate } from './archive.js' +import { runCloudFileUpdate } from './cloudStorage.js' +import { shareFileOperationScope } from './fileOperationManager.js' +import { collectStoredFiles } from './storedFiles.js' + +/** Restore recipes from their retained source, never from a virtual filename or current document. */ +export async function runRequestTimeFileRestore({ + id, + collection, + copyStoredFiles, + current, + req, + selected, + write, +}: { + copyStoredFiles: (args: FileRestoreArgs) => Promise +} & FileRestoreArgs): Promise { + const original = selected.original as OriginalFileData | undefined + const originalFile = collectStoredFiles({ collection, doc: selected, req }).find(({ roles }) => + roles.some(({ type }) => type === 'original'), + ) + + if (!original?.filename || !original.mimeType || !originalFile) { + throw new APIError('Restoring request-time files requires a retained original.', 400) + } + + // A restore always uses the saved recipe. Caller upload data and query edits cannot override it. + const replayReq = isolateObjectProperty(req, ['file', 'query', 'searchParams', 'context']) + replayReq.file = undefined + replayReq.query = {} + Object.assign(replayReq, { searchParams: new URLSearchParams() }) + replayReq.context = { ...req.context } + shareFileOperationScope({ owner: req, req: replayReq }) + + const pipelineDoc = deepCopyObject({ + ...selected, + filename: original.filename, + filesize: original.filesize, + mimeType: original.mimeType, + variants: {}, + }) as Document + const pipelineOriginalDoc = deepFreeze(deepCopyObject(pipelineDoc)) + const transformError = await getTransformStateError({ + original, + value: pipelineDoc._transforms, + }) + + if (transformError) { + throw new ValidationError( + { + collection: collection.slug, + errors: [{ message: transformError, path: '_transforms' }], + req, + }, + req.t, + ) + } + + const planArgs = { + collectionSlug: collection.slug, + documentID: id, + mimeType: original.mimeType, + pipelineDoc, + pipelineOriginalDoc, + req: replayReq, + } + const plan = await planUploadPipeline(planArgs) + const { isAppliedAtRequest } = await resolveTransformKeyCoverage({ + ...planArgs, + canApplyAtRequest: true, + plan, + }) + const { bridgeTransformer, stagesThatRun } = getStagesThatRun({ + collectionSlug: collection.slug, + plan, + }) + const shouldRunUpload = !isAppliedAtRequest && stagesThatRun.length > 0 + const hasVariants = Boolean(collection.upload.variants?.length) + const hasRequestTimeVariants = + hasVariants && + (!shouldRunUpload || bridgeTransformer?.slug !== collection.upload.variantsTransformerSlug) + + if ( + hasRequestTimeVariants && + (!collection.upload.variantsTransformerSlug || + !(await canBuildAtRequest({ + ...planArgs, + requiredTransformerSlug: collection.upload.variantsTransformerSlug, + }))) + ) { + throw new APIError('No compatible transformer can restore the configured file variants.', 400) + } + + const normalizeURLs = ({ restored }: { restored: JsonObject }): JsonObject => { + const original = restored.original as JsonObject + const entries = [ + { entry: restored, isOriginal: false }, + { entry: original, isOriginal: shouldRequestOriginal({ doc: restored }) }, + ...Object.values(restored.variants ?? {}).map((entry) => ({ + entry: entry as JsonObject, + isOriginal: false, + })), + ] + for (const { entry, isOriginal } of entries) { + if (entry && (!collection.upload.fileOperations || isRequestTimeFile({ entry }))) { + entry.url = generateFilePathOrURL({ + collectionSlug: collection.slug, + config: req.payload.config, + filename: entry.filename, + isOriginal, + relative: true, + urlOrPath: undefined, + }) + } + } + return restored + } + + const clearRemovedVariants = ({ restored }: { restored: JsonObject }): JsonObject => { + const variants = { ...restored.variants } + for (const name of new Set([ + ...Object.keys(current.variants ?? {}), + ...Object.keys(selected.variants ?? {}), + ])) { + variants[name] ??= { + _objectKey: null, + _stored: null, + filename: null, + filesize: null, + height: null, + mimeType: null, + prefix: null, + url: null, + width: null, + } + } + return { ...restored, variants } + } + + if (!shouldRunUpload) { + // Copy the retained source using storage operations. Virtual restores never download bytes. + return copyStoredFiles({ + id, + collection, + current, + req: replayReq, + selected: { ...selected, _stored: false, variants: {} }, + write: async (copied) => { + const copiedOriginal = copied.original as OriginalFileData + const restored = { + ...copied, + original: { ...copiedOriginal }, + ...(isAppliedAtRequest + ? getRequestTimeDefaultFileData({ + original: copiedOriginal, + transforms: pipelineDoc._transforms, + }) + : { ...copiedOriginal, _stored: null, url: null }), + variants: hasRequestTimeVariants + ? await getRequestTimeVariants({ + collection, + documentID: id, + filename: copiedOriginal.filename, + original: copiedOriginal, + prefix: copiedOriginal.prefix, + req: replayReq, + staticPath: collection.upload.disableLocalStorage + ? undefined + : collection.upload.staticDir, + }) + : {}, + } + return write(normalizeURLs({ restored: clearRemovedVariants({ restored }) })) + }, + }) + } + + const response = await retrieveFileResponse({ + collection: { config: collection }, + doc: selected as TypeWithID, + filename: original.filename, + isOriginal: true, + operation: 'transform', + req: replayReq, + }) + if (!response.ok) { + throw new APIError('Unable to read the retained original for restore.', 400) + } + const buffer = Buffer.from(await response.arrayBuffer()) + replayReq.file = { + name: original.filename, + data: buffer, + mimetype: original.mimeType, + size: buffer.length, + } + const { data, files } = await generateFileData({ + collection: { config: collection }, + config: req.payload.config, + data: pipelineDoc, + operation: 'create', + overwriteExistingFiles: false, + req: replayReq, + }) + const finalTransformError = await getTransformStateError({ original, value: data._transforms }) + if (finalTransformError) { + throw new ValidationError( + { + collection: collection.slug, + errors: [{ message: finalTransformError, path: '_transforms' }], + req, + }, + req.t, + ) + } + + // The retained original is immutable. Reuse it instead of storing another copy or archiving + // its selected historical reference when only the generated outputs change. + data.original = { ...original } + const outputFiles = files.filter(({ role }) => role?.type !== 'original') + const restored = normalizeURLs({ restored: clearRemovedVariants({ restored: data }) }) + const writeRestored = () => { + if (collection.upload.fileOperations) { + // Keep the existing adapter metadata hook active through the outer restore's afterChange. + req.context._payloadManagedCloudStorage = true + req.context._payloadManagedCloudMetadata = replayReq.context._payloadManagedCloudMetadata + } + return write(restored) + } + + return collection.upload.fileOperations + ? runCloudFileUpdate({ + id, + collection, + current, + data: restored, + files: outputFiles, + req: replayReq, + write: writeRestored, + }) + : runLocalFileUpdate({ + id, + collection, + current, + files: outputFiles, + next: restored, + req: replayReq, + write: writeRestored, + }) +} diff --git a/packages/payload/src/uploads/fileVersioning/storedFiles.spec.ts b/packages/payload/src/uploads/fileVersioning/storedFiles.spec.ts index c6b01cf8264..da393f86e1d 100644 --- a/packages/payload/src/uploads/fileVersioning/storedFiles.spec.ts +++ b/packages/payload/src/uploads/fileVersioning/storedFiles.spec.ts @@ -49,3 +49,20 @@ it.for([ expect(files).toEqual(expect.arrayContaining(expected)) expect(files).toHaveLength(expected.length) }) + +it('should retain an archived original whose saved URL still asks for the original role', () => { + const files = collectStoredFiles({ + collection: { slug: 'media', upload: {} } as SanitizedCollectionConfig, + doc: { + _stored: false, + filename: 'photo.png', + original: { + filename: 'photo-version.png', + url: '/api/media/file/photo-version.png?original=true', + }, + }, + req: { payload: { config: { routes: { api: '/api' } } } } as PayloadRequest, + }) + + expect(files).toEqual([{ key: 'photo-version.png', roles: [{ type: 'original' }] }]) +}) diff --git a/packages/payload/src/uploads/fileVersioning/storedFiles.ts b/packages/payload/src/uploads/fileVersioning/storedFiles.ts index 24eaeb53510..591c2594e8d 100644 --- a/packages/payload/src/uploads/fileVersioning/storedFiles.ts +++ b/packages/payload/src/uploads/fileVersioning/storedFiles.ts @@ -85,7 +85,18 @@ export const collectStoredFiles = ({ isOriginal: role.type === 'original' && shouldRequestOriginal({ doc }), relative: true, urlOrPath: undefined, - }) + }) && + // Archiving can rename the original away from the main file while retaining its role query. + (role.type !== 'original' || + representation.url !== + generateFilePathOrURL({ + collectionSlug: collection.slug, + config: req.payload.config, + filename, + isOriginal: true, + relative: true, + urlOrPath: undefined, + })) ) { return } diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 8d72d51acc5..2becb974278 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -12,8 +12,7 @@ import type { SanitizedConfig } from '../config/types.js' import type { Document, PayloadRequest } from '../types/index.js' import type { ExternalUploadSource } from './sanitizeUploadData.js' import type { PreparedUploadTransformation } from './transformers/uploadTransformerBridge.js' -import type { PixelCrop } from './transformState/cropUnits.js' -import type { FileData, FileSizes, FileToSave, OriginalFileData, UploadEdits } from './types.js' +import type { FileData, FileSizes, FileToSave, UploadEdits } from './types.js' import { FileRetrievalError, @@ -41,6 +40,7 @@ import { hasFullFileContents } from './hasFullFileContents.js' import { isProcessableImage } from './isProcessableImage.js' import { isRequestTimeFile } from './isRequestTimeFile.js' import { parseFilename } from './parseFilename.js' +import { getRequestTimeDefaultFileData, getRequestTimeVariants } from './requestTimeFileData.js' import { transformUploadFile } from './transformers/transformUploadFile.js' import { getUploadTransformerInternal, @@ -1134,37 +1134,15 @@ const processUpload = async ({ requiredTransformerSlug: variantsTransformerSlug, })) ) { - // The size is built from the original, so it takes the original's extension. - const { name: baseName } = parseFilename(fileData.filename!) - const baseExt = getFileExtension(fileData.original.filename!) - const plannedNames = new Set([fileData.filename, fileData.original.filename]) - const variants: FileSizes = {} - - for (const { name: variantName } of declaredVariants) { - const filename = await getUnplannedSafeFileName({ - canUseName: (name) => !plannedNames.has(name), - collectionSlug: collectionConfig.slug, - desiredFilename: `${baseName}-${variantName}${baseExt ? `.${baseExt}` : ''}`, - // A size with no stored file can keep a name this document used before. - excludeDocumentID: documentID, - prefix, - req, - staticPath: disableLocalStorage ? undefined : staticPath!, - }) - plannedNames.add(filename) - variants[variantName] = { - _stored: false, - filename, - filesize: null, - height: null, - mimeType: null, - prefix: fileData.original.prefix, - url: null, - width: null, - } - } - - fileData.variants = variants + fileData.variants = await getRequestTimeVariants({ + collection: collectionConfig, + documentID, + filename: fileData.filename!, + original: fileData.original, + prefix, + req, + staticPath: disableLocalStorage ? undefined : staticPath!, + }) } } catch (err) { throw toFileUploadError({ err, req }) @@ -1183,33 +1161,6 @@ const processUpload = async ({ } } -/** - * The default file entry of an upload whose saved `_transforms` are applied when the file is - * requested. It has the original's name and no object. Its size is the saved crop, or the - * original's size, and its file size is unknown until the file is built. - */ -function getRequestTimeDefaultFileData({ - original, - transforms, -}: { - original: OriginalFileData - transforms: Document['_transforms'] -}) { - const crop = (transforms as { crop?: PixelCrop } | null | undefined)?.crop - - return { - _objectKey: null, - _stored: false, - filename: original.filename, - filesize: null, - height: crop?.height ?? original.height ?? null, - mimeType: original.mimeType, - prefix: original.prefix, - url: null, - width: crop?.width ?? original.width ?? null, - } -} - /** Checks the file, and uses the detected MIME type when the file is a processable image. */ const checkFileAndUseDetectedType = async ({ collection, diff --git a/packages/payload/src/uploads/requestTimeFileData.ts b/packages/payload/src/uploads/requestTimeFileData.ts new file mode 100644 index 00000000000..0661b5ee9ed --- /dev/null +++ b/packages/payload/src/uploads/requestTimeFileData.ts @@ -0,0 +1,80 @@ +import type { SanitizedCollectionConfig } from '../collections/config/types.js' +import type { Document, PayloadRequest } from '../types/index.js' +import type { PixelCrop } from './transformState/cropUnits.js' +import type { FileSizes, OriginalFileData } from './types.js' + +import { getFileExtension } from './getFileTypeIdentity.js' +import { getUnplannedSafeFileName } from './getSafeFilename.js' +import { parseFilename } from './parseFilename.js' + +/** The default entry describes the saved crop; its bytes are built from the original on request. */ +export function getRequestTimeDefaultFileData({ + original, + transforms, +}: { + original: OriginalFileData + transforms: Document['_transforms'] +}) { + const crop = (transforms as { crop?: PixelCrop } | null | undefined)?.crop + + return { + _objectKey: null, + _stored: false, + filename: original.filename, + filesize: null, + height: crop?.height ?? original.height ?? null, + mimeType: original.mimeType, + prefix: original.prefix, + url: null, + width: crop?.width ?? original.width ?? null, + } +} + +/** Generate names from the current size declarations, without retaining old virtual metadata. */ +export async function getRequestTimeVariants({ + collection, + documentID, + filename, + original, + prefix, + req, + staticPath, +}: { + collection: SanitizedCollectionConfig + documentID?: number | string + filename: string + original: OriginalFileData + prefix?: string + req: PayloadRequest + staticPath?: string +}): Promise { + const { name: baseName } = parseFilename(filename) + const baseExt = getFileExtension(original.filename) + const plannedNames = new Set([filename, original.filename]) + const variants: FileSizes = {} + + for (const { name: variantName } of collection.upload.variants ?? []) { + const variantFilename = await getUnplannedSafeFileName({ + canUseName: (name) => !plannedNames.has(name), + collectionSlug: collection.slug, + desiredFilename: `${baseName}-${variantName}${baseExt ? `.${baseExt}` : ''}`, + excludeDocumentID: documentID, + prefix, + req, + staticPath, + }) + plannedNames.add(variantFilename) + variants[variantName] = { + _stored: false, + filename: variantFilename, + filesize: null, + height: null, + mimeType: null, + prefix: original.prefix, + url: null, + width: null, + } + } + + return variants +} diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts index 1e235930aba..91d858bd14d 100644 --- a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts @@ -37,17 +37,21 @@ import { withFileTransformAccessContext } from './withFileTransformAccessContext export async function handleDynamicFileRequest({ collection, filename, + historicalDocument, isOriginal: isOriginalParam, prefix, req, }: { collection: Collection filename: string + /** An authorized version snapshot; source retrieval and planning must never use current data. */ + historicalDocument?: ResolvedUploadDocument isOriginal?: boolean prefix?: string req: PayloadRequest }): Promise { - const resolvedDocument = await resolveUploadDocument({ collection, filename, prefix, req }) + const resolvedDocument = + historicalDocument ?? (await resolveUploadDocument({ collection, filename, prefix, req })) if (!resolvedDocument) { // There's no mimeType to plan a pipeline from, so both access modes must pass @@ -95,6 +99,7 @@ export async function handleDynamicFileRequest({ const { document, documentSnapshot, pipeline } = await authorizeDocument({ collection, filename, + isHistorical: Boolean(historicalDocument), prefix, req, resolvedDocument, @@ -308,12 +313,14 @@ async function planRequestPipeline({ document, documentSnapshot, filename, + isHistorical, req, }: { collection: Collection document: ResolvedUploadDocument documentSnapshot: Readonly filename: string + isHistorical: boolean req: PayloadRequest }): Promise { const requestedFile = getRequestedFile({ document, filename }) @@ -346,6 +353,22 @@ async function planRequestPipeline({ } plan = persistedDefaultPlan + + if ( + isHistorical && + requestedFile.variantName && + (!collection.config.upload.variants?.some( + ({ name }) => name === requestedFile.variantName, + ) || + !plan.pipeline.some( + ({ slug }) => slug === collection.config.upload.variantsTransformerSlug, + )) + ) { + throw new APIError( + `No configured transformer can build historical size "${requestedFile.variantName}".`, + httpStatus.INTERNAL_SERVER_ERROR, + ) + } } else { plan = await planTransformerPipeline({ args: { ...planArgs, operation: 'request', originalDoc: documentSnapshot, purpose }, @@ -389,12 +412,14 @@ type AccessResult = { document?: ResolvedUploadDocument; isAllowed: true } | { i async function authorizeDocument({ collection, filename, + isHistorical, prefix, req, resolvedDocument, }: { collection: Collection filename: string + isHistorical: boolean prefix?: string req: PayloadRequest resolvedDocument: ResolvedUploadDocument @@ -411,7 +436,13 @@ async function authorizeDocument({ accessResults.set( isTransform, withFileTransformAccessContext({ - callback: () => checkFileAccess({ collection, filename, prefix, req }), + callback: () => + checkFileAccess({ + collection, + filename, + ...(isHistorical ? { documentID: resolvedDocument.id } : { prefix }), + req, + }), isTransform, req, }).then( @@ -453,7 +484,7 @@ async function authorizeDocument({ } const document = - access.document && access.document.id !== resolvedDocument.id + !isHistorical && access.document && access.document.id !== resolvedDocument.id ? access.document : resolvedDocument @@ -463,6 +494,7 @@ async function authorizeDocument({ document, documentSnapshot, filename, + isHistorical, req, }) // Only the request's own parameters make it a transform. Applying the saved state is an ordinary diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index 1972757df7f..26def901733 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -77,7 +77,10 @@ export const getAfterChangeHook = return withProxyPrefix(doc) } - req.context.skipCloudStorage = true + const originalContext = req.context + req.context = { ...originalContext, skipCloudStorage: true } + // Metadata is a partial update, even when the enclosing operation restores a full version. + delete req.context.isRestoringVersion req.file = undefined req.payloadUploadSizes = undefined const originalQuery = req.query @@ -99,7 +102,7 @@ export const getAfterChangeHook = return withProxyPrefix({ ...doc, ...updatedDoc }) } finally { req.query = originalQuery - delete req.context.skipCloudStorage + req.context = originalContext } } diff --git a/test/upload-dynamic/collections/VersionedCloudMedia.ts b/test/upload-dynamic/collections/VersionedCloudMedia.ts new file mode 100644 index 00000000000..1d0cc62e4e1 --- /dev/null +++ b/test/upload-dynamic/collections/VersionedCloudMedia.ts @@ -0,0 +1,15 @@ +import type { CollectionConfig } from 'payload' + +import { versionedCloudMediaSlug } from '../shared.js' +import { PublicCloudMedia } from './PublicCloudMedia.js' + +export const VersionedCloudMedia: CollectionConfig = { + ...PublicCloudMedia, + slug: versionedCloudMediaSlug, + access: { ...PublicCloudMedia.access, readVersions: () => true }, + fields: [ + { name: 'alt', type: 'text', localized: true }, + { name: 'storageMarker', type: 'text' }, + ], + versions: { drafts: true }, +} diff --git a/test/upload-dynamic/collections/VersionedDynamicMedia.ts b/test/upload-dynamic/collections/VersionedDynamicMedia.ts new file mode 100644 index 00000000000..cf331c49bc1 --- /dev/null +++ b/test/upload-dynamic/collections/VersionedDynamicMedia.ts @@ -0,0 +1,20 @@ +import type { CollectionConfig } from 'payload' + +import { versionedDynamicMediaDir, versionedDynamicMediaSlug } from '../shared.js' +import { DynamicMedia } from './DynamicMedia.js' + +export const VersionedDynamicMedia: CollectionConfig = { + ...DynamicMedia, + slug: versionedDynamicMediaSlug, + access: { + ...DynamicMedia.access, + readVersions: () => true, + }, + fields: [{ name: 'alt', type: 'text', localized: true }], + upload: { + ...(typeof DynamicMedia.upload === 'object' ? DynamicMedia.upload : {}), + staticDir: versionedDynamicMediaDir, + }, + trash: true, + versions: { drafts: true }, +} diff --git a/test/upload-dynamic/config.ts b/test/upload-dynamic/config.ts index 7d017f8c817..dc9e8f75e91 100644 --- a/test/upload-dynamic/config.ts +++ b/test/upload-dynamic/config.ts @@ -7,7 +7,9 @@ import { devUser } from '../credentials.js' import { publicVersionedCloudAdapter } from '../plugin-cloud-storage/versionedCloudStorage.js' import { DynamicMedia } from './collections/DynamicMedia.js' import { PublicCloudMedia } from './collections/PublicCloudMedia.js' -import { publicCloudMediaSlug } from './shared.js' +import { VersionedCloudMedia } from './collections/VersionedCloudMedia.js' +import { VersionedDynamicMedia } from './collections/VersionedDynamicMedia.js' +import { publicCloudMediaSlug, versionedCloudMediaSlug } from './shared.js' import { requestCropTransformer, uploadBridgeTransformer, @@ -24,7 +26,8 @@ export default buildConfigWithDefaults({ baseDir: path.resolve(dirname), }, }, - collections: [DynamicMedia, PublicCloudMedia], + collections: [DynamicMedia, PublicCloudMedia, VersionedDynamicMedia, VersionedCloudMedia], + localization: { defaultLocale: 'en', locales: ['en', 'es'] }, plugins: [ cloudStoragePlugin({ collections: { @@ -32,6 +35,10 @@ export default buildConfigWithDefaults({ adapter: publicVersionedCloudAdapter, disablePayloadAccessControl: true, }, + [versionedCloudMediaSlug]: { + adapter: publicVersionedCloudAdapter, + disablePayloadAccessControl: true, + }, }, }), ], diff --git a/test/upload-dynamic/history.int.spec.ts b/test/upload-dynamic/history.int.spec.ts new file mode 100644 index 00000000000..dcdcd73cd27 --- /dev/null +++ b/test/upload-dynamic/history.int.spec.ts @@ -0,0 +1,387 @@ +import type { FileData, Payload } from 'payload' + +import { readdir, readFile, rm } from 'fs/promises' +import path from 'path' +import sharp from 'sharp' +import { fileURLToPath } from 'url' +import { expect } from 'vitest' + +// eslint-disable-next-line payload/no-relative-monorepo-imports -- Exercise the same inherited policy wrapper used during config sanitization. +import { createInheritedReadVersionsAccess } from '../../packages/payload/src/collections/config/defaults.js' +import { test } from '../__helpers/int/vitest.js' +import { versionedCloudFiles } from '../plugin-cloud-storage/versionedCloudStorage.js' +import { sourceReadCalls } from './collections/DynamicMedia.js' +import { + versionedCloudMediaSlug, + versionedDynamicMediaDir, + versionedDynamicMediaSlug, +} from './shared.js' +import { requestCropCalls, signedImagePlans } from './transformers.js' + +const imageFixture = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../uploads/image.png', +) +const savedCrop = { height: 600, width: 800, x: 100, y: 200 } +const currentCrop = { height: 100, width: 200, x: 0, y: 0 } + +test.suite('Historical request-time files', { config: './config.ts' }, () => { + test.afterEach(async () => { + requestCropCalls.length = 0 + signedImagePlans.length = 0 + sourceReadCalls.length = 0 + versionedCloudFiles.clear() + await rm(versionedDynamicMediaDir, { force: true, recursive: true }) + }) + + test('should render the historical main file without changing its saved state or files', async ({ + payload, + restClient, + }) => { + const { bytes, selected } = await replaceImage({ payload }) + const filesBefore = (await readdir(versionedDynamicMediaDir)).sort() + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${selected.version.filename}?version=${selected.id}`, + { auth: false }, + ) + + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer())).toEqual( + await sharp(bytes) + .extract({ + left: savedCrop.x, + top: savedCrop.y, + width: savedCrop.width, + height: savedCrop.height, + }) + .png() + .toBuffer(), + ) + expect(requestCropCalls).toEqual([ + { crop: savedCrop, purpose: 'persisted-default', variantName: undefined }, + ]) + expect((await readdir(versionedDynamicMediaDir)).sort()).toEqual(filesBefore) + expect(await readVersion({ id: selected.id, payload })).toEqual(selected) + }) + + test('should serve version API URLs when the main file and original share a filename', async ({ + payload, + restClient, + }) => { + const bytes = await readFile(imageFixture) + const created = await payload.create({ + collection: versionedDynamicMediaSlug, + data: { _status: 'published', _transforms: { crop: savedCrop } }, + file: { name: 'shared.png', data: bytes, mimetype: 'image/png', size: bytes.length }, + }) + const { docs } = await payload.findVersions({ + collection: versionedDynamicMediaSlug, + where: { parent: { equals: created.id } }, + }) + const version = docs[0]!.version + + expect(version.filename).toBe(version.original!.filename) + expect(version.url).toContain('version=') + expect(version.original!.url).toContain('original=true') + + for (const entry of [version, version.original!]) { + const url = new URL(entry.url!, 'http://localhost') + + url.searchParams.set('prefix', '') + if (entry === version.original) { + url.searchParams.set('w', '50') + } + + const response = await restClient.GET(`/${url.pathname.replace(/^\/api\//, '')}${url.search}`) + + expect(response.status).toBe(200) + expect(await sharp(Buffer.from(await response.arrayBuffer())).metadata()).toMatchObject({ + height: entry === version ? 600 : 1600, + width: entry === version ? 800 : 1600, + }) + } + + expect(requestCropCalls).toHaveLength(1) + }) + + test('should check transform access before signing a historical override and keep the selected snapshot', async ({ + payload, + restClient, + }) => { + const { selected } = await replaceImage({ payload }) + const url = + `/${versionedDynamicMediaSlug}/file/${selected.version.variants!.card!.filename}?version=${selected.id}&w=200&q=70` as const + const headers = { 'x-test-sign-image': 'true' } + + expect((await restClient.GET(url, { auth: false, headers })).status).toBe(403) + expect(signedImagePlans).toHaveLength(0) + expect(sourceReadCalls).toHaveLength(0) + + const response = await restClient.GET(url, { headers, redirect: 'manual' }) + + expect(response.status).toBe(302) + expect(signedImagePlans).toHaveLength(1) + expect(signedImagePlans[0]).toMatchObject({ + document: { alt: selected.version.alt, id: selected.parent }, + crop: savedCrop, + original: selected.version.original, + overrides: { quality: 70, width: 200 }, + variantName: 'card', + }) + expect(sourceReadCalls).toHaveLength(0) + expect(Object.isFrozen(signedImagePlans[0]!.document)).toBe(true) + expect(Object.isFrozen(signedImagePlans[0]!.document._transforms)).toBe(true) + }) + + for (const policy of ['read', 'readVersions'] as const) { + test(`should apply ${policy} constraints in the requested locale before transforming a historical file`, async ({ + payload, + restClient, + }) => { + const { selected } = await replaceImage({ payload }) + const collection = payload.collections[versionedDynamicMediaSlug].config + const originalAccess = collection.access[policy] + + if (policy === 'read') { + await payload.update({ + id: selected.parent, + collection: versionedDynamicMediaSlug, + data: { alt: 'visible' }, + locale: 'en', + }) + await payload.update({ + id: selected.parent, + collection: versionedDynamicMediaSlug, + data: { alt: 'hidden' }, + locale: 'es', + }) + } else { + await payload.db.updateVersion({ + id: selected.id, + collection: versionedDynamicMediaSlug, + versionData: { version: { ...selected.version, alt: { en: 'visible', es: 'hidden' } } }, + }) + } + + try { + collection.access[policy] = () => ({ + [policy === 'read' ? 'alt' : 'version.alt']: { equals: 'visible' }, + }) + const url = + `/${versionedDynamicMediaSlug}/file/${selected.version.filename}?version=${selected.id}` as const + + expect((await restClient.GET(`${url}&locale=es`)).status).toBe(403) + expect(requestCropCalls).toHaveLength(0) + expect(sourceReadCalls).toHaveLength(0) + + const response = await restClient.GET(`${url}&locale=en`) + + expect(response.status).toBe(200) + await response.arrayBuffer() + expect(requestCropCalls).toHaveLength(1) + expect(sourceReadCalls.length).toBeGreaterThan(0) + } finally { + collection.access[policy] = originalAccess + } + }) + } + + test('should pass the parent ID to inherited version access and enforce its saved-field constraint', async ({ + payload, + restClient, + }) => { + const { selected } = await replaceImage({ payload }) + const collection = payload.collections[versionedDynamicMediaSlug].config + const originalReadVersions = collection.access.readVersions + const seenIDs: (number | string | undefined)[] = [] + + try { + collection.access.readVersions = createInheritedReadVersionsAccess(({ id }) => { + seenIDs.push(id) + return { alt: { equals: 'current' } } + }) + + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${selected.version.filename}?version=${selected.id}`, + ) + + expect(response.status).toBe(403) + expect(seenIDs).toEqual([selected.parent]) + expect(requestCropCalls).toHaveLength(0) + expect(sourceReadCalls).toHaveLength(0) + } finally { + collection.access.readVersions = originalReadVersions + } + }) + + test('should deny a trashed version before executing its request transformer', async ({ + payload, + restClient, + }) => { + const { selected } = await replaceImage({ payload }) + + await payload.db.updateVersion({ + id: selected.id, + collection: versionedDynamicMediaSlug, + versionData: { version: { ...selected.version, deletedAt: new Date().toISOString() } }, + }) + + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${selected.version.filename}?version=${selected.id}`, + ) + + expect(response.status).toBe(404) + expect(requestCropCalls).toHaveLength(0) + expect(sourceReadCalls).toHaveLength(0) + }) + + test('should support transform-only access without applying that access context to version reads', async ({ + payload, + restClient, + }) => { + const { selected } = await replaceImage({ payload }) + const collection = payload.collections[versionedDynamicMediaSlug].config + const originalRead = collection.access.read + const originalReadVersions = collection.access.readVersions + const versionAccessModes: (boolean | undefined)[] = [] + const url = + `/${versionedDynamicMediaSlug}/file/${selected.version.variants!.card!.filename}?version=${selected.id}` as const + + try { + collection.access.read = ({ req }) => Boolean(req.fileTransform) + collection.access.readVersions = ({ req }) => { + versionAccessModes.push(req.fileTransform) + return !req.fileTransform + } + + expect((await restClient.GET(url)).status).toBe(403) + expect(requestCropCalls).toHaveLength(0) + expect(sourceReadCalls).toHaveLength(0) + + const response = await restClient.GET(`${url}&w=200`) + + expect(response.status).toBe(200) + expect(await sharp(Buffer.from(await response.arrayBuffer())).metadata()).toMatchObject({ + height: 150, + width: 200, + }) + expect(versionAccessModes.every((value) => !value)).toBe(true) + } finally { + collection.access.read = originalRead + collection.access.readVersions = originalReadVersions + } + }) + + test('should use historical cloud storage identity and reject another prefix', async ({ + payload, + restClient, + }) => { + const { bytes, selected } = await replaceImage({ collection: versionedCloudMediaSlug, payload }) + const filename = selected.version.variants!.card!.filename + const url = `/${versionedCloudMediaSlug}/file/${filename}?version=${selected.id}` as const + + expect((await restClient.GET(`${url}&prefix=wrong`)).status).toBe(404) + + const response = await restClient.GET(`${url}&prefix=tenant`) + + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer())).toEqual( + await sharp(bytes) + .extract({ + left: savedCrop.x, + top: savedCrop.y, + width: savedCrop.width, + height: savedCrop.height, + }) + .resize({ width: 400 }) + .png() + .toBuffer(), + ) + }) + + test('should reject a historical size removed from the current configuration before reading its original', async ({ + payload, + restClient, + }) => { + const { selected } = await replaceImage({ payload }) + const upload = payload.collections[versionedDynamicMediaSlug].config.upload + const variants = upload.variants + + try { + upload.variants = [] + + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${selected.version.variants!.card!.filename}?version=${selected.id}`, + ) + + expect(response.status).toBe(500) + expect(requestCropCalls).toHaveLength(0) + expect(sourceReadCalls).toHaveLength(0) + } finally { + upload.variants = variants + } + }) +}) + +async function replaceImage({ + collection = versionedDynamicMediaSlug, + payload, +}: { + collection?: typeof versionedCloudMediaSlug | typeof versionedDynamicMediaSlug + payload: Payload +}) { + const bytes = await readFile(imageFixture) + const created = await payload.create({ + collection, + data: { + _status: 'published', + _transforms: { crop: savedCrop }, + alt: 'historical', + ...(collection === versionedCloudMediaSlug ? { prefix: 'tenant' } : {}), + }, + file: { name: 'historical.png', data: bytes, mimetype: 'image/png', size: bytes.length }, + }) + const { docs } = await payload.db.findVersions({ + collection, + limit: 1, + where: { parent: { equals: created.id } }, + }) + const selectedID = docs[0]!.id + const nextBytes = await sharp(bytes).flop().png().toBuffer() + + await payload.update({ + id: created.id, + collection, + data: { + _status: 'published', + _transforms: { crop: currentCrop }, + alt: 'current', + ...(collection === versionedCloudMediaSlug ? { prefix: 'current-tenant' } : {}), + }, + file: { name: 'current.png', data: nextBytes, mimetype: 'image/png', size: nextBytes.length }, + }) + requestCropCalls.length = 0 + sourceReadCalls.length = 0 + + return { bytes, created, selected: await readVersion({ collection, id: selectedID, payload }) } +} + +async function readVersion({ + collection = versionedDynamicMediaSlug, + id, + payload, +}: { + collection?: typeof versionedCloudMediaSlug | typeof versionedDynamicMediaSlug + id: number | string + payload: Payload +}) { + const { docs } = await payload.db.findVersions< + { alt?: Record | string } & FileData + >({ + collection, + limit: 1, + where: { id: { equals: id } }, + }) + + return docs[0]! +} diff --git a/test/upload-dynamic/payload-types.ts b/test/upload-dynamic/payload-types.ts index 51d2dfec971..671182403be 100644 --- a/test/upload-dynamic/payload-types.ts +++ b/test/upload-dynamic/payload-types.ts @@ -158,6 +158,8 @@ export interface Config { collections: { 'dynamic-media': DynamicMedia; 'public-cloud-dynamic-media': PublicCloudDynamicMedia; + 'versioned-dynamic-media': VersionedDynamicMedia; + 'versioned-cloud-dynamic-media': VersionedCloudDynamicMedia; 'payload-kv': PayloadKv; users: User; 'payload-locked-documents': PayloadLockedDocument; @@ -169,6 +171,8 @@ export interface Config { collectionsSelect: { 'dynamic-media': DynamicMediaSelect | DynamicMediaSelect; 'public-cloud-dynamic-media': PublicCloudDynamicMediaSelect | PublicCloudDynamicMediaSelect; + 'versioned-dynamic-media': VersionedDynamicMediaSelect | VersionedDynamicMediaSelect; + 'versioned-cloud-dynamic-media': VersionedCloudDynamicMediaSelect | VersionedCloudDynamicMediaSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; @@ -179,10 +183,10 @@ export interface Config { db: { defaultIDType: string; }; - fallbackLocale: null; + fallbackLocale: ('false' | 'none' | 'null') | false | null | ('en' | 'es') | ('en' | 'es')[]; globals: {}; globalsSelect: {}; - locale: null; + locale: 'en' | 'es'; widgets: { welcome: WelcomeWidget; collections: CollectionsWidget; @@ -296,6 +300,8 @@ export interface User { id: string; createdAt?: string | null; expiresAt: string; + refreshCount?: number | null; + refreshedAt?: string | null; }[] | null; password?: string | null; @@ -342,6 +348,118 @@ export interface PublicCloudDynamicMedia { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "versioned-dynamic-media". + */ +export interface VersionedDynamicMedia { + id: string; + alt?: string | null; + createdBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; + _status?: ('draft' | 'published') | null; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + _stored?: boolean | null; + _transforms?: UploadTransforms; + original?: { + filename?: string | null; + url?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + prefix?: string | null; + _objectKey?: string | null; + _stored?: boolean | null; + }; + focalX?: number | null; + focalY?: number | null; + variants?: { + card?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + prefix?: string | null; + _objectKey?: string | null; + _stored?: boolean | null; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "versioned-cloud-dynamic-media". + */ +export interface VersionedCloudDynamicMedia { + id: string; + alt?: string | null; + storageMarker?: string | null; + prefix?: string | null; + _objectKey?: string | null; + createdBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + _stored?: boolean | null; + _transforms?: UploadTransforms; + original?: { + filename?: string | null; + url?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + prefix?: string | null; + _objectKey?: string | null; + _stored?: boolean | null; + }; + focalX?: number | null; + focalY?: number | null; + variants?: { + card?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + prefix?: string | null; + _objectKey?: string | null; + _stored?: boolean | null; + }; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". @@ -374,6 +492,14 @@ export interface PayloadLockedDocument { relationTo: 'public-cloud-dynamic-media'; value: string | PublicCloudDynamicMedia; } | null) + | ({ + relationTo: 'versioned-dynamic-media'; + value: string | VersionedDynamicMedia; + } | null) + | ({ + relationTo: 'versioned-cloud-dynamic-media'; + value: string | VersionedCloudDynamicMedia; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -530,6 +656,116 @@ export interface PublicCloudDynamicMediaSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "versioned-dynamic-media_select". + */ +export interface VersionedDynamicMediaSelect { + alt?: T; + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + deletedAt?: T; + _status?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + _stored?: T; + _transforms?: T; + original?: + | T + | { + filename?: T; + url?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + prefix?: T; + _objectKey?: T; + _stored?: T; + }; + focalX?: T; + focalY?: T; + variants?: + | T + | { + card?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + prefix?: T; + _objectKey?: T; + _stored?: T; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "versioned-cloud-dynamic-media_select". + */ +export interface VersionedCloudDynamicMediaSelect { + alt?: T; + storageMarker?: T; + prefix?: T; + _objectKey?: T; + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + _stored?: T; + _transforms?: T; + original?: + | T + | { + filename?: T; + url?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + prefix?: T; + _objectKey?: T; + _stored?: T; + }; + focalX?: T; + focalY?: T; + variants?: + | T + | { + card?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + prefix?: T; + _objectKey?: T; + _stored?: T; + }; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". @@ -561,6 +797,8 @@ export interface UsersSelect { id?: T; createdAt?: T; expiresAt?: T; + refreshCount?: T; + refreshedAt?: T; }; } /** @@ -638,7 +876,12 @@ export interface CollectionsWidget { export interface CollectionQueryWidget { data?: { title?: string | null; - relatedCollection: 'dynamic-media' | 'public-cloud-dynamic-media' | 'users'; + relatedCollection: + | 'dynamic-media' + | 'public-cloud-dynamic-media' + | 'versioned-dynamic-media' + | 'versioned-cloud-dynamic-media' + | 'users'; where?: | { [k: string]: unknown; @@ -660,7 +903,15 @@ export interface CollectionQueryWidget { */ export interface ActivityWidget { data?: { - excludedCollections?: ('dynamic-media' | 'public-cloud-dynamic-media' | 'users')[] | null; + excludedCollections?: + | ( + | 'dynamic-media' + | 'public-cloud-dynamic-media' + | 'versioned-dynamic-media' + | 'versioned-cloud-dynamic-media' + | 'users' + )[] + | null; }; width: 'small' | 'medium' | 'large' | 'x-large' | 'full'; } @@ -670,7 +921,9 @@ export interface ActivityWidget { */ export interface UploadDropzoneWidget { data?: { - excludedCollections?: ('dynamic-media' | 'public-cloud-dynamic-media')[] | null; + excludedCollections?: + | ('dynamic-media' | 'public-cloud-dynamic-media' | 'versioned-dynamic-media' | 'versioned-cloud-dynamic-media')[] + | null; }; width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; } diff --git a/test/upload-dynamic/restore.int.spec.ts b/test/upload-dynamic/restore.int.spec.ts new file mode 100644 index 00000000000..4b20d3eddb1 --- /dev/null +++ b/test/upload-dynamic/restore.int.spec.ts @@ -0,0 +1,604 @@ +import type { Payload } from 'payload' + +import { readdir, readFile, rm } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' +import { expect, vi } from 'vitest' + +import { test } from '../__helpers/int/vitest.js' +import { + getStoredCloudFiles, + versionedCloudFiles, +} from '../plugin-cloud-storage/versionedCloudStorage.js' +import { sourceReadCalls } from './collections/DynamicMedia.js' +import { + versionedCloudMediaSlug, + versionedDynamicMediaDir, + versionedDynamicMediaSlug, +} from './shared.js' + +const imageFixture = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../uploads/image.png', +) +const savedCrop = { height: 600, width: 800, x: 100, y: 200 } +const laterCrop = { height: 200, width: 300, x: 0, y: 0 } + +const getVersions = async ({ + payload, + id, + collection = versionedDynamicMediaSlug, +}: { + collection?: typeof versionedCloudMediaSlug | typeof versionedDynamicMediaSlug + id: number | string + payload: Payload +}) => + ( + await payload.db.findVersions({ + collection, + pagination: false, + sort: '-updatedAt', + where: { parent: { equals: id } }, + }) + ).docs + +const prepareHistory = async ({ + payload, + collection = versionedDynamicMediaSlug, + replaceOriginal = false, +}: { + collection?: typeof versionedCloudMediaSlug | typeof versionedDynamicMediaSlug + payload: Payload + replaceOriginal?: boolean +}) => { + const bytes = await readFile(imageFixture) + const created = await payload.create({ + collection, + data: { _status: 'published', _transforms: { crop: savedCrop }, alt: 'selected' }, + file: { data: bytes, mimetype: 'image/png', name: 'restore.png', size: bytes.length }, + }) + + const replacement = replaceOriginal ? await sharp(bytes).negate().toBuffer() : undefined + + await payload.update({ + id: created.id, + collection, + data: { _transforms: { crop: laterCrop }, alt: 'current' }, + file: replacement && { + data: replacement, + mimetype: 'image/png', + name: 'replacement.png', + size: replacement.length, + }, + }) + + const docs = await getVersions({ payload, id: created.id, collection }) + const selected = docs.find(({ version }) => version._transforms?.crop?.width === savedCrop.width)! + + expect(selected).toBeDefined() + sourceReadCalls.length = 0 + + return { created, selected } +} + +test.suite('Restore request-time files', { config: './config.ts' }, () => { + test.afterEach(async () => { + vi.restoreAllMocks() + sourceReadCalls.length = 0 + versionedCloudFiles.clear() + await rm(versionedDynamicMediaDir, { force: true, recursive: true }) + }) + + test('should replay saved transforms through the current upload pipeline when restoring a dynamic file', async ({ + payload, + restClient, + }) => { + const { created, selected } = await prepareHistory({ payload, replaceOriginal: true }) + const before = structuredClone(selected.version) + const filesBeforeRestore = new Set(await readdir(versionedDynamicMediaDir)) + const restored = await payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }) + + expect(restored._stored).not.toBe(false) + expect(restored._transforms).toEqual({ crop: savedCrop }) + expect(restored).toMatchObject({ height: 600, width: 800 }) + expect( + await readFile(path.join(versionedDynamicMediaDir, restored.original!.filename!)), + ).toEqual(await readFile(imageFixture)) + + const versions = await getVersions({ payload, id: created.id }) + expect(versions.find(({ id }) => id === selected.id)!.version).toEqual(before) + expect(restored.id).toBe(created.id) + const latest = versions.find(({ latest }) => latest)! + expect(latest.version.url).toBe(`/api/${versionedDynamicMediaSlug}/file/${restored.filename}`) + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${restored.filename}?version=${latest.id}`, + ) + expect(response.status).toBe(200) + const selectedOutput = await sharp(await readFile(imageFixture)) + .extract({ + left: savedCrop.x, + top: savedCrop.y, + width: savedCrop.width, + height: savedCrop.height, + }) + .toBuffer() + expect(Buffer.from(await response.arrayBuffer())).toEqual(selectedOutput) + await payload.delete({ id: restored.id, collection: versionedDynamicMediaSlug, trash: false }) + const remainingFiles = await readdir(versionedDynamicMediaDir) + expect(remainingFiles).not.toContain(restored.filename) + expect(remainingFiles.filter((filename) => !filesBeforeRestore.has(filename))).toEqual([]) + }) + + test('should reject unsupported restored transforms before changing stored files or the current document', async ({ + payload, + }) => { + const { created, selected } = await prepareHistory({ payload }) + const transformer = payload.config.upload.transformers.find( + ({ slug }) => slug === 'request-crop', + )! + const beforeFiles = await readdir(versionedDynamicMediaDir) + const before = await payload.db.findOne({ + collection: versionedDynamicMediaSlug, + where: { id: { equals: created.id } }, + }) + + vi.spyOn(transformer, 'canTransform').mockReturnValue(false) + + await expect( + payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + overrideAccess: true, + }), + ).rejects.toThrow() + expect(await readdir(versionedDynamicMediaDir)).toEqual(beforeFiles) + expect( + await payload.db.findOne({ + collection: versionedDynamicMediaSlug, + where: { id: { equals: created.id } }, + }), + ).toEqual(before) + expect(sourceReadCalls).toHaveLength(0) + }) + + test('should restore a request-time file without reading source bytes and serve the restored crop', async ({ + payload, + restClient, + }) => { + const { selected } = await prepareHistory({ payload }) + const restored = await payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + overrideAccess: true, + }) + + expect(sourceReadCalls).toHaveLength(0) + expect(restored._stored).toBe(false) + expect(restored.filename).toBe(restored.original?.filename) + expect(restored._transforms).toEqual({ crop: savedCrop }) + + const response = await restClient.GET(`/${versionedDynamicMediaSlug}/file/${restored.filename}`) + + expect(response.status).toBe(200) + expect(await sharp(Buffer.from(await response.arrayBuffer())).metadata()).toMatchObject({ + height: 600, + width: 800, + }) + }) + + test('should reconcile virtual variants with the current size declarations', async ({ + payload, + }) => { + const { selected } = await prepareHistory({ payload }) + const upload = payload.collections[versionedDynamicMediaSlug].config.upload + const variants = upload.variants + try { + upload.variants = [] + const removed = await payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + overrideAccess: true, + }) + const versions = await getVersions({ payload, id: removed.id }) + const withoutVariants = versions.find(({ latest }) => latest)! + expect(withoutVariants.version.variants?.card?.filename).toBeFalsy() + expect(withoutVariants.version.variants?.card?._stored).not.toBe(false) + + upload.variants = variants + const restored = await payload.restoreVersion({ + id: String(withoutVariants.id), + collection: versionedDynamicMediaSlug, + overrideAccess: true, + }) + expect(restored.variants?.card).toMatchObject({ + _stored: false, + filesize: null, + height: null, + width: null, + }) + expect(restored.variants?.card?.filename).toBeTruthy() + expect(sourceReadCalls).toHaveLength(0) + } finally { + upload.variants = variants + } + }) + + test('should replay virtual variants with a stored main and focal-only transforms', async ({ + payload, + restClient, + }) => { + const created = await payload.create({ + collection: versionedDynamicMediaSlug, + data: { _transforms: { focalPoint: { x: 25, y: 75 } } }, + filePath: imageFixture, + }) + expect(created._stored).not.toBe(false) + expect(created.variants?.card?._stored).toBe(false) + const docs = await getVersions({ payload, id: created.id }) + await payload.update({ + id: created.id, + collection: versionedDynamicMediaSlug, + data: { _transforms: { crop: laterCrop } }, + }) + sourceReadCalls.length = 0 + const restored = await payload.restoreVersion({ + id: String(docs[0]!.id), + collection: versionedDynamicMediaSlug, + overrideAccess: true, + }) + expect(restored._stored).not.toBe(false) + expect(restored._transforms).toEqual({ focalPoint: { x: 25, y: 75 } }) + expect(restored.variants?.card).toMatchObject({ + _stored: false, + filesize: null, + height: null, + width: null, + }) + expect(sourceReadCalls).toHaveLength(0) + const versions = await getVersions({ payload, id: restored.id }) + const latest = versions.find(({ latest }) => latest)! + expect(latest.version.url).toBe(`/api/${versionedDynamicMediaSlug}/file/${restored.filename}`) + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${restored.filename}?version=${latest.id}`, + ) + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer())).toEqual(await readFile(imageFixture)) + }) + + test('should keep all locales and file identity when the response selects only a custom field', async ({ + payload, + }) => { + const created = await payload.create({ + collection: versionedDynamicMediaSlug, + data: { _transforms: { crop: savedCrop }, alt: 'English' }, + locale: 'en', + filePath: imageFixture, + }) + await payload.update({ + id: created.id, + collection: versionedDynamicMediaSlug, + locale: 'es', + data: { alt: 'Español' }, + }) + const docs = await getVersions({ payload, id: created.id }) + await payload.update({ + id: created.id, + collection: versionedDynamicMediaSlug, + data: { _transforms: { crop: laterCrop }, alt: 'Changed' }, + }) + const restored = await payload.restoreVersion({ + id: String(docs[0]!.id), + collection: versionedDynamicMediaSlug, + select: { alt: true }, + overrideAccess: true, + }) + expect(restored.alt).toBe('English') + expect(restored.filename).toBeUndefined() + const versions = await getVersions({ payload, id: created.id }) + const latest = versions.find(({ latest }) => latest)!.version + expect(latest).toMatchObject({ + alt: { en: 'English', es: 'Español' }, + _transforms: { crop: savedCrop }, + }) + expect(latest.filename).toBeTruthy() + expect(latest.original.filename).toBeTruthy() + }) + + test('should persist transform state changed by the upload pipeline', async ({ payload }) => { + const { selected } = await prepareHistory({ payload }) + const selectedSnapshot = structuredClone(selected.version) + const transformer = payload.config.upload.transformers.find( + ({ slug }) => slug === 'upload-crop', + )! + const transformFile = transformer.transformFile! + vi.spyOn(transformer, 'transformFile').mockImplementation(async (args) => { + expect(args.doc.alt).toEqual({ en: 'selected' }) + expect(args.originalDoc.alt).toEqual({ en: 'selected' }) + expect(Object.isFrozen(args.originalDoc)).toBe(true) + const result = await transformFile(args) + args.doc._transforms = { ...args.doc._transforms, replayed: true } + return result + }) + const restored = await payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }) + expect(restored._transforms).toEqual({ crop: savedCrop, replayed: true }) + const versions = await getVersions({ payload, id: restored.id }) + expect(versions.find(({ id }) => id === selected.id)!.version).toEqual(selectedSnapshot) + expect(versions.find(({ latest }) => latest)!.version._transforms).toEqual({ + crop: savedCrop, + replayed: true, + }) + }) + + test('should keep caller upload bytes out of cloud restore hooks', async ({ payload }) => { + const { selected } = await prepareHistory({ payload, collection: versionedCloudMediaSlug }) + const callerBytes = Buffer.from('not the retained original') + const callerFile = { + name: 'unrelated.png', + data: callerBytes, + mimetype: 'image/png', + size: callerBytes.length, + } + const callerContext = { + isUploadCropEnabled: true, + _payloadCloudStorage: { file: callerFile }, + _payloadManagedCloudMetadata: { filename: 'unrelated.png' }, + _payloadManagedCloudStorage: true, + _payloadVerifiedProviderOriginal: { key: 'unrelated-original' }, + skipCloudStorage: true, + } + const restored = await payload.restoreVersion({ + id: String(selected.id), + collection: versionedCloudMediaSlug, + context: callerContext, + overrideAccess: true, + req: { + file: callerFile, + query: { uploadEdits: { crop: { height: 10, width: 10, x: 0, y: 0 } } }, + }, + }) + expect(restored._transforms).toEqual({ crop: savedCrop }) + const storedDefault = getStoredCloudFiles(restored).find(({ roles }) => + roles.some(({ type }) => type === 'default'), + )! + expect(await sharp(versionedCloudFiles.get(storedDefault.key)!).metadata()).toMatchObject({ + height: 600, + width: 800, + }) + expect(callerContext._payloadCloudStorage.file).toBe(callerFile) + expect(callerContext.skipCloudStorage).toBe(true) + expect(callerContext._payloadManagedCloudStorage).toBe(true) + expect(callerContext._payloadVerifiedProviderOriginal.key).toBe('unrelated-original') + }) + + test('should reject an upload stage crop that exceeds the retained original before storing output', async ({ + payload, + }) => { + const { selected } = await prepareHistory({ payload }) + const beforeFiles = (await readdir(versionedDynamicMediaDir)).sort() + const transformer = payload.config.upload.transformers.find( + ({ slug }) => slug === 'upload-crop', + )! + const transformFile = transformer.transformFile! + vi.spyOn(transformer, 'transformFile').mockImplementation(async (args) => { + const result = await transformFile(args) + args.doc._transforms = { crop: { ...savedCrop, width: 9999 } } + return result + }) + await expect( + payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }), + ).rejects.toThrow() + expect((await readdir(versionedDynamicMediaDir)).sort()).toEqual(beforeFiles) + }) + + test('should preserve the selected stored main and original when replaying its virtual variants', async ({ + payload, + restClient, + }) => { + const created = await payload.create({ + collection: versionedDynamicMediaSlug, + data: { _transforms: { crop: savedCrop }, alt: 'selected stored' }, + context: { isUploadCropEnabled: true }, + filePath: imageFixture, + }) + const docs = await getVersions({ payload, id: created.id }) + const selected = docs[0]! + const selectedMain = await readFile( + path.join(versionedDynamicMediaDir, selected.version.filename), + ) + const selectedOriginal = structuredClone(selected.version.original) + await payload.update({ + id: created.id, + collection: versionedDynamicMediaSlug, + data: { alt: 'current' }, + }) + await payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }) + const after = await getVersions({ payload, id: created.id }) + const retained = after.find(({ id }) => id === selected.id)!.version + expect(retained).toMatchObject({ + _transforms: { crop: savedCrop }, + alt: { en: 'selected stored' }, + original: selectedOriginal, + }) + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${retained.filename}?version=${selected.id}`, + ) + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer())).toEqual(selectedMain) + }) + + test('should persist current adapter metadata when restoring a selected draft response', async ({ + payload, + }) => { + const { created, selected } = await prepareHistory({ + payload, + collection: versionedCloudMediaSlug, + }) + const currentBefore = await payload.db.findOne({ + collection: versionedCloudMediaSlug, + where: { id: { equals: created.id } }, + }) + const restored = await payload.restoreVersion({ + id: String(selected.id), + collection: versionedCloudMediaSlug, + context: { isUploadCropEnabled: true }, + draft: true, + select: { storageMarker: true }, + overrideAccess: true, + }) + expect(restored.storageMarker).toBeTruthy() + expect(restored.storageMarker).not.toBe(selected.version.storageMarker) + expect(restored.filename).toBeUndefined() + expect(restored._status).toBeUndefined() + const currentAfter = await payload.db.findOne({ + collection: versionedCloudMediaSlug, + where: { id: { equals: created.id } }, + }) + expect(currentAfter).toEqual(currentBefore) + const versions = await getVersions({ + payload, + id: created.id, + collection: versionedCloudMediaSlug, + }) + const latest = versions.find(({ latest }) => latest)!.version + expect(latest).toMatchObject({ + storageMarker: restored.storageMarker, + _transforms: { crop: savedCrop }, + _status: { en: 'draft' }, + }) + expect(latest.filename).toBeTruthy() + }) + + test.options( + 'should validate adapter metadata and roll back the restored files and document if it fails', + { db: (adapter) => adapter !== 'sqlite' }, + async ({ payload }) => { + const { created, selected } = await prepareHistory({ + payload, + collection: versionedCloudMediaSlug, + }) + const currentBefore = await payload.db.findOne({ + collection: versionedCloudMediaSlug, + where: { id: { equals: created.id } }, + }) + const filesBefore = new Map(versionedCloudFiles) + const collection = payload.collections[versionedCloudMediaSlug].config + const operations = collection.upload.fileOperations! + const stage = operations.stage + vi.spyOn(operations, 'stage').mockImplementation(async (args) => { + const result = await stage(args) + return { metadata: { ...result.metadata, storageMarker: 'invalid' } } + }) + const field = collection.fields.find( + (field) => 'name' in field && field.name === 'storageMarker', + )! + const validate = 'validate' in field ? field.validate : undefined + try { + Object.assign(field, { + validate: (value: string) => (value === 'invalid' ? 'Invalid adapter metadata' : true), + }) + await expect( + payload.restoreVersion({ + id: String(selected.id), + collection: versionedCloudMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }), + ).rejects.toThrow() + expect(versionedCloudFiles).toEqual(filesBefore) + expect( + await payload.db.findOne({ + collection: versionedCloudMediaSlug, + where: { id: { equals: created.id } }, + }), + ).toEqual(currentBefore) + } finally { + Object.assign(field, { validate }) + } + }, + ) + + test.options( + 'should retain a referenced restored file when adapter metadata fails without a transaction', + { db: (adapter) => adapter === 'sqlite' }, + async ({ payload }) => { + const { created, selected } = await prepareHistory({ + payload, + collection: versionedCloudMediaSlug, + }) + const filesBefore = new Map(versionedCloudFiles) + const collection = payload.collections[versionedCloudMediaSlug].config + const operations = collection.upload.fileOperations! + const stage = operations.stage + vi.spyOn(operations, 'stage').mockImplementation(async (args) => { + const result = await stage(args) + return { metadata: { ...result.metadata, storageMarker: 'invalid' } } + }) + const field = collection.fields.find( + (field) => 'name' in field && field.name === 'storageMarker', + )! + const validate = 'validate' in field ? field.validate : undefined + try { + Object.assign(field, { + validate: (value: string) => (value === 'invalid' ? 'Invalid adapter metadata' : true), + }) + await expect( + payload.restoreVersion({ + id: String(selected.id), + collection: versionedCloudMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }), + ).rejects.toThrow() + const currentAfter = await payload.findByID({ + id: created.id, + collection: versionedCloudMediaSlug, + showHiddenFields: true, + }) + expect(currentAfter._transforms).toEqual({ crop: savedCrop }) + expect(currentAfter.storageMarker).toBe(selected.version.storageMarker) + const storedDefault = getStoredCloudFiles(currentAfter).find(({ roles }) => + roles.some(({ type }) => type === 'default'), + )! + expect(await sharp(versionedCloudFiles.get(storedDefault.key)!).metadata()).toMatchObject({ + height: 600, + width: 800, + }) + expect([...versionedCloudFiles.keys()].sort()).toEqual( + [...filesBefore.keys(), storedDefault.key].sort(), + ) + for (const [key, bytes] of filesBefore) { + expect(versionedCloudFiles.get(key)).toEqual(bytes) + } + const versions = await getVersions({ + payload, + id: created.id, + collection: versionedCloudMediaSlug, + }) + expect(versions.find(({ id }) => id === selected.id)!.version).toEqual(selected.version) + } finally { + Object.assign(field, { validate }) + } + }, + ) +}) diff --git a/test/upload-dynamic/shared.ts b/test/upload-dynamic/shared.ts index 52a9997e9f1..b33750777ec 100644 --- a/test/upload-dynamic/shared.ts +++ b/test/upload-dynamic/shared.ts @@ -7,3 +7,6 @@ export const dynamicMediaDir = path.resolve( 'dynamic-media', ) export const publicCloudMediaSlug = 'public-cloud-dynamic-media' +export const versionedDynamicMediaSlug = 'versioned-dynamic-media' +export const versionedDynamicMediaDir = path.resolve(dynamicMediaDir, '../versioned-dynamic-media') +export const versionedCloudMediaSlug = 'versioned-cloud-dynamic-media' diff --git a/test/upload-dynamic/transformers.ts b/test/upload-dynamic/transformers.ts index d6c34ebbd29..950ff1c8407 100644 --- a/test/upload-dynamic/transformers.ts +++ b/test/upload-dynamic/transformers.ts @@ -6,9 +6,19 @@ import { registerUploadVariants } from 'payload' import { uploadTransformerInternal } from 'payload/internal' import sharp from 'sharp' -import { dynamicMediaSlug, publicCloudMediaSlug } from './shared.js' - -const dynamicCollectionSlugs = [dynamicMediaSlug, publicCloudMediaSlug] +import { + dynamicMediaSlug, + publicCloudMediaSlug, + versionedCloudMediaSlug, + versionedDynamicMediaSlug, +} from './shared.js' + +const dynamicCollectionSlugs = [ + dynamicMediaSlug, + publicCloudMediaSlug, + versionedCloudMediaSlug, + versionedDynamicMediaSlug, +] type Crop = { height: number; width: number; x: number; y: number } @@ -18,6 +28,7 @@ export const requestCropCalls: { crop: Crop | null; purpose: string; variantName /** Inputs handed to a signing-only provider; no image bytes are needed to build its redirect. */ export const signedImagePlans: { crop: Crop | null + document: Readonly> focalPoint: { x: number; y: number } | null original: FileData['original'] overrides: { quality: number; width: number } @@ -70,6 +81,7 @@ export const requestCropTransformer: UploadTransformer = { } signedImagePlans.push({ + document: doc, crop, focalPoint: (doc._transforms as { focalPoint?: { x: number; y: number } } | null)?.focalPoint ?? null, @@ -117,7 +129,11 @@ export const requestCropTransformer: UploadTransformer = { // presets would be. init: (config) => registerUploadVariants({ - collections: { [dynamicMediaSlug]: { variants: [{ name: 'card' }] } }, + collections: { + [dynamicMediaSlug]: { variants: [{ name: 'card' }] }, + [versionedDynamicMediaSlug]: { variants: [{ name: 'card' }] }, + [versionedCloudMediaSlug]: { variants: [{ name: 'card' }] }, + }, config, transformerSlug: 'request-crop', }), @@ -128,7 +144,9 @@ export const requestCropTransformer: UploadTransformer = { export const uploadCropTransformer: UploadTransformer = { slug: 'upload-crop', canTransform: ({ collectionSlug, operation, req }) => - collectionSlug === dynamicMediaSlug && + [dynamicMediaSlug, versionedCloudMediaSlug, versionedDynamicMediaSlug].includes( + collectionSlug, + ) && operation === 'upload' && req.context?.isUploadCropEnabled === true ? { canTransform: true, handledTransformKeys: ['crop'] } From b520652364196df94b1940618d206c6810056186 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 16:17:45 -0500 Subject: [PATCH 2/9] chore: fix restore test selection for sqlite uuid adapters --- test/upload-dynamic/restore.int.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/upload-dynamic/restore.int.spec.ts b/test/upload-dynamic/restore.int.spec.ts index 4b20d3eddb1..5358d735b33 100644 --- a/test/upload-dynamic/restore.int.spec.ts +++ b/test/upload-dynamic/restore.int.spec.ts @@ -491,7 +491,7 @@ test.suite('Restore request-time files', { config: './config.ts' }, () => { test.options( 'should validate adapter metadata and roll back the restored files and document if it fails', - { db: (adapter) => adapter !== 'sqlite' }, + { db: (adapter) => !adapter.startsWith('sqlite') }, async ({ payload }) => { const { created, selected } = await prepareHistory({ payload, @@ -540,7 +540,7 @@ test.suite('Restore request-time files', { config: './config.ts' }, () => { test.options( 'should retain a referenced restored file when adapter metadata fails without a transaction', - { db: (adapter) => adapter === 'sqlite' }, + { db: (adapter) => adapter.startsWith('sqlite') }, async ({ payload }) => { const { created, selected } = await prepareHistory({ payload, From 13382b431adaf6dca739125a2b428591bad73cac Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 16:51:17 -0500 Subject: [PATCH 3/9] chore: ignore request parameters on historical file requests --- docs/upload/overview.mdx | 2 +- docs/upload/transformers.mdx | 2 +- .../transformers/handleDynamicFileRequest.ts | 9 ++-- test/upload-dynamic/history.int.spec.ts | 52 +++++-------------- 4 files changed, 22 insertions(+), 43 deletions(-) diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 247b009f71f..6cef0c28056 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -346,7 +346,7 @@ SQL projects need a schema migration; see the [v4 migration guide](../migration- To read a historical file through Payload's file endpoint, include `?version=`. Version reads generate these URLs for the main file, original, variants, and thumbnail. The requested filename and storage prefix must belong to that version, and both parent-document read access and version read access are enforced. Without `version`, the endpoint resolves current published files and the latest draft, subject to collection read access; filenames found only in history return 404. -Stored historical files and originals serve their saved bytes without rerunning transformations, including when the URL contains resize parameters. A file built at request time instead uses that version's retained original and saved `_transforms` with the current compatible transformer pipeline. It does not use the current document's source or edits. Request overrides compose after the saved state and require transform access, as on current file requests. Reading a historical file never saves new files or changes the version. +Stored historical files and originals serve their saved bytes without rerunning transformations, including when the URL contains resize parameters. A file built at request time instead uses that version's retained original and saved `_transforms` with the current compatible transformer pipeline. It does not use the current document's source or edits. Request parameters such as `?width=` are ignored, as for stored historical files, so a historical request is always an ordinary read. Reading a historical file never saves new files or changes the version. A request-time historical file needs a compatible transformer. A request-time size also needs its name registered in the current configuration. If either is unavailable, the request fails instead of serving the current file or an untransformed original. Stored historical sizes remain readable even if their names are no longer configured. Public URLs supplied directly by a storage provider remain direct. diff --git a/docs/upload/transformers.mdx b/docs/upload/transformers.mdx index 4d013a039d7..9964c060149 100644 --- a/docs/upload/transformers.mdx +++ b/docs/upload/transformers.mdx @@ -348,7 +348,7 @@ Request-time sizes follow the same [saved-key rules](#applying-saved-_transforms Request parameters never change `_transforms`. To save a change, update the document. -Historical requests (`?version=`) build request-time files from the selected version's original and saved `_transforms`. The handler receives that version's document snapshot, and the current compatible pipeline applies its saved state and any request overrides. Stored historical files and originals continue to serve saved bytes without transformations. Restoring a version with request-time entries resolves the saved original and edits through the current configuration; stored-only versions reuse saved output. See [Historical file URLs](./overview#historical-file-urls) and [Restoring files](./overview#restoring-files). +Historical requests (`?version=`) build request-time files from the selected version's original and saved `_transforms`. The handler receives that version's document snapshot, and the current compatible pipeline applies its saved state. Request parameters are ignored, so only phase 1 runs. Stored historical files and originals continue to serve saved bytes without transformations. Restoring a version with request-time entries resolves the saved original and edits through the current configuration; stored-only versions reuse saved output. See [Historical file URLs](./overview#historical-file-urls) and [Restoring files](./overview#restoring-files). #### Handing off to an image service diff --git a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts index 91d858bd14d..b85db2fe746 100644 --- a/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts +++ b/packages/payload/src/uploads/transformers/handleDynamicFileRequest.ts @@ -324,9 +324,12 @@ async function planRequestPipeline({ req: PayloadRequest }): Promise { const requestedFile = getRequestedFile({ document, filename }) - const purposes = requestedFile.isBuiltAtRequest - ? (['persisted-default', 'request-override'] as const) - : (['request-override'] as const) + // A historical file shows a saved state, so request parameters never change it. + const purposes = isHistorical + ? (['persisted-default'] as const) + : requestedFile.isBuiltAtRequest + ? (['persisted-default', 'request-override'] as const) + : (['request-override'] as const) const pipeline: RequestStage[] = [] for (const purpose of purposes) { diff --git a/test/upload-dynamic/history.int.spec.ts b/test/upload-dynamic/history.int.spec.ts index dcdcd73cd27..981b8c58c69 100644 --- a/test/upload-dynamic/history.int.spec.ts +++ b/test/upload-dynamic/history.int.spec.ts @@ -104,33 +104,24 @@ test.suite('Historical request-time files', { config: './config.ts' }, () => { expect(requestCropCalls).toHaveLength(1) }) - test('should check transform access before signing a historical override and keep the selected snapshot', async ({ + test('should ignore request parameters on a historical file and serve its saved state', async ({ payload, restClient, }) => { const { selected } = await replaceImage({ payload }) - const url = - `/${versionedDynamicMediaSlug}/file/${selected.version.variants!.card!.filename}?version=${selected.id}&w=200&q=70` as const - const headers = { 'x-test-sign-image': 'true' } - - expect((await restClient.GET(url, { auth: false, headers })).status).toBe(403) - expect(signedImagePlans).toHaveLength(0) - expect(sourceReadCalls).toHaveLength(0) - - const response = await restClient.GET(url, { headers, redirect: 'manual' }) + const response = await restClient.GET( + `/${versionedDynamicMediaSlug}/file/${selected.version.variants!.card!.filename}?version=${selected.id}&w=200&q=70`, + { auth: false }, + ) - expect(response.status).toBe(302) - expect(signedImagePlans).toHaveLength(1) - expect(signedImagePlans[0]).toMatchObject({ - document: { alt: selected.version.alt, id: selected.parent }, - crop: savedCrop, - original: selected.version.original, - overrides: { quality: 70, width: 200 }, - variantName: 'card', + expect(response.status).toBe(200) + expect(await sharp(Buffer.from(await response.arrayBuffer())).metadata()).toMatchObject({ + height: 300, + width: 400, }) - expect(sourceReadCalls).toHaveLength(0) - expect(Object.isFrozen(signedImagePlans[0]!.document)).toBe(true) - expect(Object.isFrozen(signedImagePlans[0]!.document._transforms)).toBe(true) + expect(requestCropCalls).toEqual([ + { crop: savedCrop, purpose: 'persisted-default', variantName: 'card' }, + ]) }) for (const policy of ['read', 'readVersions'] as const) { @@ -235,40 +226,25 @@ test.suite('Historical request-time files', { config: './config.ts' }, () => { expect(sourceReadCalls).toHaveLength(0) }) - test('should support transform-only access without applying that access context to version reads', async ({ + test('should not let transform-only access read a historical file, with or without request parameters', async ({ payload, restClient, }) => { const { selected } = await replaceImage({ payload }) const collection = payload.collections[versionedDynamicMediaSlug].config const originalRead = collection.access.read - const originalReadVersions = collection.access.readVersions - const versionAccessModes: (boolean | undefined)[] = [] const url = `/${versionedDynamicMediaSlug}/file/${selected.version.variants!.card!.filename}?version=${selected.id}` as const try { collection.access.read = ({ req }) => Boolean(req.fileTransform) - collection.access.readVersions = ({ req }) => { - versionAccessModes.push(req.fileTransform) - return !req.fileTransform - } expect((await restClient.GET(url)).status).toBe(403) + expect((await restClient.GET(`${url}&w=200`)).status).toBe(403) expect(requestCropCalls).toHaveLength(0) expect(sourceReadCalls).toHaveLength(0) - - const response = await restClient.GET(`${url}&w=200`) - - expect(response.status).toBe(200) - expect(await sharp(Buffer.from(await response.arrayBuffer())).metadata()).toMatchObject({ - height: 150, - width: 200, - }) - expect(versionAccessModes.every((value) => !value)).toBe(true) } finally { collection.access.read = originalRead - collection.access.readVersions = originalReadVersions } }) From 5faff958c8f789454cc6fedf4f478eadfb85a60a Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 16:54:46 -0500 Subject: [PATCH 4/9] chore: use findVersionByID to authorize historical file requests --- .../fileVersioning/resolveHistoricalFile.ts | 69 +++---------------- 1 file changed, 8 insertions(+), 61 deletions(-) diff --git a/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts b/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts index 984da3c4906..6829debdb1c 100644 --- a/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts +++ b/packages/payload/src/uploads/fileVersioning/resolveHistoricalFile.ts @@ -2,21 +2,10 @@ import type { Collection } from '../../collections/config/types.js' import type { JsonObject, PayloadRequest } from '../../types/index.js' import type { ResolvedUploadDocument } from '../transformers/resolveUploadDocument.js' -import { executeAccess } from '../../auth/executeAccess.js' -import { combineQueries } from '../../database/combineQueries.js' -import { sanitizeWhereQuery } from '../../database/sanitizeWhereQuery.js' -import { Forbidden } from '../../errors/Forbidden.js' import { NotFound } from '../../errors/NotFound.js' -import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' -import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' -import { - isInheritedReadVersionsAccess, - withInheritedReadVersionsParentID, -} from '../../versions/isInheritedReadVersionsAccess.js' import { checkFileAccess } from '../checkFileAccess.js' import { isOriginalFileRequest } from '../isOriginalFileRequest.js' import { isRequestTimeFile } from '../isRequestTimeFile.js' -import { withFileTransformAccessContext } from '../transformers/withFileTransformAccessContext.js' import { collectStoredFiles, withLegacyCloudUploadFileData } from './storedFiles.js' type StoredVersion = { @@ -49,60 +38,18 @@ export const resolveHistoricalFile = async ({ }) const matched = docs[0] - // Check both policies before exposing a missing version or a filename mismatch. - const entry = matched && getHistoricalEntry({ doc: matched.version, filename, isOriginal }) - const checkParent = ({ isTransform }: { isTransform: boolean }) => - withFileTransformAccessContext({ - callback: () => checkFileAccess({ collection, documentID: matched?.parent, filename, req }), - isTransform, - req, - }) - - try { - await checkParent({ isTransform: false }) - } catch (error) { - if (!(error instanceof Forbidden) || !isRequestTimeFile({ entry: entry?.value })) { - throw error - } - // The request pipeline will check its final access mode before executing any stage. - await checkParent({ isTransform: true }) - } - const versionAccess = await executeAccess( - { id: versionID, slug: collection.config.slug, req }, - isInheritedReadVersionsAccess(collection.config.access.readVersions) - ? (accessArgs) => - collection.config.access.readVersions( - withInheritedReadVersionsParentID(accessArgs, matched?.parent), - ) - : collection.config.access.readVersions, - ) - const where = appendNonTrashedFilter({ - deletedAtPath: 'version.deletedAt', - enableTrash: collection.config.trash, - where: combineQueries({ id: { equals: versionID } }, versionAccess), - }) - - sanitizeWhereQuery({ - fields: buildVersionCollectionFields(req.payload.config, collection.config, true), - payload: req.payload, - where, - }) - - const { docs: authorizedVersions } = await req.payload.db.findVersions({ + // Check both policies before exposing a missing version or a filename mismatch. A historical + // request ignores request parameters, so it is always an ordinary read. + await checkFileAccess({ collection, documentID: matched?.parent, filename, req }) + await req.payload.findVersionByID({ + id: versionID, collection: collection.config.slug, - limit: 1, - locale: req.locale ?? undefined, + depth: 0, + overrideAccess: false, req, - where, + showHiddenFields: true, }) - if (!authorizedVersions[0]) { - if (typeof versionAccess === 'object') { - throw new Forbidden(req.t) - } - throw new NotFound(req.t) - } - if (!matched) { throw new NotFound(req.t) } From 5476f4ef76b33c630ef62e385a99884071792d35 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 17:00:02 -0500 Subject: [PATCH 5/9] chore: keep cloud storage request state behind the file versioning helpers --- .../collections/operations/restoreVersion.ts | 27 ++++--------------- .../uploads/fileVersioning/cloudStorage.ts | 23 ++++++++++++++++ .../src/uploads/fileVersioning/restore.ts | 19 ++++++++++++- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index e77780c6fc6..018126ad5b8 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -17,13 +17,14 @@ import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { hasManagedCloudMetadata } from '../../uploads/fileVersioning/cloudStorage.js' import { abortFileOperationScope, beginFileOperationScope, completeFileOperationScope, shareFileOperationScope, } from '../../uploads/fileVersioning/fileOperationManager.js' -import { runFileRestore } from '../../uploads/fileVersioning/restore.js' +import { isolateRestoreRequest, runFileRestore } from '../../uploads/fileVersioning/restore.js' import { restoreUploadDataFromDocument } from '../../uploads/sanitizeUploadData.js' import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' @@ -74,27 +75,9 @@ export const restoreVersionOperation = async < showHiddenFields, } = args const hasFileOperationScope = Boolean(collectionConfig.upload) - const req = hasFileOperationScope - ? isolateObjectProperty(incomingReq, ['context', 'file', 'payloadUploadSizes', 'query']) - : incomingReq + const req = hasFileOperationScope ? isolateRestoreRequest({ req: incomingReq }) : incomingReq if (hasFileOperationScope) { - // A nested restore must not consume the enclosing upload's bytes or storage receipts. - req.file = undefined - req.payloadUploadSizes = undefined - req.query = { ...incomingReq.query } - delete req.query.uploadEdits - req.context = { ...incomingReq.context } - for (const key of [ - '_payloadCloudStorage', - '_payloadManagedCloudMetadata', - '_payloadManagedCloudStorage', - '_payloadVerifiedProviderOriginal', - 'skipCloudStorage', - ]) { - delete req.context[key] - } - shareFileOperationScope({ owner: incomingReq, req }) args = { ...args, req } } @@ -360,7 +343,7 @@ export const restoreVersionOperation = async < const writeRestoredVersion = async (restored: JsonObject) => { if (collectionConfig.upload) { result = restoreUploadDataFromDocument(result, restored, { clearMissing: true }) - if (req.context._payloadManagedCloudStorage) { + if (hasManagedCloudMetadata({ req })) { data = restoreUploadDataFromDocument(data, restored, { clearMissing: true }) } } @@ -442,7 +425,7 @@ export const restoreVersionOperation = async < // Adapter metadata is saved by its normal afterChange hook. It needs the full upload data // and draft status even when the caller's response selection omits those fields. - const managedAfterChangeData = req.context._payloadManagedCloudStorage + const managedAfterChangeData = hasManagedCloudMetadata({ req }) ? { ...data, _status: draftArg ? 'draft' : data._status } : undefined diff --git a/packages/payload/src/uploads/fileVersioning/cloudStorage.ts b/packages/payload/src/uploads/fileVersioning/cloudStorage.ts index f9cf067fc3d..ab77c6f1cd9 100644 --- a/packages/payload/src/uploads/fileVersioning/cloudStorage.ts +++ b/packages/payload/src/uploads/fileVersioning/cloudStorage.ts @@ -1,4 +1,5 @@ import type { SanitizedCollectionConfig } from '../../collections/config/types.js' +import type { RequestContext } from '../../index.js' import type { JsonObject, PayloadRequest } from '../../types/index.js' import type { FileToSave } from '../types.js' import type { StagedObject } from './fileOperationManager.js' @@ -16,6 +17,28 @@ import { withLegacyCloudUploadFileData, } from './storedFiles.js' +/** Request context keys that carry one upload's cloud storage state. */ +const cloudStorageStateKeys = [ + '_payloadCloudStorage', + '_payloadManagedCloudMetadata', + '_payloadManagedCloudStorage', + '_payloadVerifiedProviderOriginal', + 'skipCloudStorage', +] + +/** A copy of `context` without one upload's cloud storage state, for a nested file operation. */ +export const withoutCloudStorageState = (context: RequestContext): RequestContext => { + const copy = { ...context } + for (const key of cloudStorageStateKeys) { + delete copy[key] + } + return copy +} + +/** Whether the storage adapter's afterChange hook saves the metadata of this operation's files. */ +export const hasManagedCloudMetadata = ({ req }: { req: PayloadRequest }): boolean => + Boolean(req.context?._payloadManagedCloudStorage) + /** Preserve the caller's cloud flags; nested Local API calls can replace req.context. */ export const captureCloudHookState = ({ req }: { req: PayloadRequest }): (() => void) => { const previousState = ['_payloadManagedCloudStorage', '_payloadManagedCloudMetadata'].map( diff --git a/packages/payload/src/uploads/fileVersioning/restore.ts b/packages/payload/src/uploads/fileVersioning/restore.ts index cc8e8ce5cdb..14cd73f37e3 100644 --- a/packages/payload/src/uploads/fileVersioning/restore.ts +++ b/packages/payload/src/uploads/fileVersioning/restore.ts @@ -7,10 +7,12 @@ import type { JsonObject, PayloadRequest } from '../../types/index.js' import type { StoredFileList } from './types.js' import { APIError } from '../../errors/APIError.js' +import { isolateObjectProperty } from '../../utilities/isolateObjectProperty.js' import { isRequestTimeFile } from '../isRequestTimeFile.js' import { archiveOutgoingLocalFiles, replaceStoredFileReferences } from './archive.js' import { removeUnreferencedStagedObjects, scheduleUnreferencedFileCleanup } from './cleanup.js' -import { runFileOperationPlan } from './fileOperationManager.js' +import { withoutCloudStorageState } from './cloudStorage.js' +import { runFileOperationPlan, shareFileOperationScope } from './fileOperationManager.js' import { copyLocalFile } from './localStorage.js' import { getArchivedFilename } from './naming.js' import { runRequestTimeFileRestore } from './restoreRequestTime.js' @@ -29,6 +31,21 @@ export type FileRestoreArgs = { write: (restored: JsonObject) => Promise } +/** + * The request for a restore of an upload document. A restore can run inside another upload + * operation, so it must not use that upload's file, sizes, edits or storage state. + */ +export const isolateRestoreRequest = ({ req }: { req: PayloadRequest }): PayloadRequest => { + const isolated = isolateObjectProperty(req, ['context', 'file', 'payloadUploadSizes', 'query']) + isolated.file = undefined + isolated.payloadUploadSizes = undefined + isolated.query = { ...req.query } + delete isolated.query.uploadEdits + isolated.context = withoutCloudStorageState(req.context) + shareFileOperationScope({ owner: req, req: isolated }) + return isolated +} + /** Replay virtual outputs with today's pipeline; saved stored outputs remain reusable. */ export const runFileRestore = async (args: FileRestoreArgs): Promise => { const entries = [args.selected, ...Object.values(args.selected.variants ?? {})] From 99bcef038c0cb7f217c5dbe59e626ee622779bea Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 17:06:04 -0500 Subject: [PATCH 6/9] chore: stream the retained original to a temp file when a restore rebuilds stored output --- .../fileVersioning/restoreRequestTime.ts | 124 ++++++++++-------- .../uploads/getFileFromUploadInstructions.ts | 2 +- test/upload-dynamic/restore.int.spec.ts | 19 +++ test/upload-dynamic/transformers.ts | 7 +- 4 files changed, 93 insertions(+), 59 deletions(-) diff --git a/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts b/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts index 4fa6af7d12b..7c4aea6d199 100644 --- a/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts +++ b/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts @@ -1,3 +1,5 @@ +import { rm, stat } from 'node:fs/promises' + import type { TypeWithID } from '../../collections/config/types.js' import type { Document, JsonObject } from '../../types/index.js' import type { OriginalFileData } from '../types.js' @@ -11,6 +13,7 @@ import { isolateObjectProperty } from '../../utilities/isolateObjectProperty.js' import { retrieveFileResponse } from '../endpoints/getFile.js' import { generateFileData } from '../generateFileData.js' import { generateFilePathOrURL } from '../generateFilePathOrURL.js' +import { streamResponseToTempFile } from '../getFileFromUploadInstructions.js' import { shouldRequestOriginal } from '../isOriginalFileRequest.js' import { isRequestTimeFile } from '../isRequestTimeFile.js' import { getRequestTimeDefaultFileData, getRequestTimeVariants } from '../requestTimeFileData.js' @@ -209,64 +212,71 @@ export async function runRequestTimeFileRestore({ if (!response.ok) { throw new APIError('Unable to read the retained original for restore.', 400) } - const buffer = Buffer.from(await response.arrayBuffer()) - replayReq.file = { - name: original.filename, - data: buffer, - mimetype: original.mimeType, - size: buffer.length, - } - const { data, files } = await generateFileData({ - collection: { config: collection }, - config: req.payload.config, - data: pipelineDoc, - operation: 'create', - overwriteExistingFiles: false, - req: replayReq, - }) - const finalTransformError = await getTransformStateError({ original, value: data._transforms }) - if (finalTransformError) { - throw new ValidationError( - { - collection: collection.slug, - errors: [{ message: finalTransformError, path: '_transforms' }], - req, - }, - req.t, - ) - } + // Stream the retained original to a temp file, so core never holds the whole source in memory. + const tempFilePath = await streamResponseToTempFile({ req: replayReq, response }) - // The retained original is immutable. Reuse it instead of storing another copy or archiving - // its selected historical reference when only the generated outputs change. - data.original = { ...original } - const outputFiles = files.filter(({ role }) => role?.type !== 'original') - const restored = normalizeURLs({ restored: clearRemovedVariants({ restored: data }) }) - const writeRestored = () => { - if (collection.upload.fileOperations) { - // Keep the existing adapter metadata hook active through the outer restore's afterChange. - req.context._payloadManagedCloudStorage = true - req.context._payloadManagedCloudMetadata = replayReq.context._payloadManagedCloudMetadata + try { + replayReq.file = { + name: original.filename, + data: Buffer.alloc(0), + mimetype: original.mimeType, + size: (await stat(tempFilePath)).size, + tempFilePath, + } + const { data, files } = await generateFileData({ + collection: { config: collection }, + config: req.payload.config, + data: pipelineDoc, + operation: 'create', + overwriteExistingFiles: false, + req: replayReq, + }) + const finalTransformError = await getTransformStateError({ original, value: data._transforms }) + if (finalTransformError) { + throw new ValidationError( + { + collection: collection.slug, + errors: [{ message: finalTransformError, path: '_transforms' }], + req, + }, + req.t, + ) } - return write(restored) - } - return collection.upload.fileOperations - ? runCloudFileUpdate({ - id, - collection, - current, - data: restored, - files: outputFiles, - req: replayReq, - write: writeRestored, - }) - : runLocalFileUpdate({ - id, - collection, - current, - files: outputFiles, - next: restored, - req: replayReq, - write: writeRestored, - }) + // The retained original is immutable. Reuse it instead of storing another copy or archiving + // its selected historical reference when only the generated outputs change. + data.original = { ...original } + const outputFiles = files.filter(({ role }) => role?.type !== 'original') + const restored = normalizeURLs({ restored: clearRemovedVariants({ restored: data }) }) + const writeRestored = () => { + if (collection.upload.fileOperations) { + // Keep the existing adapter metadata hook active through the outer restore's afterChange. + req.context._payloadManagedCloudStorage = true + req.context._payloadManagedCloudMetadata = replayReq.context._payloadManagedCloudMetadata + } + return write(restored) + } + + return await (collection.upload.fileOperations + ? runCloudFileUpdate({ + id, + collection, + current, + data: restored, + files: outputFiles, + req: replayReq, + write: writeRestored, + }) + : runLocalFileUpdate({ + id, + collection, + current, + files: outputFiles, + next: restored, + req: replayReq, + write: writeRestored, + })) + } finally { + await rm(tempFilePath, { force: true }) + } } diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.ts index 3fb198b73f8..7e899fe25c4 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.ts @@ -571,7 +571,7 @@ const readBoundedPrefix = async (response: Response, maxBytes: number): Promise< * (e.g. Azure's chunkLargeFiles) can be far larger than the server's available memory, so this * avoids re-downloading the whole file into a single in-memory buffer just to read it back out. */ -const streamResponseToTempFile = async ({ +export const streamResponseToTempFile = async ({ req, response, }: { diff --git a/test/upload-dynamic/restore.int.spec.ts b/test/upload-dynamic/restore.int.spec.ts index 5358d735b33..69a4b61e39f 100644 --- a/test/upload-dynamic/restore.int.spec.ts +++ b/test/upload-dynamic/restore.int.spec.ts @@ -1,5 +1,6 @@ import type { Payload } from 'payload' +import { existsSync } from 'node:fs' import { readdir, readFile, rm } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -17,6 +18,7 @@ import { versionedDynamicMediaDir, versionedDynamicMediaSlug, } from './shared.js' +import { uploadCropSources } from './transformers.js' const imageFixture = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -90,6 +92,23 @@ test.suite('Restore request-time files', { config: './config.ts' }, () => { await rm(versionedDynamicMediaDir, { force: true, recursive: true }) }) + test('should read the retained original from a temp file when restoring through the upload pipeline, and remove it', async ({ + payload, + }) => { + const { selected } = await prepareHistory({ payload, replaceOriginal: true }) + uploadCropSources.length = 0 + + await payload.restoreVersion({ + id: String(selected.id), + collection: versionedDynamicMediaSlug, + context: { isUploadCropEnabled: true }, + overrideAccess: true, + }) + + expect(uploadCropSources).toEqual([{ tempFilePath: expect.any(String) }]) + expect(existsSync(uploadCropSources[0]!.tempFilePath!)).toBe(false) + }) + test('should replay saved transforms through the current upload pipeline when restoring a dynamic file', async ({ payload, restClient, diff --git a/test/upload-dynamic/transformers.ts b/test/upload-dynamic/transformers.ts index 950ff1c8407..9390acba0f5 100644 --- a/test/upload-dynamic/transformers.ts +++ b/test/upload-dynamic/transformers.ts @@ -141,6 +141,9 @@ export const requestCropTransformer: UploadTransformer = { } /** Applies the saved crop at upload time when a test sets `context.isUploadCropEnabled`. */ +/** The temp file path of each `uploadCropTransformer` upload, so a test can check the source. */ +export const uploadCropSources: { tempFilePath?: string }[] = [] + export const uploadCropTransformer: UploadTransformer = { slug: 'upload-crop', canTransform: ({ collectionSlug, operation, req }) => @@ -152,9 +155,11 @@ export const uploadCropTransformer: UploadTransformer = { ? { canTransform: true, handledTransformKeys: ['crop'] } : false, mimeTypes: ['image/*'], - transformFile: async ({ doc, file }) => { + transformFile: async ({ doc, file, req }) => { const crop = (doc._transforms as { crop?: Crop } | null)?.crop + uploadCropSources.push({ tempFilePath: req.file?.tempFilePath }) + if (!crop) { return { status: 'continue' } } From b11fbb2372a1f43c92cdb760b20e25c4ae1c10c7 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 17:55:32 -0500 Subject: [PATCH 7/9] test: save the full document as the new version when a restore has select Also give the duplicate test titles in this file the name of their group (globals, GraphQL), so the no-identical-title lint rule passes. --- test/versions/int.spec.ts | 70 +++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 988b833056f..f47764424f9 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -1008,6 +1008,39 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = }) }) + test('should save the full document as the new version when a restore has select', async ({ + payload, + }) => { + const post = await payload.create({ + collection: draftCollectionSlug, + data: { _status: 'published', description: 'description', title: 'restore with select' }, + overrideAccess: true, + }) + const { docs } = await payload.findVersions({ + collection: draftCollectionSlug, + overrideAccess: true, + where: { parent: { equals: post.id } }, + }) + + const restored = await payload.restoreVersion({ + id: docs[0]!.id, + collection: draftCollectionSlug, + overrideAccess: true, + select: { title: true }, + }) + const { docs: latest } = await payload.findVersions({ + collection: draftCollectionSlug, + overrideAccess: true, + where: { and: [{ parent: { equals: post.id } }, { latest: { equals: true } }] }, + }) + + expect(restored).not.toHaveProperty('description') + expect(latest[0]!.version).toMatchObject({ + description: 'description', + title: 'restore with select', + }) + }) + test('should return `findVersions` in correct order', async ({ payload }) => { const somePost = await payload.create({ collection: draftCollectionSlug, @@ -3532,7 +3565,10 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = }) }) - test('should allow a version to be restored', async ({ payload, restClient }) => { + test('should allow a version to be restored through GraphQL', async ({ + payload, + restClient, + }) => { // Update it const update = `mutation { updateAutosavePost(id: ${formatGraphQLID({ payload }, postID)}, data: {title: "${'Wrong title'}"}) { @@ -3907,7 +3943,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = expect(publishedVersion._status).toStrictEqual('published') }) - test('should have different createdAt in a new version while the same version.createdAt', async ({ + test('should have different createdAt in a new global version while the same version.createdAt', async ({ payload, }) => { await seedGlobalSiblingLocales({ @@ -4053,7 +4089,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = } }) - test('should allow a version to be retrieved by ID', async ({ payload }) => { + test('should allow a global version to be retrieved by ID', async ({ payload }) => { const version = await payload.findGlobalVersionByID({ id: globalVersionID, slug: autoSaveGlobalSlug, @@ -4084,7 +4120,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = }) test.describe('Update', () => { - test('should allow a version to save locales properly', async ({ payload }) => { + test('should allow a global version to save locales properly', async ({ payload }) => { const englishTitle = 'Title in EN' const spanishTitle = 'Title in ES' @@ -4192,7 +4228,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = await payload.db.deleteVersions({ globalSlug: simpleDraftGlobalSlug, where: {} }) }) - test('should allow a version to be restored', async ({ payload }) => { + test('should allow a global version to be restored', async ({ payload }) => { const title2 = 'Another updated title in EN' await seedGlobalSiblingLocales({ @@ -4924,7 +4960,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = }) test.describe('Patch', () => { - test('should allow a draft to be patched', async ({ payload }) => { + test('should allow a global draft to be patched', async ({ payload }) => { const originalTitle = 'Here is a published global' await seedGlobalSiblingLocales({ @@ -5138,7 +5174,10 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = await createAndSetVersionID({ restClient }) }) test.describe('Read', () => { - test('should allow read of versions by version id', async ({ payload, restClient }) => { + test('should allow read of global versions by version id', async ({ + payload, + restClient, + }) => { // language=graphql const query = `query { versionAutosaveGlobal(id: ${formatGraphQLID({ payload }, autosaveGlobalVersionID)}) { @@ -5159,7 +5198,9 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = expect(data.versionAutosaveGlobal.version.title).toStrictEqual(globalGraphQLOriginalTitle) }) - test('should allow read of versions by querying version content', async ({ restClient }) => { + test('should allow read of global versions by querying version content', async ({ + restClient, + }) => { // language=graphQL const query = `query { versionsAutosaveGlobal(where: { version__title: {equals: "${globalGraphQLOriginalTitle}" } }) { @@ -5186,7 +5227,10 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = }) test.describe('Restore', () => { - test('should allow a version to be restored', async ({ payload, restClient }) => { + test('should allow a global version to be restored through GraphQL', async ({ + payload, + restClient, + }) => { const updatedTitle = 'Wrong global title' // Update it @@ -6612,7 +6656,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = expect(globalData.title.de).toStrictEqual('German published') }) - test('should not leak draft data', async ({ payload }) => { + test('should not leak draft data of a global', async ({ payload }) => { // save spanish draft await payload.updateGlobal({ slug: global, @@ -6647,7 +6691,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = expect(globalData.title.en).toStrictEqual('Eng published') }) - test('should merge draft data from other locales when publishing all', async ({ + test('should merge draft data from other locales when publishing all locales of a global', async ({ payload, }) => { // save spanish draft @@ -6701,7 +6745,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = expect(publishedAll.title.en).toStrictEqual('Eng published') }) - test('should publish non-default individual locale', async ({ payload }) => { + test('should publish a non-default individual locale of a global', async ({ payload }) => { // save spanish draft await payload.updateGlobal({ slug: global, @@ -6736,7 +6780,7 @@ test.suite('Versions', { config: './config.ts', resetBetweenTests: false }, () = expect(globalData.title.de).toStrictEqual('German published') }) - test('should show correct data in latest version', async ({ payload }) => { + test('should show correct data in the latest global version', async ({ payload }) => { // save spanish draft await payload.updateGlobal({ slug: global, From 4a487bac56633e4e642bd0e90ebeb4684a0b6307 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 18:41:26 -0500 Subject: [PATCH 8/9] test: select the restore rollback tests by transaction support, not by adapter name CosmosDB and Firestore turn off transactions, so a failed restore cannot undo the document write. They now run the no-transaction test, like SQLite. --- test/upload-dynamic/restore.int.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/upload-dynamic/restore.int.spec.ts b/test/upload-dynamic/restore.int.spec.ts index 69a4b61e39f..d7d57ff555f 100644 --- a/test/upload-dynamic/restore.int.spec.ts +++ b/test/upload-dynamic/restore.int.spec.ts @@ -25,6 +25,14 @@ const imageFixture = path.resolve( '../uploads/image.png', ) const savedCrop = { height: 600, width: 800, x: 100, y: 200 } +// These adapters cannot roll back a document write, so a failed restore keeps the files it saved. +const adaptersWithoutTransactions = [ + 'cosmosdb', + 'firestore', + 'sqlite', + 'sqlite-uuid', + 'sqlite-uuidv7', +] const laterCrop = { height: 200, width: 300, x: 0, y: 0 } const getVersions = async ({ @@ -510,7 +518,7 @@ test.suite('Restore request-time files', { config: './config.ts' }, () => { test.options( 'should validate adapter metadata and roll back the restored files and document if it fails', - { db: (adapter) => !adapter.startsWith('sqlite') }, + { db: (adapter) => !adaptersWithoutTransactions.includes(adapter) }, async ({ payload }) => { const { created, selected } = await prepareHistory({ payload, @@ -559,7 +567,7 @@ test.suite('Restore request-time files', { config: './config.ts' }, () => { test.options( 'should retain a referenced restored file when adapter metadata fails without a transaction', - { db: (adapter) => adapter.startsWith('sqlite') }, + { db: (adapter) => adaptersWithoutTransactions.includes(adapter) }, async ({ payload }) => { const { created, selected } = await prepareHistory({ payload, From 896e3c8ccfdbce192afac05334e40af1dd232635 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 9 Nov 2026 19:12:12 -0500 Subject: [PATCH 9/9] chore: do not prepare a copy of the original when a restore rebuilds stored output generateFileData read the whole original into memory to prepare a copy after a transformer changed the file, and restore then discarded it. A restore now passes isOriginalStored, so the copy is not prepared. --- .../fileVersioning/restoreRequestTime.ts | 2 + .../src/uploads/generateFileData.spec.ts | 47 +++++++++++++++++++ .../payload/src/uploads/generateFileData.ts | 8 +++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts b/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts index 7c4aea6d199..0e1240fe399 100644 --- a/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts +++ b/packages/payload/src/uploads/fileVersioning/restoreRequestTime.ts @@ -227,6 +227,8 @@ export async function runRequestTimeFileRestore({ collection: { config: collection }, config: req.payload.config, data: pipelineDoc, + // The retained original stays as it is, so no copy of it is prepared. + isOriginalStored: true, operation: 'create', overwriteExistingFiles: false, req: replayReq, diff --git a/packages/payload/src/uploads/generateFileData.spec.ts b/packages/payload/src/uploads/generateFileData.spec.ts index 00d925a45c2..9c12c01ee76 100644 --- a/packages/payload/src/uploads/generateFileData.spec.ts +++ b/packages/payload/src/uploads/generateFileData.spec.ts @@ -377,6 +377,53 @@ describe('generateFileData', () => { ]) }) + it.for([ + { isOriginalStored: undefined, roles: ['original', 'default'] }, + { isOriginalStored: true, roles: ['default'] }, + ])( + 'prepares a copy of the original after a transformer changes the file only when it is not stored ($isOriginalStored)', + async ({ isOriginalStored, roles }) => { + const pdfBytes = Buffer.from('%PDF-1.4\n%original\n') + const changeFile: UploadTransformer = { + slug: 'change-file', + canTransform: () => true, + mimeTypes: ['application/pdf'], + transformFile: () => + Promise.resolve({ + file: new File([Buffer.from('changed')], 'document.pdf', { type: 'application/pdf' }), + status: 'continue', + }), + } + const req = { + file: { + data: pdfBytes, + mimetype: 'application/pdf', + name: 'document.pdf', + size: pdfBytes.length, + }, + payload: { + collections: { media: { config: { fields: [], upload: {} } } }, + config: { routes: { api: '/api' }, upload: { transformers: [changeFile] } }, + db: { findOne: vi.fn(async () => null) }, + logger: { error: vi.fn() }, + }, + query: {}, + } as unknown as PayloadRequest + + const { files } = await generateFileData({ + collection: createCollection({ disableLocalStorage: false }), + config: {} as SanitizedConfig, + data: {}, + isOriginalStored, + operation: 'create', + overwriteExistingFiles: true, + req, + }) + + expect(files.map(({ role }) => role?.type)).toEqual(roles) + }, + ) + it('does not save anything when local storage is disabled and no processing is needed', async () => { const req = { file: { diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 2becb974278..0e4c56d5c6c 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -71,6 +71,11 @@ type Args = { */ fetchFullClientUpload?: typeof downloadFullClientUpload isDuplicating?: boolean + /** + * The original is already stored, for example when a restore replays a version from its + * retained original. A changed file then does not get a copy of the original to store. + */ + isOriginalStored?: boolean operation: 'create' | 'update' originalDoc?: T overwriteExistingFiles?: boolean @@ -236,6 +241,7 @@ const processUpload = async ({ fetchFullClientUpload = downloadFullClientUpload, hasTransformers, isDuplicating, + isOriginalStored, operation, originalDoc, overwriteExistingFiles, @@ -925,7 +931,7 @@ const processUpload = async ({ } } - if (fileWasTransformed && !retainedOriginal && !providerOriginal) { + if (fileWasTransformed && !retainedOriginal && !providerOriginal && !isOriginalStored) { filesToSave.push({ buffer: Buffer.from(await originalWebFile!.arrayBuffer()), path: `${staticPath}/${originalFilename}`,