@humaan/payload-storage-imagekit 2.6.2 → 2.7.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
@@ -224,7 +246,14 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
224
246
  if (!filename) {
225
247
  return undefined;
226
248
  }
227
- return buildSignedURL(getFilePath(rootFolder, collection.slug, filename));
249
+ const source = data || {};
250
+ const documentPrefix = path.posix.join(source.prefix || prefix, source._objectKey || '');
251
+ const storageFilePath = buildStoragePathData({
252
+ collectionPrefix: prefix,
253
+ docPrefix: documentPrefix,
254
+ filename
255
+ }).storageFilePath;
256
+ return buildSignedURL(`/${storageFilePath}`);
228
257
  }
229
258
  ]
230
259
  },
@@ -243,29 +272,44 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
243
272
  label: 'ImageKit'
244
273
  }
245
274
  ],
246
- generateURL: ({ filename })=>client.helper.buildSrc({
247
- src: getFilePath(rootFolder, collection.slug, filename),
275
+ generateURL: ({ filename, prefix: documentPrefix = '' })=>client.helper.buildSrc({
276
+ src: `/${buildStoragePathData({
277
+ collectionPrefix: prefix,
278
+ docPrefix: documentPrefix,
279
+ filename
280
+ }).storageFilePath}`,
248
281
  urlEndpoint
249
282
  }),
250
- handleDelete: async ({ doc, filename })=>{
283
+ handleDelete: async ({ doc, storageFilePath })=>{
251
284
  const storedFileId = doc.imagekit?.fileId;
252
- const fileId = storedFileId || await findImageKitFileId(client, getFilePath(rootFolder, collection.slug, filename));
285
+ const fileId = storedFileId || await findImageKitFileId(client, `/${storageFilePath.replace(/^\/+/, '')}`);
253
286
  if (!fileId) {
254
287
  return;
255
288
  }
256
289
  await client.files.delete(fileId);
257
290
  },
258
- handleUpload: async ({ clientUploadContext, data, file })=>{
259
- if (isImageKitClientUploadContext(clientUploadContext)) {
260
- return applyClientUploadContextToData(data, clientUploadContext, buildThumbnailURL, isPrivate);
291
+ handleUpload: async ({ clientUploadContext, data, file, storageFilePath })=>{
292
+ if (clientUploadContext) {
293
+ const uploaded = await verifyClientUpload({
294
+ collectionSlug: collection.slug,
295
+ context: clientUploadContext
296
+ });
297
+ return applyVerifiedClientUploadToData(data, uploaded, buildThumbnailURL, isPrivate);
261
298
  }
262
- const uploadFile = await toFile(file.buffer, file.filename, {
299
+ // ImageKit's `toFile()` consumes async iterables before starting the request. A file-backed
300
+ // Blob keeps temporary-file uploads lazy so FormData can stream them from disk instead.
301
+ const uploadSource = file.tempFilePath ? await openAsBlob(file.tempFilePath, {
302
+ type: file.mimeType
303
+ }) : file.buffer;
304
+ const uploadFile = await toFile(uploadSource, file.filename, {
263
305
  type: file.mimeType
264
306
  });
307
+ const uploadFolder = path.posix.dirname(`/${storageFilePath.replace(/^\/+/, '')}`);
308
+ const uploadFilename = path.posix.basename(storageFilePath);
265
309
  const uploaded = await client.files.upload({
266
310
  file: uploadFile,
267
- fileName: file.filename,
268
- folder: getCollectionFolder(rootFolder, collection.slug),
311
+ fileName: uploadFilename,
312
+ folder: uploadFolder,
269
313
  useUniqueFileName: false,
270
314
  ...isPrivate ? {
271
315
  isPrivateFile: true
@@ -282,44 +326,30 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
282
326
  // `data` yet at this point, so fall back to the uploaded file's name.
283
327
  data.thumbnailURL = buildThumbnailURL({
284
328
  ...data,
285
- filename: data.filename || file.filename
329
+ filename: data.filename || uploadFilename
286
330
  });
287
331
  return data;
288
332
  },
333
+ requiresClientUploadReceipt: true,
289
334
  staticHandler: async (req, args)=>{
290
335
  const requestURL = new URL(req.url || 'http://localhost');
291
336
  const tr = requestURL.searchParams.get('tr');
292
337
  const hasClientUploadContext = 'clientUploadContext' in args.params;
293
338
  if (hasClientUploadContext) {
294
339
  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.');
340
+ if (!isImageKitReceiptContext(clientUploadContext)) {
341
+ throw new APIError('Invalid ImageKit client upload context.', 400);
302
342
  }
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.');
309
- }
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
343
+ const uploadedFile = await verifyClientUpload({
344
+ collectionSlug: collection.slug,
345
+ context: clientUploadContext
346
+ });
347
+ const expectedFilePath = getClientUploadStorageFilePath({
348
+ collectionPrefix: prefix,
349
+ context: clientUploadContext
320
350
  });
321
- const verifiedFileURL = isPrivate ? buildSignedURL(expectedFilePath, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
322
- src: expectedFilePath,
351
+ const verifiedFileURL = isPrivate ? buildSignedURL(`/${expectedFilePath}`, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
352
+ src: `/${expectedFilePath}`,
323
353
  transformation: [
324
354
  {
325
355
  raw: ORIGINAL_FILE_TRANSFORMATION
@@ -327,14 +357,12 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
327
357
  ],
328
358
  urlEndpoint
329
359
  });
330
- if (isVideoMimeType(uploadedFileMime)) {
331
- return fetchUploadedFilePrefix({
332
- fileSize: uploadedFileSize,
333
- fileURL: verifiedFileURL,
334
- mimeType: uploadedFileMime
335
- });
336
- }
337
- return fetchUploadedFile(verifiedFileURL);
360
+ return fetchUploadedFile({
361
+ fileSize: uploadedFile.size,
362
+ fileURL: verifiedFileURL,
363
+ mimeType: uploadedFile.mimeType,
364
+ range: req.headers.get('Range')
365
+ });
338
366
  }
339
367
  const doc = args.doc || await findUploadDocumentByFilename({
340
368
  collection: args.params.collection,
@@ -342,11 +370,43 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
342
370
  prefix: args.params.prefix,
343
371
  req
344
372
  });
373
+ const storageFilePath = await getStorageFilePath({
374
+ collection,
375
+ collectionPrefix: prefix,
376
+ doc: doc || undefined,
377
+ filename: args.params.filename,
378
+ prefixQueryParam: args.params.prefix,
379
+ req
380
+ });
381
+ const imageKitPath = `/${storageFilePath.replace(/^\/+/, '')}`;
382
+ const mimeType = doc?.mimeType;
383
+ // XML-family uploads (including SVG) must pass through Payload so its CSP is
384
+ // present even when ImageKit uses a custom, same-origin delivery domain.
385
+ if (typeof mimeType === 'string' && isXmlMimeType(mimeType)) {
386
+ const filesize = doc?.filesize;
387
+ if (typeof filesize !== 'number' || !Number.isSafeInteger(filesize) || filesize < 0) {
388
+ throw new APIError('Stored ImageKit file metadata is incomplete.', 500);
389
+ }
390
+ const fileURL = isDocumentPrivate(doc, isPrivate) ? buildSignedURL(imageKitPath, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
391
+ src: imageKitPath,
392
+ transformation: [
393
+ {
394
+ raw: ORIGINAL_FILE_TRANSFORMATION
395
+ }
396
+ ],
397
+ urlEndpoint
398
+ });
399
+ return fetchUploadedFile({
400
+ fileSize: filesize,
401
+ fileURL,
402
+ mimeType,
403
+ range: req.headers.get('Range')
404
+ });
405
+ }
345
406
  // Sign based on the document's stored status, not the collection's current
346
407
  // setting, so files keep working if the collection's privacy is changed later.
347
408
  if (isDocumentPrivate(doc, isPrivate)) {
348
- const mimeType = doc?.mimeType;
349
- let signSrc = getFilePath(rootFolder, args.params.collection, args.params.filename);
409
+ let signSrc = imageKitPath;
350
410
  // A `tr` request for a video/PDF is a thumbnail request (the plain transform
351
411
  // can't render a preview); serve ImageKit's generated thumbnail instead.
352
412
  if (tr && isVideoMimeType(mimeType)) {
@@ -357,7 +417,7 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
357
417
  return Response.redirect(buildSignedURL(signSrc, tr), 302);
358
418
  }
359
419
  const imageKitURL = client.helper.buildSrc({
360
- src: getStoredImageKitURL(doc) || getFilePath(rootFolder, args.params.collection, args.params.filename),
420
+ src: getStoredImageKitURL(doc) || imageKitPath,
361
421
  transformation: tr ? [
362
422
  {
363
423
  raw: tr
@@ -370,14 +430,15 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
370
430
  };
371
431
  };
372
432
  };
373
- const toCloudStorageCollections = (collections, adapter, privateSlugs, disablePayloadAccessControl)=>{
433
+ const toCloudStorageCollections = (collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl)=>{
374
434
  const mappedCollections = {};
375
435
  for (const collectionSlug of Object.keys(collections)){
376
436
  mappedCollections[collectionSlug] = {
377
437
  adapter,
378
438
  disableLocalStorage: true,
379
439
  // Private files require Payload access control so URLs can be signed at serve time.
380
- disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl
440
+ disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl,
441
+ prefix: getCollectionFolder(rootFolder, collectionSlug)
381
442
  };
382
443
  }
383
444
  return mappedCollections;
@@ -397,15 +458,34 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
397
458
  throw new Error('imagekitStorage: `collections` must include at least one upload collection.');
398
459
  }
399
460
  const privateSlugs = new Set(Object.entries(collections).filter(([, value])=>isPrivateCollectionConfig(value)).map(([slug])=>slug));
461
+ const configuredSlugs = new Set(Object.keys(collections));
400
462
  if (disablePayloadAccessControl && privateSlugs.size > 0) {
401
463
  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
464
  }
465
+ if (disablePayloadAccessControl) {
466
+ const xmlCollectionSlugs = (config.collections || []).filter((collection)=>configuredSlugs.has(collection.slug) && collectionCanStoreXml(collection)).map((collection)=>collection.slug);
467
+ if (xmlCollectionSlugs.length > 0) {
468
+ 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.`);
469
+ }
470
+ }
403
471
  const signedUrlExpiresIn = typeof signedUrlExpiresInOption === 'number' && signedUrlExpiresInOption > 0 ? signedUrlExpiresInOption : DEFAULT_SIGNED_URL_EXPIRES_IN;
404
472
  const apiRoute = config.routes?.api ?? '/api';
405
473
  const rootFolder = normalizeFolder(folder);
406
474
  const imageKitClient = new ImageKit({
407
475
  privateKey
408
476
  });
477
+ const collectionSecurity = Object.fromEntries((config.collections || []).filter((collection)=>configuredSlugs.has(collection.slug)).map((collection)=>[
478
+ collection.slug,
479
+ {
480
+ collection,
481
+ isPrivate: privateSlugs.has(collection.slug),
482
+ prefix: getCollectionFolder(rootFolder, collection.slug)
483
+ }
484
+ ]));
485
+ const verifyClientUpload = createImageKitClientUploadVerifier({
486
+ client: imageKitClient,
487
+ collections: collectionSecurity
488
+ });
409
489
  const adapter = imagekitAdapter({
410
490
  apiRoute,
411
491
  client: imageKitClient,
@@ -413,9 +493,9 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
413
493
  privateSlugs,
414
494
  rootFolder,
415
495
  signedUrlExpiresIn,
416
- urlEndpoint
496
+ urlEndpoint,
497
+ verifyClientUpload
417
498
  });
418
- const configuredSlugs = new Set(Object.keys(collections));
419
499
  const configWithCropDisabled = {
420
500
  ...config,
421
501
  collections: (config.collections || []).map((collection)=>{
@@ -424,7 +504,6 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
424
504
  }
425
505
  const isCollectionPrivate = privateSlugs.has(collection.slug);
426
506
  const buildThumbnailURL = (doc)=>isDocumentPrivate(doc, isCollectionPrivate) ? getPrivateAdminThumbnailURL(doc, collection.slug, apiRoute) : getAdminThumbnailURL(doc);
427
- const beforeDocumentControls = collection.admin?.components?.edit?.beforeDocumentControls || [];
428
507
  const collectionOptions = collections[collection.slug];
429
508
  const collectionFocalPoint = collectionOptions !== true && typeof collectionOptions === 'object' && typeof collectionOptions.focalPoint === 'boolean' ? collectionOptions.focalPoint : undefined;
430
509
  // Surface a "this is a private collection" note in the list and edit views.
@@ -437,37 +516,60 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
437
516
  } : collection.admin;
438
517
  return {
439
518
  ...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,
519
+ admin: baseAdmin,
462
520
  hooks: {
463
521
  ...collection.hooks || {},
464
522
  beforeChange: [
465
523
  ...collection.hooks?.beforeChange || [],
466
- ({ data, req })=>{
524
+ async ({ data, req })=>{
467
525
  if (!isRecord(data) || !req.file?.clientUploadContext) {
468
526
  return data;
469
527
  }
470
- return applyClientUploadContextToData(data, req.file.clientUploadContext, buildThumbnailURL, isCollectionPrivate);
528
+ const context = req.file.clientUploadContext;
529
+ const requestMimeType = req.file.mimetype.split(';', 1)[0]?.trim().toLowerCase();
530
+ 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.
531
+ // generateFileData preserves the original size on the document.
532
+ context.expectedSize !== data.filesize) {
533
+ throw new APIError('ImageKit client upload reference does not match this request.', 400);
534
+ }
535
+ const uploaded = await verifyClientUpload({
536
+ collectionSlug: collection.slug,
537
+ context
538
+ });
539
+ // A provider file can only be claimed once, including through legacy receipts.
540
+ // The unique file ID index also rejects concurrent claims.
541
+ const existing = await req.payload.db.findOne({
542
+ collection: collection.slug,
543
+ req,
544
+ where: {
545
+ 'imagekit.fileId': {
546
+ equals: uploaded.fileId
547
+ }
548
+ }
549
+ });
550
+ if (existing) {
551
+ throw new APIError('This ImageKit client upload receipt has already been used.', 400);
552
+ }
553
+ data.filename = uploaded.name;
554
+ data.filesize = uploaded.size;
555
+ data.mimeType = uploaded.mimeType;
556
+ return applyVerifiedClientUploadToData(data, uploaded, buildThumbnailURL, isCollectionPrivate);
557
+ }
558
+ ],
559
+ beforeOperation: [
560
+ ...collection.hooks?.beforeOperation || [],
561
+ ({ args, operation, req })=>{
562
+ if (operation !== 'create' && operation !== 'update' || req.context?.skipCloudStorage || !req.file || req.file.clientUploadContext || !('data' in args) || !isRecord(args.data) || typeof args.data.prefix !== 'string') {
563
+ return args;
564
+ }
565
+ // Payload checks filename uniqueness before cloud storage's beforeChange
566
+ // normalizes the prefix. Use the persisted form during that lookup too.
567
+ args.data.prefix = buildUploadStoragePathData({
568
+ collectionPrefix: getCollectionFolder(rootFolder, collection.slug),
569
+ docPrefix: args.data.prefix,
570
+ filename: req.file.name
571
+ }).sanitizedDocPrefix;
572
+ return args;
471
573
  }
472
574
  ]
473
575
  },
@@ -488,24 +590,63 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
488
590
  crop: false
489
591
  }
490
592
  };
491
- }),
492
- endpoints: clientUploads ? [
493
- ...config.endpoints || [],
593
+ })
594
+ };
595
+ const uploadCollections = Object.fromEntries(Object.keys(collections).map((collectionSlug)=>[
596
+ collectionSlug,
494
597
  {
495
- handler: createImageKitUploadAuthHandler({
496
- client: imageKitClient,
497
- collections
498
- }),
499
- method: 'get',
500
- path: `${getImageKitUploadAuthPath(':collection')}`
598
+ isPrivate: privateSlugs.has(collectionSlug),
599
+ prefix: getCollectionFolder(rootFolder, collectionSlug)
501
600
  }
502
- ] : config.endpoints
503
- };
504
- return cloudStoragePlugin({
601
+ ]));
602
+ initClientUploads({
603
+ clientHandler: '@humaan/payload-storage-imagekit/client#ImageKitUploadHandler',
604
+ collections: uploadCollections,
605
+ config: configWithCropDisabled,
606
+ enabled: enabled !== false && Boolean(clientUploads),
607
+ serverHandler: createImageKitUploadAuthHandler({
608
+ access: typeof clientUploads === 'object' ? clientUploads.access : undefined,
609
+ collections: uploadCollections,
610
+ privateKey,
611
+ publicKey,
612
+ verifyClientUpload
613
+ }),
614
+ serverHandlerPath: imageKitUploadAuthPath
615
+ });
616
+ const output = cloudStoragePlugin({
505
617
  alwaysInsertFields,
506
- collections: toCloudStorageCollections(collections, adapter, privateSlugs, disablePayloadAccessControl),
618
+ collections: toCloudStorageCollections(collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl),
507
619
  enabled
508
620
  })(configWithCropDisabled);
621
+ // Make the provider identity unique at persistence time to reject concurrent receipt replays.
622
+ // Keep the legacy object key index for existing uploads.
623
+ output.collections = (output.collections || []).map((collection)=>{
624
+ if (!configuredSlugs.has(collection.slug)) {
625
+ return collection;
626
+ }
627
+ return {
628
+ ...collection,
629
+ fields: collection.fields.map((field)=>{
630
+ if ('name' in field && field.name === '_objectKey') {
631
+ return {
632
+ ...field,
633
+ unique: true
634
+ };
635
+ }
636
+ if (field.type === 'group' && 'name' in field && field.name === 'imagekit') {
637
+ return {
638
+ ...field,
639
+ fields: field.fields.map((metadataField)=>'name' in metadataField && metadataField.name === 'fileId' ? {
640
+ ...metadataField,
641
+ unique: true
642
+ } : metadataField)
643
+ };
644
+ }
645
+ return field;
646
+ })
647
+ };
648
+ });
649
+ return output;
509
650
  };
510
651
  export { migrateFolder } from './migrate.js';
511
652
  // Backward-compatible alias