@humaan/payload-storage-imagekit 2.6.2 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -6
- 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 +366 -206
- 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
|
|
@@ -165,107 +187,140 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
|
|
|
165
187
|
clientUploads,
|
|
166
188
|
fields: [
|
|
167
189
|
{
|
|
168
|
-
|
|
169
|
-
|
|
190
|
+
// Presentational wrapper: tucks the ImageKit details into a closed panel without
|
|
191
|
+
// changing the `imagekit.*` data paths.
|
|
192
|
+
type: 'collapsible',
|
|
170
193
|
admin: {
|
|
171
194
|
condition: (data)=>hasStoredImageKitURL(data),
|
|
172
|
-
|
|
173
|
-
disableListFilter: true
|
|
195
|
+
initCollapsed: true
|
|
174
196
|
},
|
|
175
197
|
fields: [
|
|
176
198
|
{
|
|
177
|
-
name: '
|
|
178
|
-
type: '
|
|
179
|
-
admin: {
|
|
180
|
-
hidden: true
|
|
181
|
-
}
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
name: 'url',
|
|
185
|
-
type: 'text',
|
|
199
|
+
name: 'imagekit',
|
|
200
|
+
type: 'group',
|
|
186
201
|
admin: {
|
|
187
|
-
|
|
202
|
+
disableListColumn: true,
|
|
203
|
+
disableListFilter: true
|
|
188
204
|
},
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
condition: (_data, siblingData)=>isDocumentPrivate({
|
|
196
|
-
imagekit: siblingData
|
|
197
|
-
}, isPrivate),
|
|
198
|
-
description: "Indicates whether this file was uploaded to ImageKit as private. Private files are delivered through signed URLs. Changes to this collection's private option in payload.config only affect future uploads.",
|
|
199
|
-
readOnly: true
|
|
200
|
-
},
|
|
201
|
-
label: 'Private file'
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
name: 'signedUrl',
|
|
205
|
-
type: 'text',
|
|
206
|
-
admin: {
|
|
207
|
-
// Only relevant for private files; the plain `url` works directly for public ones.
|
|
208
|
-
condition: (_data, siblingData)=>isDocumentPrivate({
|
|
209
|
-
imagekit: siblingData
|
|
210
|
-
}, isPrivate),
|
|
211
|
-
description: `Temporary signed URL for reaching this private file directly on ImageKit. Expires ${signedUrlExpiresIn} seconds after load and is regenerated on every read. Note: anyone with this URL can access the file until it expires — it is not gated by Payload access control.`,
|
|
212
|
-
readOnly: true
|
|
213
|
-
},
|
|
214
|
-
hooks: {
|
|
215
|
-
// Computed server-side on read so it is always freshly signed and never persisted.
|
|
216
|
-
afterRead: [
|
|
217
|
-
({ data, siblingData })=>{
|
|
218
|
-
if (!isDocumentPrivate({
|
|
219
|
-
imagekit: siblingData
|
|
220
|
-
}, isPrivate)) {
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
223
|
-
const filename = data?.filename;
|
|
224
|
-
if (!filename) {
|
|
225
|
-
return undefined;
|
|
226
|
-
}
|
|
227
|
-
return buildSignedURL(getFilePath(rootFolder, collection.slug, filename));
|
|
205
|
+
fields: [
|
|
206
|
+
{
|
|
207
|
+
name: 'fileId',
|
|
208
|
+
type: 'text',
|
|
209
|
+
admin: {
|
|
210
|
+
hidden: true
|
|
228
211
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'url',
|
|
215
|
+
type: 'text',
|
|
216
|
+
admin: {
|
|
217
|
+
readOnly: true
|
|
218
|
+
},
|
|
219
|
+
label: 'URL'
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'isPrivate',
|
|
223
|
+
type: 'checkbox',
|
|
224
|
+
admin: {
|
|
225
|
+
condition: (_data, siblingData)=>isDocumentPrivate({
|
|
226
|
+
imagekit: siblingData
|
|
227
|
+
}, isPrivate),
|
|
228
|
+
description: "Indicates whether this file was uploaded to ImageKit as private. Private files are delivered through signed URLs. Changes to this collection's private option in payload.config only affect future uploads.",
|
|
229
|
+
readOnly: true
|
|
230
|
+
},
|
|
231
|
+
label: 'Private file'
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'signedUrl',
|
|
235
|
+
type: 'text',
|
|
236
|
+
admin: {
|
|
237
|
+
// Only relevant for private files; the plain `url` works directly for public ones.
|
|
238
|
+
condition: (_data, siblingData)=>isDocumentPrivate({
|
|
239
|
+
imagekit: siblingData
|
|
240
|
+
}, isPrivate),
|
|
241
|
+
description: `Temporary signed URL for reaching this private file directly on ImageKit. Expires ${signedUrlExpiresIn} seconds after load and is regenerated on every read. Note: anyone with this URL can access the file until it expires — it is not gated by Payload access control.`,
|
|
242
|
+
readOnly: true
|
|
243
|
+
},
|
|
244
|
+
hooks: {
|
|
245
|
+
// Computed server-side on read so it is always freshly signed and never persisted.
|
|
246
|
+
afterRead: [
|
|
247
|
+
({ data, siblingData })=>{
|
|
248
|
+
if (!isDocumentPrivate({
|
|
249
|
+
imagekit: siblingData
|
|
250
|
+
}, isPrivate)) {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
const filename = data?.filename;
|
|
254
|
+
if (!filename) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const source = data || {};
|
|
258
|
+
const documentPrefix = path.posix.join(source.prefix || prefix, source._objectKey || '');
|
|
259
|
+
const storageFilePath = buildStoragePathData({
|
|
260
|
+
collectionPrefix: prefix,
|
|
261
|
+
docPrefix: documentPrefix,
|
|
262
|
+
filename
|
|
263
|
+
}).storageFilePath;
|
|
264
|
+
return buildSignedURL(`/${storageFilePath}`);
|
|
265
|
+
}
|
|
266
|
+
]
|
|
267
|
+
},
|
|
268
|
+
label: 'Signed URL',
|
|
269
|
+
virtual: true
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'thumbnailUrl',
|
|
273
|
+
type: 'text',
|
|
274
|
+
admin: {
|
|
275
|
+
hidden: true
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
],
|
|
279
|
+
interfaceName: 'imagekitFields',
|
|
280
|
+
label: false
|
|
240
281
|
}
|
|
241
282
|
],
|
|
242
|
-
|
|
243
|
-
label: 'ImageKit'
|
|
283
|
+
label: 'Storage: ImageKit'
|
|
244
284
|
}
|
|
245
285
|
],
|
|
246
|
-
generateURL: ({ filename })=>client.helper.buildSrc({
|
|
247
|
-
src:
|
|
286
|
+
generateURL: ({ filename, prefix: documentPrefix = '' })=>client.helper.buildSrc({
|
|
287
|
+
src: `/${buildStoragePathData({
|
|
288
|
+
collectionPrefix: prefix,
|
|
289
|
+
docPrefix: documentPrefix,
|
|
290
|
+
filename
|
|
291
|
+
}).storageFilePath}`,
|
|
248
292
|
urlEndpoint
|
|
249
293
|
}),
|
|
250
|
-
handleDelete: async ({ doc,
|
|
294
|
+
handleDelete: async ({ doc, storageFilePath })=>{
|
|
251
295
|
const storedFileId = doc.imagekit?.fileId;
|
|
252
|
-
const fileId = storedFileId || await findImageKitFileId(client,
|
|
296
|
+
const fileId = storedFileId || await findImageKitFileId(client, `/${storageFilePath.replace(/^\/+/, '')}`);
|
|
253
297
|
if (!fileId) {
|
|
254
298
|
return;
|
|
255
299
|
}
|
|
256
300
|
await client.files.delete(fileId);
|
|
257
301
|
},
|
|
258
|
-
handleUpload: async ({ clientUploadContext, data, file })=>{
|
|
259
|
-
if (
|
|
260
|
-
|
|
302
|
+
handleUpload: async ({ clientUploadContext, data, file, storageFilePath })=>{
|
|
303
|
+
if (clientUploadContext) {
|
|
304
|
+
const uploaded = await verifyClientUpload({
|
|
305
|
+
collectionSlug: collection.slug,
|
|
306
|
+
context: clientUploadContext
|
|
307
|
+
});
|
|
308
|
+
return applyVerifiedClientUploadToData(data, uploaded, buildThumbnailURL, isPrivate);
|
|
261
309
|
}
|
|
262
|
-
|
|
310
|
+
// ImageKit's `toFile()` consumes async iterables before starting the request. A file-backed
|
|
311
|
+
// Blob keeps temporary-file uploads lazy so FormData can stream them from disk instead.
|
|
312
|
+
const uploadSource = file.tempFilePath ? await openAsBlob(file.tempFilePath, {
|
|
313
|
+
type: file.mimeType
|
|
314
|
+
}) : file.buffer;
|
|
315
|
+
const uploadFile = await toFile(uploadSource, file.filename, {
|
|
263
316
|
type: file.mimeType
|
|
264
317
|
});
|
|
318
|
+
const uploadFolder = path.posix.dirname(`/${storageFilePath.replace(/^\/+/, '')}`);
|
|
319
|
+
const uploadFilename = path.posix.basename(storageFilePath);
|
|
265
320
|
const uploaded = await client.files.upload({
|
|
266
321
|
file: uploadFile,
|
|
267
|
-
fileName:
|
|
268
|
-
folder:
|
|
322
|
+
fileName: uploadFilename,
|
|
323
|
+
folder: uploadFolder,
|
|
269
324
|
useUniqueFileName: false,
|
|
270
325
|
...isPrivate ? {
|
|
271
326
|
isPrivateFile: true
|
|
@@ -282,44 +337,30 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
|
|
|
282
337
|
// `data` yet at this point, so fall back to the uploaded file's name.
|
|
283
338
|
data.thumbnailURL = buildThumbnailURL({
|
|
284
339
|
...data,
|
|
285
|
-
filename: data.filename ||
|
|
340
|
+
filename: data.filename || uploadFilename
|
|
286
341
|
});
|
|
287
342
|
return data;
|
|
288
343
|
},
|
|
344
|
+
requiresClientUploadReceipt: true,
|
|
289
345
|
staticHandler: async (req, args)=>{
|
|
290
346
|
const requestURL = new URL(req.url || 'http://localhost');
|
|
291
347
|
const tr = requestURL.searchParams.get('tr');
|
|
292
348
|
const hasClientUploadContext = 'clientUploadContext' in args.params;
|
|
293
349
|
if (hasClientUploadContext) {
|
|
294
350
|
const clientUploadContext = args.params.clientUploadContext;
|
|
295
|
-
if (!
|
|
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.');
|
|
302
|
-
}
|
|
303
|
-
const uploadedFile = await client.files.get(fileId);
|
|
304
|
-
const uploadedFileMime = uploadedFile.mime;
|
|
305
|
-
const uploadedFileSize = uploadedFile.size;
|
|
306
|
-
const metadataMatchesUpload = uploadedFile.fileId === fileId && uploadedFile.filePath === expectedFilePath && uploadedFile.name === args.params.filename && typeof uploadedFileMime === 'string' && typeof uploadedFile.url === 'string' && typeof uploadedFileSize === 'number' && Number.isSafeInteger(uploadedFileSize) && uploadedFileSize > 0 && Boolean(uploadedFile.isPrivateFile) === isPrivate && (typeof clientUploadContext.size !== 'number' || uploadedFileSize === clientUploadContext.size);
|
|
307
|
-
if (!metadataMatchesUpload || typeof uploadedFileMime !== 'string' || typeof uploadedFileSize !== 'number') {
|
|
308
|
-
throw new Error('imagekitStorage: client upload metadata did not match.');
|
|
351
|
+
if (!isImageKitReceiptContext(clientUploadContext)) {
|
|
352
|
+
throw new APIError('Invalid ImageKit client upload context.', 400);
|
|
309
353
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
fileId: uploadedFile.fileId,
|
|
314
|
-
filePath: uploadedFile.filePath,
|
|
315
|
-
fileType: uploadedFile.fileType,
|
|
316
|
-
mimeType: uploadedFileMime,
|
|
317
|
-
size: uploadedFileSize,
|
|
318
|
-
thumbnailUrl: uploadedFile.thumbnail,
|
|
319
|
-
url: uploadedFile.url
|
|
354
|
+
const uploadedFile = await verifyClientUpload({
|
|
355
|
+
collectionSlug: collection.slug,
|
|
356
|
+
context: clientUploadContext
|
|
320
357
|
});
|
|
321
|
-
const
|
|
322
|
-
|
|
358
|
+
const expectedFilePath = getClientUploadStorageFilePath({
|
|
359
|
+
collectionPrefix: prefix,
|
|
360
|
+
context: clientUploadContext
|
|
361
|
+
});
|
|
362
|
+
const verifiedFileURL = isPrivate ? buildSignedURL(`/${expectedFilePath}`, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
|
|
363
|
+
src: `/${expectedFilePath}`,
|
|
323
364
|
transformation: [
|
|
324
365
|
{
|
|
325
366
|
raw: ORIGINAL_FILE_TRANSFORMATION
|
|
@@ -327,14 +368,12 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
|
|
|
327
368
|
],
|
|
328
369
|
urlEndpoint
|
|
329
370
|
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
return fetchUploadedFile(verifiedFileURL);
|
|
371
|
+
return fetchUploadedFile({
|
|
372
|
+
fileSize: uploadedFile.size,
|
|
373
|
+
fileURL: verifiedFileURL,
|
|
374
|
+
mimeType: uploadedFile.mimeType,
|
|
375
|
+
range: req.headers.get('Range')
|
|
376
|
+
});
|
|
338
377
|
}
|
|
339
378
|
const doc = args.doc || await findUploadDocumentByFilename({
|
|
340
379
|
collection: args.params.collection,
|
|
@@ -342,11 +381,43 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
|
|
|
342
381
|
prefix: args.params.prefix,
|
|
343
382
|
req
|
|
344
383
|
});
|
|
384
|
+
const storageFilePath = await getStorageFilePath({
|
|
385
|
+
collection,
|
|
386
|
+
collectionPrefix: prefix,
|
|
387
|
+
doc: doc || undefined,
|
|
388
|
+
filename: args.params.filename,
|
|
389
|
+
prefixQueryParam: args.params.prefix,
|
|
390
|
+
req
|
|
391
|
+
});
|
|
392
|
+
const imageKitPath = `/${storageFilePath.replace(/^\/+/, '')}`;
|
|
393
|
+
const mimeType = doc?.mimeType;
|
|
394
|
+
// XML-family uploads (including SVG) must pass through Payload so its CSP is
|
|
395
|
+
// present even when ImageKit uses a custom, same-origin delivery domain.
|
|
396
|
+
if (typeof mimeType === 'string' && isXmlMimeType(mimeType)) {
|
|
397
|
+
const filesize = doc?.filesize;
|
|
398
|
+
if (typeof filesize !== 'number' || !Number.isSafeInteger(filesize) || filesize < 0) {
|
|
399
|
+
throw new APIError('Stored ImageKit file metadata is incomplete.', 500);
|
|
400
|
+
}
|
|
401
|
+
const fileURL = isDocumentPrivate(doc, isPrivate) ? buildSignedURL(imageKitPath, ORIGINAL_FILE_TRANSFORMATION) : client.helper.buildSrc({
|
|
402
|
+
src: imageKitPath,
|
|
403
|
+
transformation: [
|
|
404
|
+
{
|
|
405
|
+
raw: ORIGINAL_FILE_TRANSFORMATION
|
|
406
|
+
}
|
|
407
|
+
],
|
|
408
|
+
urlEndpoint
|
|
409
|
+
});
|
|
410
|
+
return fetchUploadedFile({
|
|
411
|
+
fileSize: filesize,
|
|
412
|
+
fileURL,
|
|
413
|
+
mimeType,
|
|
414
|
+
range: req.headers.get('Range')
|
|
415
|
+
});
|
|
416
|
+
}
|
|
345
417
|
// Sign based on the document's stored status, not the collection's current
|
|
346
418
|
// setting, so files keep working if the collection's privacy is changed later.
|
|
347
419
|
if (isDocumentPrivate(doc, isPrivate)) {
|
|
348
|
-
|
|
349
|
-
let signSrc = getFilePath(rootFolder, args.params.collection, args.params.filename);
|
|
420
|
+
let signSrc = imageKitPath;
|
|
350
421
|
// A `tr` request for a video/PDF is a thumbnail request (the plain transform
|
|
351
422
|
// can't render a preview); serve ImageKit's generated thumbnail instead.
|
|
352
423
|
if (tr && isVideoMimeType(mimeType)) {
|
|
@@ -357,7 +428,7 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
|
|
|
357
428
|
return Response.redirect(buildSignedURL(signSrc, tr), 302);
|
|
358
429
|
}
|
|
359
430
|
const imageKitURL = client.helper.buildSrc({
|
|
360
|
-
src: getStoredImageKitURL(doc) ||
|
|
431
|
+
src: getStoredImageKitURL(doc) || imageKitPath,
|
|
361
432
|
transformation: tr ? [
|
|
362
433
|
{
|
|
363
434
|
raw: tr
|
|
@@ -370,14 +441,15 @@ const imagekitAdapter = ({ apiRoute, client, clientUploads, privateSlugs, rootFo
|
|
|
370
441
|
};
|
|
371
442
|
};
|
|
372
443
|
};
|
|
373
|
-
const toCloudStorageCollections = (collections, adapter, privateSlugs, disablePayloadAccessControl)=>{
|
|
444
|
+
const toCloudStorageCollections = (collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl)=>{
|
|
374
445
|
const mappedCollections = {};
|
|
375
446
|
for (const collectionSlug of Object.keys(collections)){
|
|
376
447
|
mappedCollections[collectionSlug] = {
|
|
377
448
|
adapter,
|
|
378
449
|
disableLocalStorage: true,
|
|
379
450
|
// Private files require Payload access control so URLs can be signed at serve time.
|
|
380
|
-
disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl
|
|
451
|
+
disablePayloadAccessControl: privateSlugs.has(collectionSlug) ? undefined : disablePayloadAccessControl,
|
|
452
|
+
prefix: getCollectionFolder(rootFolder, collectionSlug)
|
|
381
453
|
};
|
|
382
454
|
}
|
|
383
455
|
return mappedCollections;
|
|
@@ -397,15 +469,34 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
|
|
|
397
469
|
throw new Error('imagekitStorage: `collections` must include at least one upload collection.');
|
|
398
470
|
}
|
|
399
471
|
const privateSlugs = new Set(Object.entries(collections).filter(([, value])=>isPrivateCollectionConfig(value)).map(([slug])=>slug));
|
|
472
|
+
const configuredSlugs = new Set(Object.keys(collections));
|
|
400
473
|
if (disablePayloadAccessControl && privateSlugs.size > 0) {
|
|
401
474
|
throw new Error('imagekitStorage: `disablePayloadAccessControl` cannot be used with private collections. Private files require Payload access control to remain enabled so URLs can be signed at serve time.');
|
|
402
475
|
}
|
|
476
|
+
if (disablePayloadAccessControl) {
|
|
477
|
+
const xmlCollectionSlugs = (config.collections || []).filter((collection)=>configuredSlugs.has(collection.slug) && collectionCanStoreXml(collection)).map((collection)=>collection.slug);
|
|
478
|
+
if (xmlCollectionSlugs.length > 0) {
|
|
479
|
+
throw new Error(`imagekitStorage: \`disablePayloadAccessControl\` cannot be used with collections that can store XML-family files (${xmlCollectionSlugs.join(', ')}). XML-family files require Payload's file route so its Content Security Policy is applied. Configure a MIME allow list that excludes SVG/XML, or keep Payload access control enabled.`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
403
482
|
const signedUrlExpiresIn = typeof signedUrlExpiresInOption === 'number' && signedUrlExpiresInOption > 0 ? signedUrlExpiresInOption : DEFAULT_SIGNED_URL_EXPIRES_IN;
|
|
404
483
|
const apiRoute = config.routes?.api ?? '/api';
|
|
405
484
|
const rootFolder = normalizeFolder(folder);
|
|
406
485
|
const imageKitClient = new ImageKit({
|
|
407
486
|
privateKey
|
|
408
487
|
});
|
|
488
|
+
const collectionSecurity = Object.fromEntries((config.collections || []).filter((collection)=>configuredSlugs.has(collection.slug)).map((collection)=>[
|
|
489
|
+
collection.slug,
|
|
490
|
+
{
|
|
491
|
+
collection,
|
|
492
|
+
isPrivate: privateSlugs.has(collection.slug),
|
|
493
|
+
prefix: getCollectionFolder(rootFolder, collection.slug)
|
|
494
|
+
}
|
|
495
|
+
]));
|
|
496
|
+
const verifyClientUpload = createImageKitClientUploadVerifier({
|
|
497
|
+
client: imageKitClient,
|
|
498
|
+
collections: collectionSecurity
|
|
499
|
+
});
|
|
409
500
|
const adapter = imagekitAdapter({
|
|
410
501
|
apiRoute,
|
|
411
502
|
client: imageKitClient,
|
|
@@ -413,9 +504,9 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
|
|
|
413
504
|
privateSlugs,
|
|
414
505
|
rootFolder,
|
|
415
506
|
signedUrlExpiresIn,
|
|
416
|
-
urlEndpoint
|
|
507
|
+
urlEndpoint,
|
|
508
|
+
verifyClientUpload
|
|
417
509
|
});
|
|
418
|
-
const configuredSlugs = new Set(Object.keys(collections));
|
|
419
510
|
const configWithCropDisabled = {
|
|
420
511
|
...config,
|
|
421
512
|
collections: (config.collections || []).map((collection)=>{
|
|
@@ -424,7 +515,6 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
|
|
|
424
515
|
}
|
|
425
516
|
const isCollectionPrivate = privateSlugs.has(collection.slug);
|
|
426
517
|
const buildThumbnailURL = (doc)=>isDocumentPrivate(doc, isCollectionPrivate) ? getPrivateAdminThumbnailURL(doc, collection.slug, apiRoute) : getAdminThumbnailURL(doc);
|
|
427
|
-
const beforeDocumentControls = collection.admin?.components?.edit?.beforeDocumentControls || [];
|
|
428
518
|
const collectionOptions = collections[collection.slug];
|
|
429
519
|
const collectionFocalPoint = collectionOptions !== true && typeof collectionOptions === 'object' && typeof collectionOptions.focalPoint === 'boolean' ? collectionOptions.focalPoint : undefined;
|
|
430
520
|
// Surface a "this is a private collection" note in the list and edit views.
|
|
@@ -437,37 +527,60 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
|
|
|
437
527
|
} : collection.admin;
|
|
438
528
|
return {
|
|
439
529
|
...collection,
|
|
440
|
-
admin:
|
|
441
|
-
...baseAdmin || {},
|
|
442
|
-
components: {
|
|
443
|
-
...collection.admin?.components || {},
|
|
444
|
-
edit: {
|
|
445
|
-
...collection.admin?.components?.edit || {},
|
|
446
|
-
beforeDocumentControls: [
|
|
447
|
-
...beforeDocumentControls,
|
|
448
|
-
{
|
|
449
|
-
clientProps: {
|
|
450
|
-
authPath: getImageKitUploadAuthPath(collection.slug),
|
|
451
|
-
collectionSlug: collection.slug,
|
|
452
|
-
folder: getCollectionFolder(rootFolder, collection.slug),
|
|
453
|
-
isPrivate: isCollectionPrivate,
|
|
454
|
-
publicKey
|
|
455
|
-
},
|
|
456
|
-
path: '@humaan/payload-storage-imagekit/client#ImageKitUploadHandler'
|
|
457
|
-
}
|
|
458
|
-
]
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
} : baseAdmin,
|
|
530
|
+
admin: baseAdmin,
|
|
462
531
|
hooks: {
|
|
463
532
|
...collection.hooks || {},
|
|
464
533
|
beforeChange: [
|
|
465
534
|
...collection.hooks?.beforeChange || [],
|
|
466
|
-
({ data, req })=>{
|
|
535
|
+
async ({ data, req })=>{
|
|
467
536
|
if (!isRecord(data) || !req.file?.clientUploadContext) {
|
|
468
537
|
return data;
|
|
469
538
|
}
|
|
470
|
-
|
|
539
|
+
const context = req.file.clientUploadContext;
|
|
540
|
+
const requestMimeType = req.file.mimetype.split(';', 1)[0]?.trim().toLowerCase();
|
|
541
|
+
if (!isImageKitReceiptContext(context) || context.filename !== data.filename || context.filename !== req.file.name || context.expectedMimeType !== requestMimeType || // Payload can clear req.file.size when preparing an in-memory image.
|
|
542
|
+
// generateFileData preserves the original size on the document.
|
|
543
|
+
context.expectedSize !== data.filesize) {
|
|
544
|
+
throw new APIError('ImageKit client upload reference does not match this request.', 400);
|
|
545
|
+
}
|
|
546
|
+
const uploaded = await verifyClientUpload({
|
|
547
|
+
collectionSlug: collection.slug,
|
|
548
|
+
context
|
|
549
|
+
});
|
|
550
|
+
// A provider file can only be claimed once, including through legacy receipts.
|
|
551
|
+
// The unique file ID index also rejects concurrent claims.
|
|
552
|
+
const existing = await req.payload.db.findOne({
|
|
553
|
+
collection: collection.slug,
|
|
554
|
+
req,
|
|
555
|
+
where: {
|
|
556
|
+
'imagekit.fileId': {
|
|
557
|
+
equals: uploaded.fileId
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
if (existing) {
|
|
562
|
+
throw new APIError('This ImageKit client upload receipt has already been used.', 400);
|
|
563
|
+
}
|
|
564
|
+
data.filename = uploaded.name;
|
|
565
|
+
data.filesize = uploaded.size;
|
|
566
|
+
data.mimeType = uploaded.mimeType;
|
|
567
|
+
return applyVerifiedClientUploadToData(data, uploaded, buildThumbnailURL, isCollectionPrivate);
|
|
568
|
+
}
|
|
569
|
+
],
|
|
570
|
+
beforeOperation: [
|
|
571
|
+
...collection.hooks?.beforeOperation || [],
|
|
572
|
+
({ args, operation, req })=>{
|
|
573
|
+
if (operation !== 'create' && operation !== 'update' || req.context?.skipCloudStorage || !req.file || req.file.clientUploadContext || !('data' in args) || !isRecord(args.data) || typeof args.data.prefix !== 'string') {
|
|
574
|
+
return args;
|
|
575
|
+
}
|
|
576
|
+
// Payload checks filename uniqueness before cloud storage's beforeChange
|
|
577
|
+
// normalizes the prefix. Use the persisted form during that lookup too.
|
|
578
|
+
args.data.prefix = buildUploadStoragePathData({
|
|
579
|
+
collectionPrefix: getCollectionFolder(rootFolder, collection.slug),
|
|
580
|
+
docPrefix: args.data.prefix,
|
|
581
|
+
filename: req.file.name
|
|
582
|
+
}).sanitizedDocPrefix;
|
|
583
|
+
return args;
|
|
471
584
|
}
|
|
472
585
|
]
|
|
473
586
|
},
|
|
@@ -488,24 +601,71 @@ export const imagekitStorage = (pluginOptions)=>(config)=>{
|
|
|
488
601
|
crop: false
|
|
489
602
|
}
|
|
490
603
|
};
|
|
491
|
-
})
|
|
492
|
-
|
|
493
|
-
|
|
604
|
+
})
|
|
605
|
+
};
|
|
606
|
+
const uploadCollections = Object.fromEntries(Object.keys(collections).map((collectionSlug)=>[
|
|
607
|
+
collectionSlug,
|
|
494
608
|
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
collections
|
|
498
|
-
}),
|
|
499
|
-
method: 'get',
|
|
500
|
-
path: `${getImageKitUploadAuthPath(':collection')}`
|
|
609
|
+
isPrivate: privateSlugs.has(collectionSlug),
|
|
610
|
+
prefix: getCollectionFolder(rootFolder, collectionSlug)
|
|
501
611
|
}
|
|
502
|
-
]
|
|
503
|
-
|
|
504
|
-
|
|
612
|
+
]));
|
|
613
|
+
initClientUploads({
|
|
614
|
+
clientHandler: '@humaan/payload-storage-imagekit/client#ImageKitUploadHandler',
|
|
615
|
+
collections: uploadCollections,
|
|
616
|
+
config: configWithCropDisabled,
|
|
617
|
+
enabled: enabled !== false && Boolean(clientUploads),
|
|
618
|
+
serverHandler: createImageKitUploadAuthHandler({
|
|
619
|
+
access: typeof clientUploads === 'object' ? clientUploads.access : undefined,
|
|
620
|
+
collections: uploadCollections,
|
|
621
|
+
privateKey,
|
|
622
|
+
publicKey,
|
|
623
|
+
verifyClientUpload
|
|
624
|
+
}),
|
|
625
|
+
serverHandlerPath: imageKitUploadAuthPath
|
|
626
|
+
});
|
|
627
|
+
const output = cloudStoragePlugin({
|
|
505
628
|
alwaysInsertFields,
|
|
506
|
-
collections: toCloudStorageCollections(collections, adapter, privateSlugs, disablePayloadAccessControl),
|
|
629
|
+
collections: toCloudStorageCollections(collections, adapter, privateSlugs, rootFolder, disablePayloadAccessControl),
|
|
507
630
|
enabled
|
|
508
631
|
})(configWithCropDisabled);
|
|
632
|
+
// Make the provider identity unique at persistence time to reject concurrent receipt replays.
|
|
633
|
+
// Keep the legacy object key index for existing uploads.
|
|
634
|
+
output.collections = (output.collections || []).map((collection)=>{
|
|
635
|
+
if (!configuredSlugs.has(collection.slug)) {
|
|
636
|
+
return collection;
|
|
637
|
+
}
|
|
638
|
+
const withUniqueIdentity = (field)=>{
|
|
639
|
+
if ('name' in field && field.name === '_objectKey') {
|
|
640
|
+
return {
|
|
641
|
+
...field,
|
|
642
|
+
unique: true
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
if (field.type === 'group' && 'name' in field && field.name === 'imagekit') {
|
|
646
|
+
return {
|
|
647
|
+
...field,
|
|
648
|
+
fields: field.fields.map((metadataField)=>'name' in metadataField && metadataField.name === 'fileId' ? {
|
|
649
|
+
...metadataField,
|
|
650
|
+
unique: true
|
|
651
|
+
} : metadataField)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
// The imagekit group sits inside the adapter's presentational "Storage: ImageKit" panel.
|
|
655
|
+
if (field.type === 'collapsible') {
|
|
656
|
+
return {
|
|
657
|
+
...field,
|
|
658
|
+
fields: field.fields.map(withUniqueIdentity)
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return field;
|
|
662
|
+
};
|
|
663
|
+
return {
|
|
664
|
+
...collection,
|
|
665
|
+
fields: collection.fields.map(withUniqueIdentity)
|
|
666
|
+
};
|
|
667
|
+
});
|
|
668
|
+
return output;
|
|
509
669
|
};
|
|
510
670
|
export { migrateFolder } from './migrate.js';
|
|
511
671
|
// Backward-compatible alias
|