@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/README.md +95 -5
- package/dist/clientImageIdentity.d.ts +5 -0
- package/dist/clientImageIdentity.js +86 -0
- package/dist/clientImageIdentity.js.map +1 -0
- package/dist/clientUploadVerification.d.ts +30 -0
- package/dist/clientUploadVerification.js +104 -0
- package/dist/clientUploadVerification.js.map +1 -0
- package/dist/clientUploads.d.ts +41 -4
- package/dist/clientUploads.js +4 -4
- package/dist/clientUploads.js.map +1 -1
- package/dist/components/ImageKitUploadHandler.d.ts +8 -2
- package/dist/components/ImageKitUploadHandler.js +80 -65
- package/dist/components/ImageKitUploadHandler.js.map +1 -1
- package/dist/endpoints/createImageKitUploadAuthHandler.d.ts +12 -4
- package/dist/endpoints/createImageKitUploadAuthHandler.js +162 -29
- package/dist/endpoints/createImageKitUploadAuthHandler.js.map +1 -1
- package/dist/imageKitUploadToken.d.ts +12 -0
- package/dist/imageKitUploadToken.js +25 -0
- package/dist/imageKitUploadToken.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +282 -141
- package/dist/index.js.map +1 -1
- package/package.json +32 -33
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 {
|
|
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
|
|
91
|
-
if (!isImageKitClientUploadContext(clientUploadContext)) {
|
|
92
|
-
return data;
|
|
93
|
-
}
|
|
109
|
+
const applyVerifiedClientUploadToData = (data, uploaded, buildThumbnailURL = getAdminThumbnailURL, isPrivate)=>{
|
|
94
110
|
data.imagekit = {
|
|
95
|
-
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:
|
|
100
|
-
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
void response.body?.cancel().catch(()=>
|
|
132
|
-
throw new Error(
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
283
|
+
handleDelete: async ({ doc, storageFilePath })=>{
|
|
251
284
|
const storedFileId = doc.imagekit?.fileId;
|
|
252
|
-
const fileId = storedFileId || await findImageKitFileId(client,
|
|
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 (
|
|
260
|
-
|
|
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
|
-
|
|
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:
|
|
268
|
-
folder:
|
|
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 ||
|
|
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 (!
|
|
296
|
-
throw new
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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) ||
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
593
|
+
})
|
|
594
|
+
};
|
|
595
|
+
const uploadCollections = Object.fromEntries(Object.keys(collections).map((collectionSlug)=>[
|
|
596
|
+
collectionSlug,
|
|
494
597
|
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
collections
|
|
498
|
-
}),
|
|
499
|
-
method: 'get',
|
|
500
|
-
path: `${getImageKitUploadAuthPath(':collection')}`
|
|
598
|
+
isPrivate: privateSlugs.has(collectionSlug),
|
|
599
|
+
prefix: getCollectionFolder(rootFolder, collectionSlug)
|
|
501
600
|
}
|
|
502
|
-
]
|
|
503
|
-
|
|
504
|
-
|
|
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
|