@humaan/payload-storage-imagekit 2.6.1 → 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,16 +1,44 @@
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}`;
15
+ /**
16
+ * ImageKit re-encodes some uploads and serves the smaller derivative from the plain
17
+ * delivery URL — a 1.9 MB MP4 can come back as 868 KB. That is exactly what we want
18
+ * for visitors, but not when reading the file back to verify a client upload: the
19
+ * bytes and the byte count would both belong to a file ImageKit's API never reported.
20
+ * `orig-true` asks for the untouched original and does not disable optimisation for
21
+ * anyone else.
22
+ */ const ORIGINAL_FILE_TRANSFORMATION = 'orig-true';
12
23
  const PRIVATE_COLLECTION_DESCRIPTION = 'Files in this collection are uploaded to ImageKit as private files and delivered through short-lived signed URLs.';
13
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
+ };
14
42
  /**
15
43
  * Whether a specific document is private. The flag stored on the document at upload time
16
44
  * (`imagekit.isPrivate`) is the source of truth — a file's privacy on ImageKit is fixed
@@ -28,7 +56,6 @@ const normalizeFolder = (folder)=>{
28
56
  return `/${normalized}`;
29
57
  };
30
58
  const getCollectionFolder = (rootFolder, collectionSlug)=>path.posix.join(rootFolder, collectionSlug);
31
- const getFilePath = (rootFolder, collectionSlug, filename)=>path.posix.join(getCollectionFolder(rootFolder, collectionSlug), filename);
32
59
  const findImageKitFileId = async (imagekit, filePath)=>{
33
60
  const parentPath = path.posix.dirname(filePath);
34
61
  const name = path.posix.basename(filePath);
@@ -79,59 +106,66 @@ const findUploadDocumentByFilename = async ({ collection, filename, prefix, req
79
106
  }
80
107
  });
81
108
  };
82
- const applyClientUploadContextToData = (data, clientUploadContext, buildThumbnailURL = getAdminThumbnailURL, isPrivate = false)=>{
83
- if (!isImageKitClientUploadContext(clientUploadContext)) {
84
- return data;
85
- }
109
+ const applyVerifiedClientUploadToData = (data, uploaded, buildThumbnailURL = getAdminThumbnailURL, isPrivate)=>{
86
110
  data.imagekit = {
87
- fileId: clientUploadContext.fileId,
88
- // Record the collection's privacy at upload time; this is the file's permanent
89
- // ImageKit status and the source of truth for delivery.
111
+ fileId: uploaded.fileId,
90
112
  isPrivate,
91
- thumbnailUrl: clientUploadContext.thumbnailUrl,
92
- url: clientUploadContext.url
113
+ thumbnailUrl: uploaded.thumbnailUrl,
114
+ url: uploaded.url
93
115
  };
94
116
  // Preserve the admin preview when the original file was already uploaded client-side.
95
117
  data.thumbnailURL = buildThumbnailURL(data);
96
118
  return data;
97
119
  };
98
- const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFolder, signedUrlExpiresIn, urlEndpoint })=>{
99
- const fetchUploadedFile = async (fileURL, range)=>{
100
- const response = range ? await fetch(fileURL, {
101
- headers: {
102
- Range: range
103
- }
104
- }) : await fetch(fileURL);
105
- if (!response.ok) {
106
- 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
+ });
107
131
  }
108
- return response;
109
- };
110
- const fetchUploadedFilePrefix = async ({ fileSize, fileURL, mimeType })=>{
111
- const response = await fetchUploadedFile(fileURL, FILE_TYPE_DETECTION_RANGE);
112
- const contentLength = Number(response.headers.get('Content-Length'));
113
- const contentRange = response.headers.get('Content-Range');
114
- const rangeMatch = contentRange?.match(/^bytes 0-(\d+)\/(\d+)$/);
115
- const rangeEnd = rangeMatch ? Number(rangeMatch[1]) : Number.NaN;
116
- const totalSize = rangeMatch ? Number(rangeMatch[2]) : Number.NaN;
117
- const expectedLength = Math.min(fileSize, FILE_TYPE_DETECTION_SIZE);
118
- if (response.status !== 206 || !rangeMatch || !Number.isSafeInteger(contentLength) || contentLength !== expectedLength || !Number.isSafeInteger(rangeEnd) || rangeEnd + 1 !== contentLength || totalSize !== fileSize) {
119
- await response.body?.cancel();
120
- 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}).`);
121
149
  }
