Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/upload/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-id>`. 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-id>`. 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 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.

### 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

Expand Down
2 changes: 1 addition & 1 deletion docs/upload/transformers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<version-id>`) 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=<version-id>`) 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export async function restoreVersionLocal<TSlug extends CollectionSlug>(
id,
collection: collectionSlug,
depth,
draft,
overrideAccess = false,
populate,
select,
Expand All @@ -95,6 +96,7 @@ export async function restoreVersionLocal<TSlug extends CollectionSlug>(
id,
collection,
depth,
draft,
overrideAccess,
payload,
populate,
Expand Down
27 changes: 21 additions & 6 deletions packages/payload/src/collections/operations/restoreVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { runStoredFileRestore } 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'
Expand Down Expand Up @@ -68,12 +69,17 @@ 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 ? isolateRestoreRequest({ req: incomingReq }) : incomingReq

if (hasFileOperationScope) {
args = { ...args, req }
}

if (hasFileOperationScope) {
beginFileOperationScope({ req })
Expand Down Expand Up @@ -337,14 +343,16 @@ export const restoreVersionOperation = async <
const writeRestoredVersion = async (restored: JsonObject) => {
if (collectionConfig.upload) {
result = restoreUploadDataFromDocument(result, restored, { clearMissing: true })
if (hasManagedCloudMetadata({ req })) {
data = restoreUploadDataFromDocument(data, restored, { clearMissing: true })
}
}
if (!draftArg) {
result = await req.payload.db.updateOne({
id: parentDocID,
collection: collectionConfig.slug,
data: result,
req: reqWithValidationLocale,
select,
})
}

Expand All @@ -363,7 +371,7 @@ export const restoreVersionOperation = async <
}

result = collectionConfig.upload
? await runStoredFileRestore({
? await runFileRestore({
id: parentDocID,
collection: collectionConfig,
current: prevDocWithLocales,
Expand Down Expand Up @@ -415,10 +423,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 = hasManagedCloudMetadata({ req })
? { ...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',
Expand All @@ -436,12 +450,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
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/payload/src/uploads/checkFileAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
})
Expand Down
18 changes: 17 additions & 1 deletion packages/payload/src/uploads/endpoints/getFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions packages/payload/src/uploads/fileVersioning/cloudStorage.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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 { Forbidden } from '../../errors/Forbidden.js'
import { NotFound } from '../../errors/NotFound.js'
import { checkFileAccess } from '../checkFileAccess.js'
import { isOriginalFileRequest } from '../isOriginalFileRequest.js'
import { isRequestTimeFile } from '../isRequestTimeFile.js'
import { collectStoredFiles, withLegacyCloudUploadFileData } from './storedFiles.js'

type StoredVersion = {
Expand All @@ -12,20 +14,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<TypeWithID> => {
}): Promise<ResolvedUploadDocument> => {
const { docs } = await req.payload.db.findVersions<JsonObject>({
collection: collection.config.slug,
limit: 1,
Expand All @@ -34,9 +38,10 @@ export const resolveHistoricalFile = async ({
})
const matched = docs[0]

// Check both policies before exposing a missing version or a filename mismatch.
// 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 })
const authorized = await req.payload.findVersionByID({
await req.payload.findVersionByID({
id: versionID,
collection: collection.config.slug,
depth: 0,
Expand All @@ -45,15 +50,18 @@ export const resolveHistoricalFile = async ({
showHiddenFields: true,
})

if (!authorized) {
throw new Forbidden(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)
Expand All @@ -65,39 +73,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<TypeWithID | undefined> => {
}): Promise<ResolvedUploadDocument | undefined> => {
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<string, JsonObject> | 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<string, JsonObject>,
)) {
if (value?.filename === filename) {
return { type: 'size', sizeKey, value }
}
}
}
Loading
Loading