@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/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 +295 -137
- package/dist/index.js.map +1 -1
- package/package.json +32 -33
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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
83
|
-
if (!isImageKitClientUploadContext(clientUploadContext)) {
|
|
84
|
-
return data;
|
|
85
|
-
}
|
|
109
|
+
const applyVerifiedClientUploadToData = (data, uploaded, buildThumbnailURL = getAdminThumbnailURL, isPrivate)=>{
|
|
86
110
|
data.imagekit = {
|
|
87
|
-
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:
|
|
92
|
-
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
283
|
+
handleDelete: async ({ doc, storageFilePath })=>{
|
|
239
284
|
const storedFileId = doc.imagekit?.fileId;
|
|
240
|
-
const fileId = storedFileId || await findImageKitFileId(client,
|
|
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 (
|
|
248
|
-
|
|
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
|
-
|
|
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:
|
|
256
|
-
folder:
|
|
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 ||
|
|
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 (!
|
|
284
|
-
throw new
|
|
340
|
+
if (!isImageKitReceiptContext(clientUploadContext)) {
|
|
341
|
+
throw new APIError('Invalid ImageKit client upload context.', 400);
|
|
285
342
|
}
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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) ||
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
593
|
+
})
|
|
594
|
+
};
|
|
595
|
+
const uploadCollections = Object.fromEntries(Object.keys(collections).map((collectionSlug)=>[
|
|
596
|
+
collectionSlug,
|
|
477
597
|
{
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
collections
|
|
481
|
-
}),
|
|
482
|
-
method: 'get',
|
|
483
|
-
path: `${getImageKitUploadAuthPath(':collection')}`
|
|
598
|
+
isPrivate: privateSlugs.has(collectionSlug),
|
|
599
|
+
prefix: getCollectionFolder(rootFolder, collectionSlug)
|
|
484
600
|
}
|
|
485
|
-
]
|
|
486
|
-
|
|
487
|
-
|
|
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
|