122
- const filePrefix = await response.arrayBuffer();
123
- if (filePrefix.byteLength !== contentLength) {
124
- 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.');
125
157
  }
126
- return new Response(filePrefix, {
127
- headers: {
128
- 'Content-Length': String(filePrefix.byteLength),
129
- 'Content-Type': mimeType
130
- },
131
- 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
132
166
  });
133
167
  };
134
- return ({ collection })=>{
168
+ return ({ collection, prefix = getCollectionFolder(rootFolder, collection.slug) })=>{
135
169
  const isPrivate = privateSlugs.has(collection.slug);
136
170
  // Sign a relative ImageKit path against the configured urlEndpoint. We always sign
137
171
  // a computed path (never a stored absolute URL) because ImageKit's signature is
@@ -212,7 +246,14 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
212
246
  if (!filename) {
213
247
  return undefined;
214
248
  }
215
- 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}`);
216
257
  }
217
258
  ]
218
259
  },
@@ -231,29 +272,44 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
231
272
  label: 'ImageKit'
232
273
  }
233
274
  ],
234
- generateURL: ({ filename })=>client.helper.buildSrc({
235
- 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}`,
236
281
  urlEndpoint
237
282
  }),
238
- handleDelete: async ({ doc, filename })=>{
283
+ handleDelete: async ({ doc, storageFilePath })=>{
239
284
  const storedFileId = doc.imagekit?.fileId;
240
- const fileId = storedFileId || await findImageKitFileId(client, getFilePath(rootFolder, collection.slug, filename));
285
+ const fileId = storedFileId || await findImageKitFileId(client, `/${storageFilePath.replace(/^\/+/, '')}`);
241
286
  if (!fileId) {
242
287
  return;
243
288
  }
244
289
  await client.files.delete(fileId);
245
290
  },
246
- handleUpload: async ({ clientUploadContext, data, file })=>{
247
- if (isImageKitClientUploadContext(clientUploadContext)) {
248
- 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);
249
298
  }
250
- 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, {
251
305
  type: file.mimeType
252
306
  });
