@humaan/payload-storage-imagekit 2.6.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import ImageKit, { toFile } from '@imagekit/nodejs';
2
2
  import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage';
3
+ import { buildStoragePathData, buildUploadStoragePathData, getStorageFilePath, initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities';
4
+ import { openAsBlob } from 'node:fs';
3
5
  import path from 'node:path';
4
- import { getImageKitUploadAuthPath, isImageKitClientUploadContext } from './clientUploads.js';
6
+ import { APIError } from 'payload';
7
+ import { getRangeRequestInfo, isXmlMimeType, UPLOAD_CONTENT_SECURITY_POLICY } from 'payload/internal';
8
+ import { validateMimeType } from 'payload/shared';
9
+ import { imageKitUploadAuthPath, isImageKitReceiptContext } from './clientUploads.js';
10
+ import { createImageKitClientUploadVerifier, getClientUploadStorageFilePath } from './clientUploadVerification.js';
5
11
  import { createImageKitUploadAuthHandler } from './endpoints/createImageKitUploadAuthHandler.js';
6
12
  import { getAdminThumbnailURL, getPrivateAdminThumbnailURL, isPdfMimeType, isVideoMimeType } from './getAdminThumbnailURL.js';
7
13
  import { getImageKitThumbnailSrc, getVideoPosterSrc } from './imageTransforms.js';
8
14
  const DEFAULT_SIGNED_URL_EXPIRES_IN = 900;
9
- // Payload's file-type detector samples this many bytes when validating binary uploads.
10
- const FILE_TYPE_DETECTION_SIZE = 4100;
11
- const FILE_TYPE_DETECTION_RANGE = `bytes=0-${FILE_TYPE_DETECTION_SIZE - 1}`;
12
15
  /**
13
16
  * ImageKit re-encodes some uploads and serves the smaller derivative from the plain
14
17
  * delivery URL — a 1.9 MB MP4 can come back as 868 KB. That is exactly what we want
@@ -19,6 +22,23 @@ const FILE_TYPE_DETECTION_RANGE = `bytes=0-${FILE_TYPE_DETECTION_SIZE - 1}`;
19
22
  */ const ORIGINAL_FILE_TRANSFORMATION = 'orig-true';
20
23
  const PRIVATE_COLLECTION_DESCRIPTION = 'Files in this collection are uploaded to ImageKit as private files and delivered through short-lived signed URLs.';
21
24
  const isPrivateCollectionConfig = (value)=>value !== true && typeof value === 'object' && value?.private === true;
25
+ const XML_FAMILY_MIME_TYPES = [
26
+ 'application/xml',
27
+ 'image/svg+xml',
28
+ 'text/xml'
29
+ ];
30
+ const collectionCanStoreXml = (collection)=>{
31
+ const upload = collection.upload;
32
+ if (!upload || upload === true) {
33
+ return Boolean(upload);
34
+ }
35
+ // Payload skips MIME and content restrictions when this escape hatch is enabled.
36
+ if (upload.allowRestrictedFileTypes) {
37
+ return true;
38
+ }
39
+ const allowedMimeTypes = upload.mimeTypes;
40
+ return !allowedMimeTypes?.length || allowedMimeTypes.some((mimeType)=>mimeType.includes('*') || isXmlMimeType(mimeType)) || XML_FAMILY_MIME_TYPES.some((mimeType)=>validateMimeType(mimeType, allowedMimeTypes));
41
+ };
22
42
  /**
23
43
  * Whether a specific document is private. The flag stored on the document at upload time
24
44
  * (`imagekit.isPrivate`) is the source of truth — a file's privacy on ImageKit is fixed
@@ -36,7 +56,6 @@ const normalizeFolder = (folder)=>{
36
56
  return `/${normalized}`;
37
57
  };
38
58
  const getCollectionFolder = (rootFolder, collectionSlug)=>path.posix.join(rootFolder, collectionSlug);
39
- const getFilePath = (rootFolder, collectionSlug, filename)=>path.posix.join(getCollectionFolder(rootFolder, collectionSlug), filename);
40
59
  const findImageKitFileId = async (imagekit, filePath)=>{
41
60
  const parentPath = path.posix.dirname(filePath);
42
61
  const name = path.posix.basename(filePath);
@@ -87,63 +106,66 @@ const findUploadDocumentByFilename = async ({ collection, filename, prefix, req
87
106
  }
88
107
  });
89
108
  };
90
- const applyClientUploadContextToData = (data, clientUploadContext, buildThumbnailURL = getAdminThumbnailURL, isPrivate = false)=>{
91
- if (!isImageKitClientUploadContext(clientUploadContext)) {
92
- return data;
93
- }
109
+ const applyVerifiedClientUploadToData = (data, uploaded, buildThumbnailURL = getAdminThumbnailURL, isPrivate)=>{
94
110
  data.imagekit = {
95
- fileId: clientUploadContext.fileId,
96
- // Record the collection's privacy at upload time; this is the file's permanent
97
- // ImageKit status and the source of truth for delivery.
111
+ fileId: uploaded.fileId,
98
112
  isPrivate,
99
- thumbnailUrl: clientUploadContext.thumbnailUrl,
100
- url: clientUploadContext.url
113
+ thumbnailUrl: uploaded.thumbnailUrl,
114
+ url: uploaded.url
101
115
  };
102
116
  // Preserve the admin preview when the original file was already uploaded client-side.
103
117
  data.thumbnailURL = buildThumbnailURL(data);
104
118
  return data;
105
119
  };
106
- const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFolder, signedUrlExpiresIn, urlEndpoint })=>{
107
- const fetchUploadedFile = async (fileURL, range)=>{
108
- const response = range ? await fetch(fileURL, {
109
- headers: {
110
- Range: range
111
- }
112
- }) : await fetch(fileURL);
113
- if (!response.ok) {
114
- throw new Error(`imagekitStorage: failed to fetch uploaded file from ImageKit (${response.status}).`);
120
+ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFolder, signedUrlExpiresIn, urlEndpoint, verifyClientUpload })=>{
121
+ const fetchUploadedFile = async ({ fileSize, fileURL, mimeType, range })=>{
122
+ const rangeResult = getRangeRequestInfo({
123
+ fileSize,
124
+ rangeHeader: range ?? null
125
+ });
126
+ if (rangeResult.type === 'invalid') {
127
+ return new Response(null, {
128
+ headers: new Headers(rangeResult.headers),
129
+ status: rangeResult.status
130
+ });
115
131
  }
116
- return response;
117
- };
118
- const fetchUploadedFilePrefix = async ({ fileSize, fileURL, mimeType })=>{
119
- const response = await fetchUploadedFile(fileURL, FILE_TYPE_DETECTION_RANGE);
120
- const contentLength = Number(response.headers.get('Content-Length'));
121
- const contentRange = response.headers.get('Content-Range');
122
- const rangeMatch = contentRange?.match(/^bytes 0-(\d+)\/(\d+)$/);
123
- const rangeEnd = rangeMatch ? Number(rangeMatch[1]) : Number.NaN;
124
- const totalSize = rangeMatch ? Number(rangeMatch[2]) : Number.NaN;
125
- const expectedLength = Math.min(fileSize, FILE_TYPE_DETECTION_SIZE);
126
- if (response.status !== 206 || !rangeMatch || !Number.isSafeInteger(contentLength) || contentLength !== expectedLength || !Number.isSafeInteger(rangeEnd) || rangeEnd + 1 !== contentLength || totalSize !== fileSize) {
127
- // Deliberately not awaited. Under the instrumented `fetch` that Next.js and
128
- // Vercel install, this promise never settles, so awaiting it swallows the error
129
- // and hangs the request until the function times out (5 minutes on Vercel)
130
- // instead of surfacing a 500.
131
- void response.body?.cancel().catch(()=>{});
132
- throw new Error('imagekitStorage: ImageKit returned an invalid video range response.');
132
+ const normalizedRange = rangeResult.type === 'partial' ? `bytes=${rangeResult.rangeStart}-${rangeResult.rangeEnd}` : undefined;
133
+ // Payload cancels image probes after reading a bounded prefix. Next.js memoizes
134
+ // GET responses by teeing their streams; cancelling one branch can then wait
135
+ // forever for its unread cached sibling. An explicit signal opts out of that
136
+ // memoization, while no-store also excludes these reads from the data cache.
137
+ const response = await fetch(fileURL, {
138
+ cache: 'no-store',
139
+ ...normalizedRange ? {
140
+ headers: {
141
+ Range: normalizedRange
142
+ }
143
+ } : {},
144
+ signal: new AbortController().signal
145
+ });
146
+ if (!response.ok || response.status !== rangeResult.status) {
147
+ void response.body?.cancel().catch(()=>undefined);
148
+ throw new Error(`imagekitStorage: ImageKit returned an unexpected file response (${response.status}).`);
133
149
  }
134
- const filePrefix = await response.arrayBuffer();
135
- if (filePrefix.byteLength !== contentLength) {
136
- throw new Error('imagekitStorage: ImageKit returned an invalid video range body.');
150
+ const upstreamLength = response.headers.get('Content-Length');
151
+ const expectedLength = rangeResult.headers['Content-Length'];
152
+ const upstreamRange = response.headers.get('Content-Range');
153
+ const expectedRange = rangeResult.type === 'partial' ? rangeResult.headers['Content-Range'] : undefined;
154
+ if (upstreamLength !== null && upstreamLength !== expectedLength || expectedRange && upstreamRange !== expectedRange) {
155
+ void response.body?.cancel().catch(()=>undefined);
156
+ throw new Error('imagekitStorage: ImageKit returned mismatched file content metadata.');
137
157
  }
138
- return new Response(filePrefix, {
139
- headers: {
140
- 'Content-Length': String(filePrefix.byteLength),
141
- 'Content-Type': mimeType
142
- },
143
- status: 200
158
+ const headers = new Headers(rangeResult.headers);
159
+ headers.set('Content-Type', mimeType);
160
+ if (isXmlMimeType(mimeType)) {
161
+ headers.set('Content-Security-Policy', UPLOAD_CONTENT_SECURITY_POLICY);
162
+ }
163
+ return new Response(response.body, {
164
+ headers,
165
+ status: response.status
144
166
  });
145
167
  };
146
- return ({ collection })=>{
168
+ return ({ collection, prefix = getCollectionFolder(rootFolder, collection.slug) })=>{
147
169
  const isPrivate = privateSlugs.has(collection.slug);
148
170
  // Sign a relative ImageKit path against the configured urlEndpoint. We always sign
149
171
  // a computed path (never a stored absolute URL) because ImageKit's signature is
@@ -165,107 +187,140 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
165
187
  clientUploads,
166
188
  fields: [
167
189
  {
168
- name: 'imagekit',
169
- type: 'group',
190
+ // Presentational wrapper: tucks the ImageKit details into a closed panel without
191
+ // changing the `imagekit.*` data paths.
192
+ type: 'collapsible',
170
193
  admin: {
171
194
  condition: (data)=>hasStoredImageKitURL(data),
172
- disableListColumn: true,
173
- disableListFilter: true
195
+ initCollapsed: true
174
196
  },
175
197
  fields: [
176
198
  {
177
- name: 'fileId',
178
- type: 'text',
179
- admin: {
180
- hidden: true
181
- }
182
- },
183
- {
184
- name: 'url',
185
- type: 'text',
199
+ name: 'imagekit',
200
+ type: 'group',
186
201
  admin: {
187
- readOnly: true
202
+ disableListColumn: true,
203
+ disableListFilter: true
188
204
  },
189
- label: 'URL'
190
- },
191
- {
192
- name: 'isPrivate',
193
- type: 'checkbox',
194
- admin: {
195
- condition: (_data, siblingData)=>isDocumentPrivate({
196
- imagekit: siblingData
197
- }, isPrivate),
198
- description: "Indicates whether this file was uploaded to ImageKit as private. Private files are delivered through signed URLs. Changes to this collection's private option in payload.config only affect future uploads.",
199
- readOnly: true
200
- },
201
- label: 'Private file'
202
- },
203
- {
204
- name: 'signedUrl',
205
- type: 'text',
206
- admin: {
207
- // Only relevant for private files; the plain `url` works directly for public ones.
208
- condition: (_data, siblingData)=>isDocumentPrivate({
209
- imagekit: siblingData
210
- }, isPrivate),
211
- description: `Temporary signed URL for reaching this private file directly on ImageKit. Expires ${signedUrlExpiresIn} seconds after load and is regenerated on every read. Note: anyone with this URL can access the file until it expires — it is not gated by Payload access control.`,
212
- readOnly: true
213
- },
214
- hooks: {
215
- // Computed server-side on read so it is always freshly signed and never persisted.
216
- afterRead: [
217
- ({ data, siblingData })=>{
218
- if (!isDocumentPrivate({
219
- imagekit: siblingData
220
- }, isPrivate)) {
221
- return undefined;
222
- }
223
- const filename = data?.filename;
224
- if (!filename) {
225
- return undefined;
226
- }
227
- return buildSignedURL(getFilePath(rootFolder, collection.slug, filename));
205
+ fields: [
206
+ {
207
+ name: 'fileId',
208
+ type: 'text',
209
+ admin: {
210
+ hidden: true
228
211
  }
229
- ]
230
- },
231
- label: 'Signed URL',
232
- virtual: true
233
- },
234
- {
235
- name: 'thumbnailUrl',
236
- type: 'text',
237
- admin: {
238
- hidden: true
239
- }
212
+ },
213
+ {
214
+ name: 'url',
215
+ type: 'text',
216
+ admin: {
217
+ readOnly: true
218
+ },
219
+ label: 'URL'
220
+ },
221
+ {
222
+ name: 'isPrivate',
223
+ type: 'checkbox',
224
+ admin: {
225
+ condition: (_data, siblingData)=>isDocumentPrivate({
226
+ imagekit: siblingData
227
+ }, isPrivate),
228
+ description: "Indicates whether this file was uploaded to ImageKit as private. Private files are delivered through signed URLs. Changes to this collection's private option in payload.config only affect future uploads.",
229
+ readOnly: true
230
+ },
231
+ label: 'Private file'
232
+ },
233
+ {
234
+ name: 'signedUrl',
235
+ type: 'text',
236
+ admin: {
237
+ // Only relevant for private files; the plain `url` works directly for public ones.
238
+ condition: (_data, siblingData)=>isDocumentPrivate({
239
+ imagekit: siblingData
240
+ }, isPrivate),
241
+ description: `Temporary signed URL for reaching this private file directly on ImageKit. Expires ${signedUrlExpiresIn} seconds after load and is regenerated on every read. Note: anyone with this URL can access the file until it expires — it is not gated by Payload access control.`,
242
+ readOnly: true
243
+ },
244
+ hooks: {
245
+ // Computed server-side on read so it is always freshly signed and never persisted.
246
+ afterRead: [
247
+ ({ data, siblingData })=>{
248
+ if (!isDocumentPrivate({
249
+ imagekit: siblingData
250
+ }, isPrivate)) {
251
+ return undefined;
252
+ }
253
+ const filename = data?.filename;
254
+ if (!filename) {
255
+ return undefined;
256
+ }
257
+ const source = data || {};
258
+ const documentPrefix = path.posix.join(source.prefix || prefix, source._objectKey || '');
259
+ const storageFilePath = buildStoragePathData({
260
+ collectionPrefix: prefix,
261
+ docPrefix: documentPrefix,
262
+ filename
263
+ }).storageFilePath;
264
+ return buildSignedURL(`/${storageFilePath}`);
265
+ }
266
+ ]
267
+ },
268
+ label: 'Signed URL',
269
+ virtual: true
270
+ },
271
+ {
272
+ name: 'thumbnailUrl',
273
+ type: 'text',
274
+ admin: {
275
+ hidden: true
276
+ }
277
+ }
278
+ ],
279
+ interfaceName: 'imagekitFields',
280
+ label: false
240
281
  }
241
282
  ],
242
- interfaceName: 'imagekitFields',
243
- label: 'ImageKit'
283
+ label: 'Storage: ImageKit'
244
284
  }
245
285
  ],
246
- generateURL: ({ filename })=>client.helper.buildSrc({
247
- src: getFilePath(rootFolder, collection.slug, filename),
286
+ generateURL: ({ filename, prefix: documentPrefix = '' })=>client.helper.buildSrc({
287
+ src: `/${buildStoragePathData({
288
+ collectionPrefix: prefix,
289
+ docPrefix: documentPrefix,
290
+ filename
291
+ }).storageFilePath}`,
248
292
  urlEndpoint
249
293
  }),
250
- handleDelete: async ({ doc, filename })=>{
294
+ handleDelete: async ({ doc, storageFilePath })=>{
251
295
  const storedFileId = doc.imagekit?.fileId;
252
- const fileId = storedFileId || await findImageKitFileId(client, getFilePath(rootFolder, collection.slug, filename));
296
+ const fileId = storedFileId || await findImageKitFileId(client, `/${storageFilePath.replace(/^\/+/, '')}`);
253
297
  if (!fileId) {
254
298
  return;
255
299
  }
256
300
  await client.files.delete(fileId);
257
301
  },
258
- handleUpload: async ({ clientUploadContext, data, file })=>{
259
- if (isImageKitClientUploadContext(clientUploadContext)) {
260
- return applyClientUploadContextToData(data, clientUploadContext, buildThumbnailURL, isPrivate);
302
+ handleUpload: async ({ clientUploadContext, data, file, storageFilePath })=>{
303
+ if (clientUploadContext) {
304
+ const uploaded = await verifyClientUpload({
305
+ collectionSlug: collection.slug,
306
+ context: clientUploadContext
307
+ });
308
+ return applyVerifiedClientUploadToData(data, uploaded, buildThumbnailURL, isPrivate);
261
309
  }
262
- const uploadFile = await toFile(file.buffer, file.filename, {
310
+ // ImageKit's `toFile()` consumes async iterables before starting the request. A file-backed
311
+ // Blob keeps temporary-file uploads lazy so FormData can stream them from disk instead.
312
+ const uploadSource = file.tempFilePath ? await openAsBlob(file.tempFilePath, {
313
+ type: file.mimeType
314
+ }) : file.buffer;
315
+ const uploadFile = await toFile(uploadSource, file.filename, {
263
316
  type: file.mimeType
264
317
  });
318
+ const uploadFolder = path.posix.dirname(`/${storageFilePath.replace(/^\/+/, '')}`);
319
+ const uploadFilename = path.posix.basename(storageFilePath);
265
320
  const uploaded = await client.files.upload({
266
321
  file: uploadFile,
267
- fileName: file.filename,
268
- folder: getCollectionFolder(rootFolder, collection.slug),
322
+ fileName: uploadFilename,
323
+ folder: uploadFolder,
269
324
  useUniqueFileName: false,
270
325
  ...isPrivate ? {
271
326
  isPrivateFile: true
@@ -282,44 +337,30 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
282
337
  // `data` yet at this point, so fall back to the uploaded file's name.
283
338
  data.thumbnailURL = buildThumbnailURL({
284
339
  ...data,
285
- filename: data.filename || file.filename
340
+ filename: data.filename || uploadFilename
286
341
  });
287
342
  return data;
288
343
  },
344
+ requiresClientUploadReceipt: true,
289
345
  staticHandler: async (req, args)=>{
290
346
  const requestURL = new URL(req.url || 'http://localhost');
291
347
  const tr = requestURL.searchParams.get('tr');
292
348
  const hasClientUploadContext = 'clientUploadContext' in args.params;
293
349
  if (hasClientUploadContext) {
294
350
  const clientUploadContext = args.params.clientUploadContext;
295
- if (!isImageKitClientUploadContext(clientUploadContext)) {
296
- throw new Error('imagekitStorage: client upload context is invalid.');
297
- }
298
- const expectedFilePath = getFilePath(rootFolder, args.params.collection, args.params.filename);
299
- const fileId = clientUploadContext.fileId;
300
- if (typeof fileId !== 'string' || !fileId) {
301
- throw new Error('imagekitStorage: client upload is missing its file ID.');
302
- }
303
- const uploadedFile = await client.files.get(fileId);
304
- const uploadedFileMime = uploadedFile.mime;
305
- const uploadedFileSize = uploadedFile.size;
306
- const metadataMatchesUpload = uploadedFile.fileId === fileId && uploadedFile.filePath === expectedFilePath && uploadedFile.name === args.params.filename && typeof uploadedFileMime === 'string' && typeof uploadedFile.url === 'string' && typeof uploadedFileSize === 'number' && Number.isSafeInteger(uploadedFileSize) && uploadedFileSize > 0 && Boolean(uploadedFile.isPrivateFile) === isPrivate && (typeof clientUploadContext.size !== 'number' || uploadedFileSize === clientUploadContext.size);
307
- if (!metadataMatchesUpload || typeof uploadedFileMime !== 'string' || typeof uploadedFileSize !== 'number') {
308
- throw new Error('imagekitStorage: client upload metadata did not match.');
351
+ if (!isImageKitReceiptContext(clientUploadContext)) {
352
+ throw new APIError('Invalid ImageKit client upload context.', 400);
309
353
  }
310
- // Replace browser-provided values before hooks persist the upload context.
311
- Object.assign(clientUploadContext, {
312
- name: uploadedFile.name,
313
- fileId: uploadedFile.fileId,
314
- filePath: uploadedFile.filePath,
315
- fileType: uploadedFile.fileType,
316
- mimeType: uploadedFileMime,
317
- size: uploadedFileSize,
318
- thumbnailUrl: uploadedFile.thumbnail,
319
- url: uploadedFile.url
354
+ const uploadedFile = await verifyClientUpload({
355
+ collectionSlug: collection.slug,
356
+ context: clientUploadContext
320
357
  });
321
- const verifiedFileURL = isPrivate ? buildSignedURL(expectedFilePath, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
322
- src: expectedFilePath,
358
+ const expectedFilePath = getClientUploadStorageFilePath({
359
+ collectionPrefix: prefix,
360
+ context: clientUploadContext
361
+ });
362
+ const verifiedFileURL = isPrivate ? buildSignedURL(`/${expectedFilePath}`, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
363
+ src: `/${expectedFilePath}`,
323
364
  transformation: [
324
365
  {
325
366
  raw: ORIGINAL_FILE_TRANSFORMATION
@@ -327,14 +368,12 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
327
368
  ],
328
369
  urlEndpoint
329
370
  });
330
- if (isVideoMimeType(uploadedFileMime)) {
331
- return fetchUploadedFilePrefix({
332
- fileSize: uploadedFileSize,
333
- fileURL: verifiedFileURL,
334
- mimeType: uploadedFileMime
335
- });
336
- }
337
- return fetchUploadedFile(verifiedFileURL);
371
+ return fetchUploadedFile({
372
+ fileSize: uploadedFile.size,
373
+ fileURL: verifiedFileURL,
374
+ mimeType: uploadedFile.mimeType,
375
+ range: req.headers.get('Range')
376
+ });
338
377
  }
339
378
  const doc = args.doc || await findUploadDocumentByFilename({
340
379
  collection: args.params.collection,
@@ -342,11 +381,43 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
342
381
  prefix: args.params.prefix,
343
382
  req
344
383
  });
384
+ const storageFilePath = await getStorageFilePath({
385
+ collection,
386
+ collectionPrefix: prefix,
387
+ doc: doc || undefined,
388
+ filename: args.params.filename,
389
+ prefixQueryParam: args.params.prefix,
390
+ req
391
+ });
392
+ const imageKitPath = `/${storageFilePath.replace(/^\/+/, '')}`;
393
+ const mimeType = doc?.mimeType;
394
+ // XML-family uploads (including SVG) must pass through Payload so its CSP is
395
+ // present even when ImageKit uses a custom, same-origin delivery domain.
396
+ if (typeof mimeType === 'string' && isXmlMimeType(mimeType)) {
397
+ const filesize = doc?.filesize;
398
+ if (typeof filesize !== 'number' || !Number.isSafeInteger(filesize) || filesize < 0) {
399
+ throw new APIError('Stored ImageKit file metadata is incomplete.', 500);
400
+ }
401
+ const fileURL = isDocumentPrivate(doc, isPrivate) ? buildSignedURL(imageKitPath, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
402
+ src: imageKitPath,
403
+ transformation: [
404
+ {
405
+ raw: ORIGINAL_FILE_TRANSFORMATION
406
+ }
407
+ ],
408
+ urlEndpoint
409
+ });
410
+ return fetchUploadedFile({
411
+ fileSize: filesize,
412
+ fileURL,
413
+ mimeType,
414
+ range: req.headers.get('Range')
415
+ });
416
+ }
345
417
  // Sign based on the document's stored status, not the collection's current
346
418
  // setting, so files keep working if the collection's privacy is changed later.
347
419
  if (isDocumentPrivate(doc, isPrivate)) {
348
- const mimeType = doc?.mimeType;
349
- let signSrc = getFilePath(rootFolder, args.params.collection, args.params.filename);
420
+ let signSrc = imageKitPath;
350
421
  // A `tr` request for a video/PDF is a thumbnail request (the plain transform
351
422
  // can't render a preview); serve ImageKit's generated thumbnail instead.
352
423
  if (tr && isVideoMimeType(mimeType)) {
@@ -357,7 +428,7 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
357
428
  return Response.redirect(buildSignedURL(signSrc, tr), 302);
358
429
  }
359
430
  const imageKitURL = client.helper.buildSrc({
360
- src: getStoredImageKitURL(doc) || getFilePath(rootFolder, args.params.collection, args.params.filename),
431
+ src: getStoredImageKitURL(doc) || imageKitPath,
361
432
  transformation: tr ? [
362
433
  {
363
434
  raw: tr
@@ -370,14 +441,15 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
370
441
  };
371
442
  };
372
443
  };
373
- const toCloudStorageCollections = (collections, adapter, privateSlugs, disablePayloadAccessControl)=>{
444
+ const toCloudStorageCollections = (collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl)=>{
374
445
  const mappedCollections = {};
375
446
  for (const collectionSlug of Object.keys(collections)){
376
447
  mappedCollections[collectionSlug] = {
377
448
  adapter,
378
449
  disableLocalStorage: true,
379
450
  // Private files require Payload access control so URLs can be signed at serve time.
380
- disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl
451
+ disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl,
452
+ prefix: getCollectionFolder(rootFolder, collectionSlug)
381
453
  };
382
454
  }
383
455
  return mappedCollections;
@@ -397,15 +469,34 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
397
469
  throw new Error('imagekitStorage: `collections` must include at least one upload collection.');
398
470
  }
399
471
  const privateSlugs = new Set(Object.entries(collections).filter(([, value])=>isPrivateCollectionConfig(value)).map(([slug])=>slug));
472
+ const configuredSlugs = new Set(Object.keys(collections));
400
473
  if (disablePayloadAccessControl && privateSlugs.size > 0) {
401
474
  throw new Error('imagekitStorage: `disablePayloadAccessControl` cannot be used with private collections. Private files require Payload access control to remain enabled so URLs can be signed at serve time.');
402
475
  }
476
+ if (disablePayloadAccessControl) {
477
+ const xmlCollectionSlugs = (config.collections || []).filter((collection)=>configuredSlugs.has(collection.slug) && collectionCanStoreXml(collection)).map((collection)=>collection.slug);
478
+ if (xmlCollectionSlugs.length > 0) {
479
+ throw new Error(`imagekitStorage: \`disablePayloadAccessControl\` cannot be used with collections that can store XML-family files (${xmlCollectionSlugs.join(', ')}). XML-family files require Payload's file route so its Content Security Policy is applied. Configure a MIME allow list that excludes SVG/XML, or keep Payload access control enabled.`);
480
+ }
481
+ }
403
482
  const signedUrlExpiresIn = typeof signedUrlExpiresInOption === 'number' && signedUrlExpiresInOption > 0 ? signedUrlExpiresInOption : DEFAULT_SIGNED_URL_EXPIRES_IN;
404
483
  const apiRoute = config.routes?.api ?? '/api';
405
484
  const rootFolder = normalizeFolder(folder);
406
485
  const imageKitClient = new ImageKit({
407
486
  privateKey
408
487
  });
488
+ const collectionSecurity = Object.fromEntries((config.collections || []).filter((collection)=>configuredSlugs.has(collection.slug)).map((collection)=>[
489
+ collection.slug,
490
+ {
491
+ collection,
492
+ isPrivate: privateSlugs.has(collection.slug),
493
+ prefix: getCollectionFolder(rootFolder, collection.slug)
494
+ }
495
+ ]));
496
+ const verifyClientUpload = createImageKitClientUploadVerifier({
497
+ client: imageKitClient,
498
+ collections: collectionSecurity
499
+ });
409
500
  const adapter = imagekitAdapter({
410
501
  apiRoute,
411
502
  client: imageKitClient,
@@ -413,9 +504,9 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
413
504
  privateSlugs,
414
505
  rootFolder,
415
506
  signedUrlExpiresIn,
416
- urlEndpoint
507
+ urlEndpoint,
508
+ verifyClientUpload
417
509
  });
418
- const configuredSlugs = new Set(Object.keys(collections));
419
510
  const configWithCropDisabled = {
420
511
  ...config,
421
512
  collections: (config.collections || []).map((collection)=>{
@@ -424,7 +515,6 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
424
515
  }
425
516
  const isCollectionPrivate = privateSlugs.has(collection.slug);
426
517
  const buildThumbnailURL = (doc)=>isDocumentPrivate(doc, isCollectionPrivate) ? getPrivateAdminThumbnailURL(doc, collection.slug, apiRoute) : getAdminThumbnailURL(doc);
427
- const beforeDocumentControls = collection.admin?.components?.edit?.beforeDocumentControls || [];
428
518
  const collectionOptions = collections[collection.slug];
429
519
  const collectionFocalPoint = collectionOptions !== true && typeof collectionOptions === 'object' && typeof collectionOptions.focalPoint === 'boolean' ? collectionOptions.focalPoint : undefined;
430
520
  // Surface a "this is a private collection" note in the list and edit views.
@@ -437,37 +527,60 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
437
527
  } : collection.admin;
438
528
  return {
439
529
  ...collection,
440
- admin: clientUploads ? {
441
- ...baseAdmin || {},
442
- components: {
443
- ...collection.admin?.components || {},
444
- edit: {
445
- ...collection.admin?.components?.edit || {},
446
- beforeDocumentControls: [
447
- ...beforeDocumentControls,
448
- {
449
- clientProps: {
450
- authPath: getImageKitUploadAuthPath(collection.slug),
451
- collectionSlug: collection.slug,
452
- folder: getCollectionFolder(rootFolder, collection.slug),
453
- isPrivate: isCollectionPrivate,
454
- publicKey
455
- },
456
- path: '@humaan/payload-storage-imagekit/client#ImageKitUploadHandler'
457
- }
458
- ]
459
- }
460
- }
461
- } : baseAdmin,
530
+ admin: baseAdmin,
462
531
  hooks: {
463
532
  ...collection.hooks || {},
464
533
  beforeChange: [
465
534
  ...collection.hooks?.beforeChange || [],
466
- ({ data, req })=>{
535
+ async ({ data, req })=>{
467
536
  if (!isRecord(data) || !req.file?.clientUploadContext) {
468
537
  return data;
469
538
  }
470
- return applyClientUploadContextToData(data, req.file.clientUploadContext, buildThumbnailURL, isCollectionPrivate);
539
+ const context = req.file.clientUploadContext;
540
+ const requestMimeType = req.file.mimetype.split(';', 1)[0]?.trim().toLowerCase();
541
+ if (!isImageKitReceiptContext(context) || context.filename !== data.filename || context.filename !== req.file.name || context.expectedMimeType !== requestMimeType || // Payload can clear req.file.size when preparing an in-memory image.
542
+ // generateFileData preserves the original size on the document.
543
+ context.expectedSize !== data.filesize) {
544
+ throw new APIError('ImageKit client upload reference does not match this request.', 400);
545
+ }
546
+ const uploaded = await verifyClientUpload({
547
+ collectionSlug: collection.slug,
548
+ context
549
+ });
550
+ // A provider file can only be claimed once, including through legacy receipts.
551
+ // The unique file ID index also rejects concurrent claims.
552
+ const existing = await req.payload.db.findOne({
553
+ collection: collection.slug,
554
+ req,
555
+ where: {
556
+ 'imagekit.fileId': {
557
+ equals: uploaded.fileId
558
+ }
559
+ }
560
+ });
561
+ if (existing) {
562
+ throw new APIError('This ImageKit client upload receipt has already been used.', 400);
563
+ }
564
+ data.filename = uploaded.name;
565
+ data.filesize = uploaded.size;
566
+ data.mimeType = uploaded.mimeType;
567
+ return applyVerifiedClientUploadToData(data, uploaded, buildThumbnailURL, isCollectionPrivate);
568
+ }
569
+ ],
570
+ beforeOperation: [
571
+ ...collection.hooks?.beforeOperation || [],
572
+ ({ args, operation, req })=>{
573
+ if (operation !== 'create' && operation !== 'update' || req.context?.skipCloudStorage || !req.file || req.file.clientUploadContext || !('data' in args) || !isRecord(args.data) || typeof args.data.prefix !== 'string') {
574
+ return args;
575
+ }
576
+ // Payload checks filename uniqueness before cloud storage's beforeChange
577
+ // normalizes the prefix. Use the persisted form during that lookup too.
578
+ args.data.prefix = buildUploadStoragePathData({
579
+ collectionPrefix: getCollectionFolder(rootFolder, collection.slug),
580
+ docPrefix: args.data.prefix,
581
+ filename: req.file.name
582
+ }).sanitizedDocPrefix;
583
+ return args;
471
584
  }
472
585
  ]
473
586
  },
@@ -488,24 +601,71 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
488
601
  crop: false
489
602
  }
490
603
  };
491
- }),
492
- endpoints: clientUploads ? [
493
- ...config.endpoints || [],
604
+ })
605
+ };
606
+ const uploadCollections = Object.fromEntries(Object.keys(collections).map((collectionSlug)=>[
607
+ collectionSlug,
494
608
  {
495
- handler: createImageKitUploadAuthHandler({
496
- client: imageKitClient,
497
- collections
498
- }),
499
- method: 'get',
500
- path: `${getImageKitUploadAuthPath(':collection')}`
609
+ isPrivate: privateSlugs.has(collectionSlug),
610
+ prefix: getCollectionFolder(rootFolder, collectionSlug)
501
611
  }
502
- ] : config.endpoints
503
- };
504
- return cloudStoragePlugin({
612
+ ]));
613
+ initClientUploads({
614
+ clientHandler: '@humaan/payload-storage-imagekit/client#ImageKitUploadHandler',
615
+ collections: uploadCollections,
616
+ config: configWithCropDisabled,
617
+ enabled: enabled !== false && Boolean(clientUploads),
618
+ serverHandler: createImageKitUploadAuthHandler({
619
+ access: typeof clientUploads === 'object' ? clientUploads.access : undefined,
620
+ collections: uploadCollections,
621
+ privateKey,
622
+ publicKey,
623
+ verifyClientUpload
624
+ }),
625
+ serverHandlerPath: imageKitUploadAuthPath
626
+ });
627
+ const output = cloudStoragePlugin({
505
628
  alwaysInsertFields,
506
- collections: toCloudStorageCollections(collections, adapter, privateSlugs, disablePayloadAccessControl),
629
+ collections: toCloudStorageCollections(collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl),
507
630
  enabled
508
631
  })(configWithCropDisabled);
632
+ // Make the provider identity unique at persistence time to reject concurrent receipt replays.
633
+ // Keep the legacy object key index for existing uploads.
634
+ output.collections = (output.collections || []).map((collection)=>{
635
+ if (!configuredSlugs.has(collection.slug)) {
636
+ return collection;
637
+ }
638
+ const withUniqueIdentity = (field)=>{
639
+ if ('name' in field && field.name === '_objectKey') {
640
+ return {
641
+ ...field,
642
+ unique: true
643
+ };
644
+ }
645
+ if (field.type === 'group' && 'name' in field && field.name === 'imagekit') {
646
+ return {
647
+ ...field,
648
+ fields: field.fields.map((metadataField)=>'name' in metadataField && metadataField.name === 'fileId' ? {
649
+ ...metadataField,
650
+ unique: true
651
+ } : metadataField)
652
+ };
653
+ }
654
+ // The imagekit group sits inside the adapter's presentational "Storage: ImageKit" panel.
655
+ if (field.type === 'collapsible') {
656
+ return {
657
+ ...field,
658
+ fields: field.fields.map(withUniqueIdentity)
659
+ };
660
+ }
661
+ return field;
662
+ };
663
+ return {
664
+ ...collection,
665
+ fields: collection.fields.map(withUniqueIdentity)
666
+ };
667
+ });
668
+ return output;
509
669
  };
510
670
  export { migrateFolder } from './migrate.js';
511
671
  // Backward-compatible alias