307
+ const uploadFolder = path.posix.dirname(`/${storageFilePath.replace(/^\/+/, '')}`);
308
+ const uploadFilename = path.posix.basename(storageFilePath);
253
309
  const uploaded = await client.files.upload({
254
310
  file: uploadFile,
255
- fileName: file.filename,
256
- folder: getCollectionFolder(rootFolder, collection.slug),
311
+ fileName: uploadFilename,
312
+ folder: uploadFolder,
257
313
  useUniqueFileName: false,
258
314
  ...isPrivate ? {
259
315
  isPrivateFile: true
@@ -270,54 +326,43 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
270
326
  // `data` yet at this point, so fall back to the uploaded file's name.
271
327
  data.thumbnailURL = buildThumbnailURL({
272
328
  ...data,
273
- filename: data.filename || file.filename
329
+ filename: data.filename || uploadFilename
274
330
  });
275
331
  return data;
276
332
  },
333
+ requiresClientUploadReceipt: true,
277
334
  staticHandler: async (req, args)=>{
278
335
  const requestURL = new URL(req.url || 'http://localhost');
279
336
  const tr = requestURL.searchParams.get('tr');
280
337
  const hasClientUploadContext = 'clientUploadContext' in args.params;
281
338
  if (hasClientUploadContext) {
282
339
  const clientUploadContext = args.params.clientUploadContext;
283
- if (!isImageKitClientUploadContext(clientUploadContext)) {
284
- throw new Error('imagekitStorage: client upload context is invalid.');
340
+ if (!isImageKitReceiptContext(clientUploadContext)) {
341
+ throw new APIError('Invalid ImageKit client upload context.', 400);
285
342
  }
286
- const expectedFilePath = getFilePath(rootFolder, args.params.collection, args.params.filename);
287
- const fileId = clientUploadContext.fileId;
288
- if (typeof fileId !== 'string' || !fileId) {
289
- throw new Error('imagekitStorage: client upload is missing its file ID.');
290
- }
291
- const uploadedFile = await client.files.get(fileId);
292
- const uploadedFileMime = uploadedFile.mime;
293
- const uploadedFileSize = uploadedFile.size;
294
- 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);
295
- if (!metadataMatchesUpload || typeof uploadedFileMime !== 'string' || typeof uploadedFileSize !== 'number') {
296
- throw new Error('imagekitStorage: client upload metadata did not match.');
297
- }
298
- // Replace browser-provided values before hooks persist the upload context.
299
- Object.assign(clientUploadContext, {
300
- name: uploadedFile.name,
301
- fileId: uploadedFile.fileId,
302
- filePath: uploadedFile.filePath,
303
- fileType: uploadedFile.fileType,
304
- mimeType: uploadedFileMime,
305
- size: uploadedFileSize,
306
- thumbnailUrl: uploadedFile.thumbnail,
307
- url: uploadedFile.url
343
+ const uploadedFile = await verifyClientUpload({
344
+ collectionSlug: collection.slug,
345
+ context: clientUploadContext
308
346
  });
309
- const verifiedFileURL = isPrivate ? buildSignedURL(expectedFilePath, tr) : client.helper.buildSrc({
310
- src: expectedFilePath,
347
+ const expectedFilePath = getClientUploadStorageFilePath({
348
+ collectionPrefix: prefix,
349
+ context: clientUploadContext
350
+ });
351
+ const verifiedFileURL = isPrivate ? buildSignedURL(`/${expectedFilePath}`, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
352
+ src: `/${expectedFilePath}`,
353
+ transformation: [
354
+ {
355
+ raw: ORIGINAL_FILE_TRANSFORMATION
356
+ }
357
+ ],
311
358
  urlEndpoint
312
359
  });
313
- if (isVideoMimeType(uploadedFileMime)) {
314
- return fetchUploadedFilePrefix({
315
- fileSize: uploadedFileSize,
316
- fileURL: verifiedFileURL,
317
- mimeType: uploadedFileMime
318
- });
319
- }
320
- return fetchUploadedFile(verifiedFileURL);
360
+ return fetchUploadedFile({
361
+ fileSize: uploadedFile.size,
362
+ fileURL: verifiedFileURL,
363
+ mimeType: uploadedFile.mimeType,
364
+ range: req.headers.get('Range')
365
+ });
321
366
  }
322
367
  const doc = args.doc || await findUploadDocumentByFilename({
323
368
  collection: args.params.collection,
@@ -325,11 +370,43 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
325
370
  prefix: args.params.prefix,
326
371
  req
327
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
+ }
328
406
  // Sign based on the document's stored status, not the collection's current
329
407
  // setting, so files keep working if the collection's privacy is changed later.
330
408
  if (isDocumentPrivate(doc, isPrivate)) {
331
- const mimeType = doc?.mimeType;
332
- let signSrc = getFilePath(rootFolder, args.params.collection, args.params.filename);
409
+ let signSrc = imageKitPath;
333
410
  // A `tr` request for a video/PDF is a thumbnail request (the plain transform
334
411
  // can't render a preview); serve ImageKit's generated thumbnail instead.
335
412
  if (tr && isVideoMimeType(mimeType)) {
@@ -340,7 +417,7 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
340
417
  return Response.redirect(buildSignedURL(signSrc, tr), 302);
341
418
  }
342
419
  const imageKitURL = client.helper.buildSrc({
343
- src: getStoredImageKitURL(doc) || getFilePath(rootFolder, args.params.collection, args.params.filename),
420
+ src: getStoredImageKitURL(doc) || imageKitPath,
344
421
  transformation: tr ? [
345
422
  {
346
423
  raw: tr
@@ -353,14 +430,15 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
353
430
  };
354
431
  };
355
432
  };
356
- const toCloudStorageCollections = (collections, adapter, privateSlugs, disablePayloadAccessControl)=>{
433
+ const toCloudStorageCollections = (collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl)=>{
357
434
  const mappedCollections = {};
358
435
  for (const collectionSlug of Object.keys(collections)){
359
436
  mappedCollections[collectionSlug] = {
360
437
  adapter,
361
438
  disableLocalStorage: true,
362
439
  // Private files require Payload access control so URLs can be signed at serve time.
363
- disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl
440
+ disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl,
441
+ prefix: getCollectionFolder(rootFolder, collectionSlug)
364
442
  };
365
443
  }
366
444
  return mappedCollections;
@@ -380,15 +458,34 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
380
458
  throw new Error('imagekitStorage: `collections` must include at least one upload collection.');
381
459
  }
382
460
  const privateSlugs = new Set(Object.entries(collections).filter(([, value])=>isPrivateCollectionConfig(value)).map(([slug])=>slug));
461
+ const configuredSlugs = new Set(Object.keys(collections));
383
462
  if (disablePayloadAccessControl && privateSlugs.size > 0) {
384
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.');
385
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
+ }
386
471
  const signedUrlExpiresIn = typeof signedUrlExpiresInOption === 'number' && signedUrlExpiresInOption > 0 ? signedUrlExpiresInOption : DEFAULT_SIGNED_URL_EXPIRES_IN;
387
472
  const apiRoute = config.routes?.api ?? '/api';
388
473
  const rootFolder = normalizeFolder(folder);
389
474
  const imageKitClient = new ImageKit({
390
475
  privateKey
391
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
+ });
392
489
  const adapter = imagekitAdapter({
393
490
  apiRoute,
394
491
  client: imageKitClient,
@@ -396,9 +493,9 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
396
493
  privateSlugs,
397
494
  rootFolder,
398
495
  signedUrlExpiresIn,
399
- urlEndpoint
496
+ urlEndpoint,
497
+ verifyClientUpload
400
498
  });
401
- const configuredSlugs = new Set(Object.keys(collections));
402
499
  const configWithCropDisabled = {
403
500
  ...config,
404
501
  collections: (config.collections || []).map((collection)=>{
@@ -407,7 +504,6 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
407
504
  }
408
505
  const isCollectionPrivate = privateSlugs.has(collection.slug);
409
506
  const buildThumbnailURL = (doc)=>isDocumentPrivate(doc, isCollectionPrivate) ? getPrivateAdminThumbnailURL(doc, collection.slug, apiRoute) : getAdminThumbnailURL(doc);
410
- const beforeDocumentControls = collection.admin?.components?.edit?.beforeDocumentControls || [];
411
507
  const collectionOptions = collections[collection.slug];
412
508
  const collectionFocalPoint = collectionOptions !== true && typeof collectionOptions === 'object' && typeof collectionOptions.focalPoint === 'boolean' ? collectionOptions.focalPoint : undefined;
413
509
  // Surface a "this is a private collection" note in the list and edit views.
@@ -420,37 +516,60 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
420
516
  } : collection.admin;
421
517
  return {
422
518
  ...collection,
423
- admin: clientUploads ? {
424
- ...baseAdmin || {},
425
- components: {
426
- ...collection.admin?.components || {},
427
- edit: {
428
- ...collection.admin?.components?.edit || {},
429
- beforeDocumentControls: [
430
- ...beforeDocumentControls,
431
- {
432
- clientProps: {
433
- authPath: getImageKitUploadAuthPath(collection.slug),
434
- collectionSlug: collection.slug,
435
- folder: getCollectionFolder(rootFolder, collection.slug),
436
- isPrivate: isCollectionPrivate,
437
- publicKey
438
- },
439
- path: '@humaan/payload-storage-imagekit/client#ImageKitUploadHandler'
440
- }
441
- ]
442
- }
443
- }
444
- } : baseAdmin,
519
+ admin: baseAdmin,
445
520
  hooks: {
446
521
  ...collection.hooks || {},
447
522
  beforeChange: [
448
523
  ...collection.hooks?.beforeChange || [],
449
- ({ data, req })=>{
524
+ async ({ data, req })=>{
450
525
  if (!isRecord(data) || !req.file?.clientUploadContext) {
451
526
  return data;
452
527
  }
453
- 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;
454
573
  }
455
574
  ]
456
575
  },
@@ -471,24 +590,63 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
471
590
  crop: false
472
591
  }
473
592
  };
474
- }),
475
- endpoints: clientUploads ? [
476
- ...config.endpoints || [],
593
+ })
594
+ };
595
+ const uploadCollections = Object.fromEntries(Object.keys(collections).map((collectionSlug)=>[
596
+ collectionSlug,
477
597
  {
478
- handler: createImageKitUploadAuthHandler({
479
- client: imageKitClient,
480
- collections
481
- }),
482
- method: 'get',
483
- path: `${getImageKitUploadAuthPath(':collection')}`
598
+ isPrivate: privateSlugs.has(collectionSlug),
599
+ prefix: getCollectionFolder(rootFolder, collectionSlug)
484
600
  }
485
- ] : config.endpoints
486
- };
487
- 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({
488
617
  alwaysInsertFields,
489
- collections: toCloudStorageCollections(collections, adapter, privateSlugs, disablePayloadAccessControl),
618
+ collections: toCloudStorageCollections(collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl),
490
619
  enabled
491
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;
492
650
  };
493
651
  export { migrateFolder } from './migrate.js';
494
652
  // Backward-compatible alias