@momentumcms/server-express 0.2.0 → 0.3.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/CHANGELOG.md +15 -0
- package/index.cjs +762 -213
- package/index.js +766 -211
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,3 +1,522 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// libs/storage/src/lib/storage.types.ts
|
|
12
|
+
var init_storage_types = __esm({
|
|
13
|
+
"libs/storage/src/lib/storage.types.ts"() {
|
|
14
|
+
"use strict";
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// libs/storage/src/lib/storage-local.ts
|
|
19
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync, lstatSync } from "node:fs";
|
|
20
|
+
import { join, extname, resolve, normalize } from "node:path";
|
|
21
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
22
|
+
function localStorageAdapter(options) {
|
|
23
|
+
const { directory, baseUrl } = options;
|
|
24
|
+
const resolvedRoot = resolve(directory);
|
|
25
|
+
if (!existsSync(resolvedRoot)) {
|
|
26
|
+
mkdirSync(resolvedRoot, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
function safePath(unsafePath) {
|
|
29
|
+
const normalized = normalize(unsafePath);
|
|
30
|
+
if (normalized.includes("..")) {
|
|
31
|
+
throw new Error("Invalid path: directory traversal not allowed");
|
|
32
|
+
}
|
|
33
|
+
const full = resolve(resolvedRoot, normalized);
|
|
34
|
+
if (!full.startsWith(resolvedRoot)) {
|
|
35
|
+
throw new Error("Invalid path: directory traversal not allowed");
|
|
36
|
+
}
|
|
37
|
+
if (existsSync(full) && lstatSync(full).isSymbolicLink()) {
|
|
38
|
+
throw new Error("Invalid path: symbolic links not allowed");
|
|
39
|
+
}
|
|
40
|
+
return full;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
async upload(file, uploadOptions) {
|
|
44
|
+
const ext = extname(file.originalName) || getExtensionFromMimeType(file.mimeType);
|
|
45
|
+
const filename = uploadOptions?.filename ? `${uploadOptions.filename}${ext}` : `${randomUUID2()}${ext}`;
|
|
46
|
+
const subdir = uploadOptions?.directory ?? "";
|
|
47
|
+
const targetDir = subdir ? safePath(subdir) : resolvedRoot;
|
|
48
|
+
if (subdir && !existsSync(targetDir)) {
|
|
49
|
+
mkdirSync(targetDir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
const filePath = safePath(subdir ? join(subdir, filename) : filename);
|
|
52
|
+
const relativePath = subdir ? join(normalize(subdir), filename) : filename;
|
|
53
|
+
writeFileSync(filePath, file.buffer);
|
|
54
|
+
const url = baseUrl ? `${baseUrl}/${relativePath}` : `/api/media/file/${relativePath}`;
|
|
55
|
+
return {
|
|
56
|
+
path: relativePath,
|
|
57
|
+
url,
|
|
58
|
+
filename,
|
|
59
|
+
mimeType: file.mimeType,
|
|
60
|
+
size: file.size
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
async delete(path) {
|
|
64
|
+
const filePath = safePath(path);
|
|
65
|
+
if (existsSync(filePath)) {
|
|
66
|
+
unlinkSync(filePath);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
},
|
|
71
|
+
getUrl(path) {
|
|
72
|
+
return baseUrl ? `${baseUrl}/${path}` : `/api/media/file/${path}`;
|
|
73
|
+
},
|
|
74
|
+
async exists(path) {
|
|
75
|
+
const filePath = safePath(path);
|
|
76
|
+
return existsSync(filePath);
|
|
77
|
+
},
|
|
78
|
+
async read(path) {
|
|
79
|
+
const filePath = safePath(path);
|
|
80
|
+
if (existsSync(filePath)) {
|
|
81
|
+
return readFileSync(filePath);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function getExtensionFromMimeType(mimeType) {
|
|
88
|
+
const mimeToExt = {
|
|
89
|
+
"image/jpeg": ".jpg",
|
|
90
|
+
"image/png": ".png",
|
|
91
|
+
"image/gif": ".gif",
|
|
92
|
+
"image/webp": ".webp",
|
|
93
|
+
"image/svg+xml": ".svg",
|
|
94
|
+
"application/pdf": ".pdf",
|
|
95
|
+
"application/json": ".json",
|
|
96
|
+
"text/plain": ".txt",
|
|
97
|
+
"text/html": ".html",
|
|
98
|
+
"text/css": ".css",
|
|
99
|
+
"application/javascript": ".js",
|
|
100
|
+
"video/mp4": ".mp4",
|
|
101
|
+
"video/webm": ".webm",
|
|
102
|
+
"audio/mpeg": ".mp3",
|
|
103
|
+
"audio/wav": ".wav",
|
|
104
|
+
"application/zip": ".zip"
|
|
105
|
+
};
|
|
106
|
+
return mimeToExt[mimeType] ?? "";
|
|
107
|
+
}
|
|
108
|
+
var init_storage_local = __esm({
|
|
109
|
+
"libs/storage/src/lib/storage-local.ts"() {
|
|
110
|
+
"use strict";
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// libs/storage/src/lib/storage-s3.ts
|
|
115
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
116
|
+
import { extname as extname2 } from "node:path";
|
|
117
|
+
async function loadAwsSdk() {
|
|
118
|
+
if (S3Client)
|
|
119
|
+
return;
|
|
120
|
+
const s3Module = await import("@aws-sdk/client-s3");
|
|
121
|
+
const presignerModule = await import("@aws-sdk/s3-request-presigner");
|
|
122
|
+
S3Client = s3Module.S3Client;
|
|
123
|
+
PutObjectCommand = s3Module.PutObjectCommand;
|
|
124
|
+
DeleteObjectCommand = s3Module.DeleteObjectCommand;
|
|
125
|
+
HeadObjectCommand = s3Module.HeadObjectCommand;
|
|
126
|
+
GetObjectCommand = s3Module.GetObjectCommand;
|
|
127
|
+
getSignedUrl = presignerModule.getSignedUrl;
|
|
128
|
+
}
|
|
129
|
+
function s3StorageAdapter(options) {
|
|
130
|
+
const {
|
|
131
|
+
bucket,
|
|
132
|
+
region,
|
|
133
|
+
accessKeyId,
|
|
134
|
+
secretAccessKey,
|
|
135
|
+
endpoint,
|
|
136
|
+
baseUrl,
|
|
137
|
+
forcePathStyle = false,
|
|
138
|
+
acl = "private",
|
|
139
|
+
presignedUrlExpiry = 3600
|
|
140
|
+
} = options;
|
|
141
|
+
let client = null;
|
|
142
|
+
async function getClient() {
|
|
143
|
+
await loadAwsSdk();
|
|
144
|
+
if (!client) {
|
|
145
|
+
const clientConfig = {
|
|
146
|
+
region,
|
|
147
|
+
forcePathStyle
|
|
148
|
+
};
|
|
149
|
+
if (endpoint) {
|
|
150
|
+
clientConfig.endpoint = endpoint;
|
|
151
|
+
}
|
|
152
|
+
if (accessKeyId && secretAccessKey) {
|
|
153
|
+
clientConfig.credentials = {
|
|
154
|
+
accessKeyId,
|
|
155
|
+
secretAccessKey
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
client = new S3Client(clientConfig);
|
|
159
|
+
}
|
|
160
|
+
return client;
|
|
161
|
+
}
|
|
162
|
+
function getPublicUrl(key) {
|
|
163
|
+
if (baseUrl) {
|
|
164
|
+
return `${baseUrl}/${key}`;
|
|
165
|
+
}
|
|
166
|
+
if (endpoint) {
|
|
167
|
+
return `${endpoint}/${bucket}/${key}`;
|
|
168
|
+
}
|
|
169
|
+
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
async upload(file, uploadOptions) {
|
|
173
|
+
const s3 = await getClient();
|
|
174
|
+
const ext = extname2(file.originalName) || getExtensionFromMimeType2(file.mimeType);
|
|
175
|
+
const filename = uploadOptions?.filename ? `${uploadOptions.filename}${ext}` : `${randomUUID3()}${ext}`;
|
|
176
|
+
const key = uploadOptions?.directory ? `${uploadOptions.directory}/${filename}` : filename;
|
|
177
|
+
await s3.send(
|
|
178
|
+
new PutObjectCommand({
|
|
179
|
+
Bucket: bucket,
|
|
180
|
+
Key: key,
|
|
181
|
+
Body: file.buffer,
|
|
182
|
+
ContentType: file.mimeType,
|
|
183
|
+
ACL: acl
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
path: key,
|
|
188
|
+
url: acl === "public-read" ? getPublicUrl(key) : `/api/media/file/${key}`,
|
|
189
|
+
filename,
|
|
190
|
+
mimeType: file.mimeType,
|
|
191
|
+
size: file.size
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
async delete(path) {
|
|
195
|
+
const s3 = await getClient();
|
|
196
|
+
try {
|
|
197
|
+
await s3.send(
|
|
198
|
+
new DeleteObjectCommand({
|
|
199
|
+
Bucket: bucket,
|
|
200
|
+
Key: path
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
getUrl(path) {
|
|
209
|
+
if (acl === "public-read") {
|
|
210
|
+
return getPublicUrl(path);
|
|
211
|
+
}
|
|
212
|
+
return `/api/media/file/${path}`;
|
|
213
|
+
},
|
|
214
|
+
async exists(path) {
|
|
215
|
+
const s3 = await getClient();
|
|
216
|
+
try {
|
|
217
|
+
await s3.send(
|
|
218
|
+
new HeadObjectCommand({
|
|
219
|
+
Bucket: bucket,
|
|
220
|
+
Key: path
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
async getSignedUrl(path, expiresIn) {
|
|
229
|
+
const s3 = await getClient();
|
|
230
|
+
await loadAwsSdk();
|
|
231
|
+
const command = new GetObjectCommand({
|
|
232
|
+
Bucket: bucket,
|
|
233
|
+
Key: path
|
|
234
|
+
});
|
|
235
|
+
return getSignedUrl(s3, command, {
|
|
236
|
+
expiresIn: expiresIn ?? presignedUrlExpiry
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
async read(path) {
|
|
240
|
+
const s3 = await getClient();
|
|
241
|
+
try {
|
|
242
|
+
const response = await s3.send(
|
|
243
|
+
new GetObjectCommand({
|
|
244
|
+
Bucket: bucket,
|
|
245
|
+
Key: path
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
if (!response.Body) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const chunks = [];
|
|
252
|
+
const body = response.Body;
|
|
253
|
+
for await (const chunk of body) {
|
|
254
|
+
chunks.push(chunk);
|
|
255
|
+
}
|
|
256
|
+
return Buffer.concat(chunks);
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function getExtensionFromMimeType2(mimeType) {
|
|
264
|
+
const mimeToExt = {
|
|
265
|
+
"image/jpeg": ".jpg",
|
|
266
|
+
"image/png": ".png",
|
|
267
|
+
"image/gif": ".gif",
|
|
268
|
+
"image/webp": ".webp",
|
|
269
|
+
"image/svg+xml": ".svg",
|
|
270
|
+
"application/pdf": ".pdf",
|
|
271
|
+
"application/json": ".json",
|
|
272
|
+
"text/plain": ".txt",
|
|
273
|
+
"text/html": ".html",
|
|
274
|
+
"text/css": ".css",
|
|
275
|
+
"application/javascript": ".js",
|
|
276
|
+
"video/mp4": ".mp4",
|
|
277
|
+
"video/webm": ".webm",
|
|
278
|
+
"audio/mpeg": ".mp3",
|
|
279
|
+
"audio/wav": ".wav",
|
|
280
|
+
"application/zip": ".zip"
|
|
281
|
+
};
|
|
282
|
+
return mimeToExt[mimeType] ?? "";
|
|
283
|
+
}
|
|
284
|
+
var S3Client, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, GetObjectCommand, getSignedUrl;
|
|
285
|
+
var init_storage_s3 = __esm({
|
|
286
|
+
"libs/storage/src/lib/storage-s3.ts"() {
|
|
287
|
+
"use strict";
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// libs/storage/src/lib/mime-validator.ts
|
|
292
|
+
function detectMimeType(buffer) {
|
|
293
|
+
for (const sig of FILE_SIGNATURES) {
|
|
294
|
+
const offset = sig.offset ?? 0;
|
|
295
|
+
if (buffer.length < offset + sig.bytes.length) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
let match = true;
|
|
299
|
+
for (let i = 0; i < sig.bytes.length; i++) {
|
|
300
|
+
if (buffer[offset + i] !== sig.bytes[i]) {
|
|
301
|
+
match = false;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (match) {
|
|
306
|
+
if (sig.bytes[0] === 82 && sig.bytes[1] === 73) {
|
|
307
|
+
if (buffer.length >= 12) {
|
|
308
|
+
const formatId = buffer.slice(8, 12).toString("ascii");
|
|
309
|
+
if (formatId === "WEBP") {
|
|
310
|
+
return "image/webp";
|
|
311
|
+
}
|
|
312
|
+
if (formatId === "WAVE") {
|
|
313
|
+
return "audio/wav";
|
|
314
|
+
}
|
|
315
|
+
if (formatId === "AVI ") {
|
|
316
|
+
return "video/avi";
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (sig.mimeType === "video/mp4" && buffer.length >= 8) {
|
|
321
|
+
const boxType = buffer.slice(4, 8).toString("ascii");
|
|
322
|
+
if (boxType === "ftyp") {
|
|
323
|
+
return "video/mp4";
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return sig.mimeType;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (isTextContent(buffer)) {
|
|
330
|
+
const text2 = buffer.toString("utf8", 0, Math.min(buffer.length, 1e3));
|
|
331
|
+
if (text2.trim().startsWith("{") || text2.trim().startsWith("[")) {
|
|
332
|
+
return "application/json";
|
|
333
|
+
}
|
|
334
|
+
if (text2.trim().startsWith("<")) {
|
|
335
|
+
if (text2.includes("<svg")) {
|
|
336
|
+
return "image/svg+xml";
|
|
337
|
+
}
|
|
338
|
+
if (text2.includes("<!DOCTYPE html") || text2.includes("<html")) {
|
|
339
|
+
return "text/html";
|
|
340
|
+
}
|
|
341
|
+
return "application/xml";
|
|
342
|
+
}
|
|
343
|
+
return "text/plain";
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
function isTextContent(buffer) {
|
|
348
|
+
const checkLength = Math.min(buffer.length, 512);
|
|
349
|
+
for (let i = 0; i < checkLength; i++) {
|
|
350
|
+
const byte = buffer[i];
|
|
351
|
+
if (byte < 9 || // Control chars before tab
|
|
352
|
+
byte > 13 && byte < 32 || // Control chars between CR and space
|
|
353
|
+
byte === 127) {
|
|
354
|
+
if (byte >= 128 && byte <= 191) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (byte >= 192 && byte <= 247) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
function mimeTypeMatches(mimeType, pattern) {
|
|
366
|
+
if (pattern === "*" || pattern === "*/*") {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
if (pattern.endsWith("/*")) {
|
|
370
|
+
const category = pattern.slice(0, -2);
|
|
371
|
+
return mimeType.startsWith(`${category}/`);
|
|
372
|
+
}
|
|
373
|
+
return mimeType === pattern;
|
|
374
|
+
}
|
|
375
|
+
function isMimeTypeAllowed(mimeType, allowedTypes) {
|
|
376
|
+
if (allowedTypes.length === 0) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
return allowedTypes.some((pattern) => mimeTypeMatches(mimeType, pattern));
|
|
380
|
+
}
|
|
381
|
+
function validateMimeType(buffer, claimedType, allowedTypes) {
|
|
382
|
+
const detectedType = detectMimeType(buffer);
|
|
383
|
+
if (allowedTypes && allowedTypes.length > 0) {
|
|
384
|
+
const typeToCheck = detectedType ?? claimedType;
|
|
385
|
+
if (!isMimeTypeAllowed(typeToCheck, allowedTypes)) {
|
|
386
|
+
return {
|
|
387
|
+
valid: false,
|
|
388
|
+
detectedType,
|
|
389
|
+
claimedType,
|
|
390
|
+
error: `File type '${typeToCheck}' is not allowed. Allowed types: ${allowedTypes.join(", ")}`
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!detectedType) {
|
|
395
|
+
return {
|
|
396
|
+
valid: true,
|
|
397
|
+
detectedType: null,
|
|
398
|
+
claimedType
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const compatible = areMimeTypesCompatible(detectedType, claimedType);
|
|
402
|
+
if (!compatible) {
|
|
403
|
+
return {
|
|
404
|
+
valid: false,
|
|
405
|
+
detectedType,
|
|
406
|
+
claimedType,
|
|
407
|
+
error: `File appears to be '${detectedType}' but was uploaded as '${claimedType}'`
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
valid: true,
|
|
412
|
+
detectedType,
|
|
413
|
+
claimedType
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function areMimeTypesCompatible(detected, claimed) {
|
|
417
|
+
if (detected === claimed) {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
const [detectedCategory] = detected.split("/");
|
|
421
|
+
const [claimedCategory] = claimed.split("/");
|
|
422
|
+
if (detectedCategory !== claimedCategory) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
const variations = {
|
|
426
|
+
"image/jpeg": ["image/jpg", "image/pjpeg"],
|
|
427
|
+
"text/plain": ["text/x-plain"],
|
|
428
|
+
"application/json": ["text/json"],
|
|
429
|
+
"application/javascript": ["text/javascript", "application/x-javascript"]
|
|
430
|
+
};
|
|
431
|
+
const allowedVariations = variations[detected];
|
|
432
|
+
if (allowedVariations && allowedVariations.includes(claimed)) {
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
for (const [canonical, variants] of Object.entries(variations)) {
|
|
436
|
+
if (variants.includes(detected) && (canonical === claimed || variants.includes(claimed))) {
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
var FILE_SIGNATURES;
|
|
443
|
+
var init_mime_validator = __esm({
|
|
444
|
+
"libs/storage/src/lib/mime-validator.ts"() {
|
|
445
|
+
"use strict";
|
|
446
|
+
FILE_SIGNATURES = [
|
|
447
|
+
// Images
|
|
448
|
+
{ mimeType: "image/jpeg", bytes: [255, 216, 255] },
|
|
449
|
+
{ mimeType: "image/png", bytes: [137, 80, 78, 71, 13, 10, 26, 10] },
|
|
450
|
+
{ mimeType: "image/gif", bytes: [71, 73, 70, 56] },
|
|
451
|
+
// GIF8
|
|
452
|
+
{ mimeType: "image/webp", bytes: [82, 73, 70, 70], offset: 0 },
|
|
453
|
+
// RIFF (need to check for WEBP at offset 8)
|
|
454
|
+
{ mimeType: "image/bmp", bytes: [66, 77] },
|
|
455
|
+
// BM
|
|
456
|
+
{ mimeType: "image/tiff", bytes: [73, 73, 42, 0] },
|
|
457
|
+
// Little-endian TIFF
|
|
458
|
+
{ mimeType: "image/tiff", bytes: [77, 77, 0, 42] },
|
|
459
|
+
// Big-endian TIFF
|
|
460
|
+
{ mimeType: "image/x-icon", bytes: [0, 0, 1, 0] },
|
|
461
|
+
// ICO
|
|
462
|
+
{ mimeType: "image/svg+xml", bytes: [60, 115, 118, 103] },
|
|
463
|
+
// <svg (partial match)
|
|
464
|
+
// Documents
|
|
465
|
+
{ mimeType: "application/pdf", bytes: [37, 80, 68, 70] },
|
|
466
|
+
// %PDF
|
|
467
|
+
// Archives
|
|
468
|
+
{ mimeType: "application/zip", bytes: [80, 75, 3, 4] },
|
|
469
|
+
// PK
|
|
470
|
+
{ mimeType: "application/gzip", bytes: [31, 139] },
|
|
471
|
+
{ mimeType: "application/x-rar-compressed", bytes: [82, 97, 114, 33] },
|
|
472
|
+
// Rar!
|
|
473
|
+
// Audio
|
|
474
|
+
{ mimeType: "audio/mpeg", bytes: [73, 68, 51] },
|
|
475
|
+
// ID3 (MP3)
|
|
476
|
+
{ mimeType: "audio/mpeg", bytes: [255, 251] },
|
|
477
|
+
// MP3 frame sync
|
|
478
|
+
{ mimeType: "audio/wav", bytes: [82, 73, 70, 70] },
|
|
479
|
+
// RIFF (need to check for WAVE)
|
|
480
|
+
{ mimeType: "audio/ogg", bytes: [79, 103, 103, 83] },
|
|
481
|
+
// OggS
|
|
482
|
+
{ mimeType: "audio/flac", bytes: [102, 76, 97, 67] },
|
|
483
|
+
// fLaC
|
|
484
|
+
// Video
|
|
485
|
+
{ mimeType: "video/mp4", bytes: [0, 0, 0], offset: 0 },
|
|
486
|
+
// Need to check for ftyp at offset 4
|
|
487
|
+
{ mimeType: "video/webm", bytes: [26, 69, 223, 163] },
|
|
488
|
+
// EBML header
|
|
489
|
+
{ mimeType: "video/avi", bytes: [82, 73, 70, 70] },
|
|
490
|
+
// RIFF (need to check for AVI)
|
|
491
|
+
// Executables (for blocking)
|
|
492
|
+
{ mimeType: "application/x-executable", bytes: [127, 69, 76, 70] },
|
|
493
|
+
// ELF
|
|
494
|
+
{ mimeType: "application/x-msdownload", bytes: [77, 90] }
|
|
495
|
+
// MZ (Windows EXE)
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// libs/storage/src/index.ts
|
|
501
|
+
var src_exports = {};
|
|
502
|
+
__export(src_exports, {
|
|
503
|
+
detectMimeType: () => detectMimeType,
|
|
504
|
+
isMimeTypeAllowed: () => isMimeTypeAllowed,
|
|
505
|
+
localStorageAdapter: () => localStorageAdapter,
|
|
506
|
+
mimeTypeMatches: () => mimeTypeMatches,
|
|
507
|
+
s3StorageAdapter: () => s3StorageAdapter,
|
|
508
|
+
validateMimeType: () => validateMimeType
|
|
509
|
+
});
|
|
510
|
+
var init_src = __esm({
|
|
511
|
+
"libs/storage/src/index.ts"() {
|
|
512
|
+
"use strict";
|
|
513
|
+
init_storage_types();
|
|
514
|
+
init_storage_local();
|
|
515
|
+
init_storage_s3();
|
|
516
|
+
init_mime_validator();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
1
520
|
// libs/server-express/src/lib/server-express.ts
|
|
2
521
|
import { Router, json as jsonParser } from "express";
|
|
3
522
|
import multer from "multer";
|
|
@@ -261,6 +780,9 @@ function getSoftDeleteField(config) {
|
|
|
261
780
|
const sdConfig = config.softDelete;
|
|
262
781
|
return sdConfig.field ?? "deletedAt";
|
|
263
782
|
}
|
|
783
|
+
function isUploadCollection(config) {
|
|
784
|
+
return config.upload != null;
|
|
785
|
+
}
|
|
264
786
|
|
|
265
787
|
// libs/core/src/lib/fields/field.types.ts
|
|
266
788
|
var ReferentialIntegrityError = class extends Error {
|
|
@@ -478,6 +1000,9 @@ var MediaCollection = defineCollection({
|
|
|
478
1000
|
singular: "Media",
|
|
479
1001
|
plural: "Media"
|
|
480
1002
|
},
|
|
1003
|
+
upload: {
|
|
1004
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
1005
|
+
},
|
|
481
1006
|
admin: {
|
|
482
1007
|
useAsTitle: "filename",
|
|
483
1008
|
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
@@ -498,7 +1023,6 @@ var MediaCollection = defineCollection({
|
|
|
498
1023
|
description: "File size in bytes"
|
|
499
1024
|
}),
|
|
500
1025
|
text("path", {
|
|
501
|
-
required: true,
|
|
502
1026
|
label: "Storage Path",
|
|
503
1027
|
description: "Path/key where the file is stored",
|
|
504
1028
|
admin: {
|
|
@@ -802,6 +1326,7 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
|
|
|
802
1326
|
}
|
|
803
1327
|
const hooks = field.hooks?.[hookType];
|
|
804
1328
|
if (hooks && hooks.length > 0) {
|
|
1329
|
+
const fieldExistsInData = field.name in processedData;
|
|
805
1330
|
let value = processedData[field.name];
|
|
806
1331
|
for (const hook of hooks) {
|
|
807
1332
|
const result = await Promise.resolve(
|
|
@@ -816,7 +1341,9 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
|
|
|
816
1341
|
value = result;
|
|
817
1342
|
}
|
|
818
1343
|
}
|
|
819
|
-
|
|
1344
|
+
if (fieldExistsInData || value !== void 0) {
|
|
1345
|
+
processedData[field.name] = value;
|
|
1346
|
+
}
|
|
820
1347
|
}
|
|
821
1348
|
if (field.type === "group" && processedData[field.name] && typeof processedData[field.name] === "object" && !Array.isArray(processedData[field.name])) {
|
|
822
1349
|
processedData[field.name] = await runFieldHooks(
|
|
@@ -2911,7 +3438,7 @@ async function sendWebhook(webhook, payload, attempt = 0) {
|
|
|
2911
3438
|
});
|
|
2912
3439
|
if (!response.ok && attempt < maxRetries) {
|
|
2913
3440
|
const delay = Math.pow(2, attempt) * 1e3;
|
|
2914
|
-
await new Promise((
|
|
3441
|
+
await new Promise((resolve2) => setTimeout(resolve2, delay));
|
|
2915
3442
|
return sendWebhook(webhook, payload, attempt + 1);
|
|
2916
3443
|
}
|
|
2917
3444
|
if (!response.ok) {
|
|
@@ -2922,7 +3449,7 @@ async function sendWebhook(webhook, payload, attempt = 0) {
|
|
|
2922
3449
|
} catch (error) {
|
|
2923
3450
|
if (attempt < maxRetries) {
|
|
2924
3451
|
const delay = Math.pow(2, attempt) * 1e3;
|
|
2925
|
-
await new Promise((
|
|
3452
|
+
await new Promise((resolve2) => setTimeout(resolve2, delay));
|
|
2926
3453
|
return sendWebhook(webhook, payload, attempt + 1);
|
|
2927
3454
|
}
|
|
2928
3455
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
@@ -3640,210 +4167,8 @@ function createAdapterApiKeyStore(adapter) {
|
|
|
3640
4167
|
};
|
|
3641
4168
|
}
|
|
3642
4169
|
|
|
3643
|
-
// libs/storage/src/lib/mime-validator.ts
|
|
3644
|
-
var FILE_SIGNATURES = [
|
|
3645
|
-
// Images
|
|
3646
|
-
{ mimeType: "image/jpeg", bytes: [255, 216, 255] },
|
|
3647
|
-
{ mimeType: "image/png", bytes: [137, 80, 78, 71, 13, 10, 26, 10] },
|
|
3648
|
-
{ mimeType: "image/gif", bytes: [71, 73, 70, 56] },
|
|
3649
|
-
// GIF8
|
|
3650
|
-
{ mimeType: "image/webp", bytes: [82, 73, 70, 70], offset: 0 },
|
|
3651
|
-
// RIFF (need to check for WEBP at offset 8)
|
|
3652
|
-
{ mimeType: "image/bmp", bytes: [66, 77] },
|
|
3653
|
-
// BM
|
|
3654
|
-
{ mimeType: "image/tiff", bytes: [73, 73, 42, 0] },
|
|
3655
|
-
// Little-endian TIFF
|
|
3656
|
-
{ mimeType: "image/tiff", bytes: [77, 77, 0, 42] },
|
|
3657
|
-
// Big-endian TIFF
|
|
3658
|
-
{ mimeType: "image/x-icon", bytes: [0, 0, 1, 0] },
|
|
3659
|
-
// ICO
|
|
3660
|
-
{ mimeType: "image/svg+xml", bytes: [60, 115, 118, 103] },
|
|
3661
|
-
// <svg (partial match)
|
|
3662
|
-
// Documents
|
|
3663
|
-
{ mimeType: "application/pdf", bytes: [37, 80, 68, 70] },
|
|
3664
|
-
// %PDF
|
|
3665
|
-
// Archives
|
|
3666
|
-
{ mimeType: "application/zip", bytes: [80, 75, 3, 4] },
|
|
3667
|
-
// PK
|
|
3668
|
-
{ mimeType: "application/gzip", bytes: [31, 139] },
|
|
3669
|
-
{ mimeType: "application/x-rar-compressed", bytes: [82, 97, 114, 33] },
|
|
3670
|
-
// Rar!
|
|
3671
|
-
// Audio
|
|
3672
|
-
{ mimeType: "audio/mpeg", bytes: [73, 68, 51] },
|
|
3673
|
-
// ID3 (MP3)
|
|
3674
|
-
{ mimeType: "audio/mpeg", bytes: [255, 251] },
|
|
3675
|
-
// MP3 frame sync
|
|
3676
|
-
{ mimeType: "audio/wav", bytes: [82, 73, 70, 70] },
|
|
3677
|
-
// RIFF (need to check for WAVE)
|
|
3678
|
-
{ mimeType: "audio/ogg", bytes: [79, 103, 103, 83] },
|
|
3679
|
-
// OggS
|
|
3680
|
-
{ mimeType: "audio/flac", bytes: [102, 76, 97, 67] },
|
|
3681
|
-
// fLaC
|
|
3682
|
-
// Video
|
|
3683
|
-
{ mimeType: "video/mp4", bytes: [0, 0, 0], offset: 0 },
|
|
3684
|
-
// Need to check for ftyp at offset 4
|
|
3685
|
-
{ mimeType: "video/webm", bytes: [26, 69, 223, 163] },
|
|
3686
|
-
// EBML header
|
|
3687
|
-
{ mimeType: "video/avi", bytes: [82, 73, 70, 70] },
|
|
3688
|
-
// RIFF (need to check for AVI)
|
|
3689
|
-
// Executables (for blocking)
|
|
3690
|
-
{ mimeType: "application/x-executable", bytes: [127, 69, 76, 70] },
|
|
3691
|
-
// ELF
|
|
3692
|
-
{ mimeType: "application/x-msdownload", bytes: [77, 90] }
|
|
3693
|
-
// MZ (Windows EXE)
|
|
3694
|
-
];
|
|
3695
|
-
function detectMimeType(buffer) {
|
|
3696
|
-
for (const sig of FILE_SIGNATURES) {
|
|
3697
|
-
const offset = sig.offset ?? 0;
|
|
3698
|
-
if (buffer.length < offset + sig.bytes.length) {
|
|
3699
|
-
continue;
|
|
3700
|
-
}
|
|
3701
|
-
let match = true;
|
|
3702
|
-
for (let i = 0; i < sig.bytes.length; i++) {
|
|
3703
|
-
if (buffer[offset + i] !== sig.bytes[i]) {
|
|
3704
|
-
match = false;
|
|
3705
|
-
break;
|
|
3706
|
-
}
|
|
3707
|
-
}
|
|
3708
|
-
if (match) {
|
|
3709
|
-
if (sig.bytes[0] === 82 && sig.bytes[1] === 73) {
|
|
3710
|
-
if (buffer.length >= 12) {
|
|
3711
|
-
const formatId = buffer.slice(8, 12).toString("ascii");
|
|
3712
|
-
if (formatId === "WEBP") {
|
|
3713
|
-
return "image/webp";
|
|
3714
|
-
}
|
|
3715
|
-
if (formatId === "WAVE") {
|
|
3716
|
-
return "audio/wav";
|
|
3717
|
-
}
|
|
3718
|
-
if (formatId === "AVI ") {
|
|
3719
|
-
return "video/avi";
|
|
3720
|
-
}
|
|
3721
|
-
}
|
|
3722
|
-
}
|
|
3723
|
-
if (sig.mimeType === "video/mp4" && buffer.length >= 8) {
|
|
3724
|
-
const boxType = buffer.slice(4, 8).toString("ascii");
|
|
3725
|
-
if (boxType === "ftyp") {
|
|
3726
|
-
return "video/mp4";
|
|
3727
|
-
}
|
|
3728
|
-
}
|
|
3729
|
-
return sig.mimeType;
|
|
3730
|
-
}
|
|
3731
|
-
}
|
|
3732
|
-
if (isTextContent(buffer)) {
|
|
3733
|
-
const text2 = buffer.toString("utf8", 0, Math.min(buffer.length, 1e3));
|
|
3734
|
-
if (text2.trim().startsWith("{") || text2.trim().startsWith("[")) {
|
|
3735
|
-
return "application/json";
|
|
3736
|
-
}
|
|
3737
|
-
if (text2.trim().startsWith("<")) {
|
|
3738
|
-
if (text2.includes("<svg")) {
|
|
3739
|
-
return "image/svg+xml";
|
|
3740
|
-
}
|
|
3741
|
-
if (text2.includes("<!DOCTYPE html") || text2.includes("<html")) {
|
|
3742
|
-
return "text/html";
|
|
3743
|
-
}
|
|
3744
|
-
return "application/xml";
|
|
3745
|
-
}
|
|
3746
|
-
return "text/plain";
|
|
3747
|
-
}
|
|
3748
|
-
return null;
|
|
3749
|
-
}
|
|
3750
|
-
function isTextContent(buffer) {
|
|
3751
|
-
const checkLength = Math.min(buffer.length, 512);
|
|
3752
|
-
for (let i = 0; i < checkLength; i++) {
|
|
3753
|
-
const byte = buffer[i];
|
|
3754
|
-
if (byte < 9 || // Control chars before tab
|
|
3755
|
-
byte > 13 && byte < 32 || // Control chars between CR and space
|
|
3756
|
-
byte === 127) {
|
|
3757
|
-
if (byte >= 128 && byte <= 191) {
|
|
3758
|
-
continue;
|
|
3759
|
-
}
|
|
3760
|
-
if (byte >= 192 && byte <= 247) {
|
|
3761
|
-
continue;
|
|
3762
|
-
}
|
|
3763
|
-
return false;
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
return true;
|
|
3767
|
-
}
|
|
3768
|
-
function mimeTypeMatches(mimeType, pattern) {
|
|
3769
|
-
if (pattern === "*" || pattern === "*/*") {
|
|
3770
|
-
return true;
|
|
3771
|
-
}
|
|
3772
|
-
if (pattern.endsWith("/*")) {
|
|
3773
|
-
const category = pattern.slice(0, -2);
|
|
3774
|
-
return mimeType.startsWith(`${category}/`);
|
|
3775
|
-
}
|
|
3776
|
-
return mimeType === pattern;
|
|
3777
|
-
}
|
|
3778
|
-
function isMimeTypeAllowed(mimeType, allowedTypes) {
|
|
3779
|
-
if (allowedTypes.length === 0) {
|
|
3780
|
-
return true;
|
|
3781
|
-
}
|
|
3782
|
-
return allowedTypes.some((pattern) => mimeTypeMatches(mimeType, pattern));
|
|
3783
|
-
}
|
|
3784
|
-
function validateMimeType(buffer, claimedType, allowedTypes) {
|
|
3785
|
-
const detectedType = detectMimeType(buffer);
|
|
3786
|
-
if (allowedTypes && allowedTypes.length > 0) {
|
|
3787
|
-
const typeToCheck = detectedType ?? claimedType;
|
|
3788
|
-
if (!isMimeTypeAllowed(typeToCheck, allowedTypes)) {
|
|
3789
|
-
return {
|
|
3790
|
-
valid: false,
|
|
3791
|
-
detectedType,
|
|
3792
|
-
claimedType,
|
|
3793
|
-
error: `File type '${typeToCheck}' is not allowed. Allowed types: ${allowedTypes.join(", ")}`
|
|
3794
|
-
};
|
|
3795
|
-
}
|
|
3796
|
-
}
|
|
3797
|
-
if (!detectedType) {
|
|
3798
|
-
return {
|
|
3799
|
-
valid: true,
|
|
3800
|
-
detectedType: null,
|
|
3801
|
-
claimedType
|
|
3802
|
-
};
|
|
3803
|
-
}
|
|
3804
|
-
const compatible = areMimeTypesCompatible(detectedType, claimedType);
|
|
3805
|
-
if (!compatible) {
|
|
3806
|
-
return {
|
|
3807
|
-
valid: false,
|
|
3808
|
-
detectedType,
|
|
3809
|
-
claimedType,
|
|
3810
|
-
error: `File appears to be '${detectedType}' but was uploaded as '${claimedType}'`
|
|
3811
|
-
};
|
|
3812
|
-
}
|
|
3813
|
-
return {
|
|
3814
|
-
valid: true,
|
|
3815
|
-
detectedType,
|
|
3816
|
-
claimedType
|
|
3817
|
-
};
|
|
3818
|
-
}
|
|
3819
|
-
function areMimeTypesCompatible(detected, claimed) {
|
|
3820
|
-
if (detected === claimed) {
|
|
3821
|
-
return true;
|
|
3822
|
-
}
|
|
3823
|
-
const [detectedCategory] = detected.split("/");
|
|
3824
|
-
const [claimedCategory] = claimed.split("/");
|
|
3825
|
-
if (detectedCategory !== claimedCategory) {
|
|
3826
|
-
return false;
|
|
3827
|
-
}
|
|
3828
|
-
const variations = {
|
|
3829
|
-
"image/jpeg": ["image/jpg", "image/pjpeg"],
|
|
3830
|
-
"text/plain": ["text/x-plain"],
|
|
3831
|
-
"application/json": ["text/json"],
|
|
3832
|
-
"application/javascript": ["text/javascript", "application/x-javascript"]
|
|
3833
|
-
};
|
|
3834
|
-
const allowedVariations = variations[detected];
|
|
3835
|
-
if (allowedVariations && allowedVariations.includes(claimed)) {
|
|
3836
|
-
return true;
|
|
3837
|
-
}
|
|
3838
|
-
for (const [canonical, variants] of Object.entries(variations)) {
|
|
3839
|
-
if (variants.includes(detected) && (canonical === claimed || variants.includes(claimed))) {
|
|
3840
|
-
return true;
|
|
3841
|
-
}
|
|
3842
|
-
}
|
|
3843
|
-
return false;
|
|
3844
|
-
}
|
|
3845
|
-
|
|
3846
4170
|
// libs/server-core/src/lib/upload-handler.ts
|
|
4171
|
+
init_src();
|
|
3847
4172
|
function getUploadConfig(config) {
|
|
3848
4173
|
if (!config.storage?.adapter) {
|
|
3849
4174
|
return null;
|
|
@@ -3958,6 +4283,64 @@ async function handleUpload(config, request) {
|
|
|
3958
4283
|
};
|
|
3959
4284
|
}
|
|
3960
4285
|
}
|
|
4286
|
+
async function handleCollectionUpload(globalConfig, request) {
|
|
4287
|
+
const { adapter } = globalConfig;
|
|
4288
|
+
const { file, user, fields, collectionSlug, collectionUpload } = request;
|
|
4289
|
+
const maxFileSize = collectionUpload.maxFileSize ?? globalConfig.maxFileSize ?? 10 * 1024 * 1024;
|
|
4290
|
+
const allowedMimeTypes = collectionUpload.mimeTypes ?? globalConfig.allowedMimeTypes ?? [];
|
|
4291
|
+
try {
|
|
4292
|
+
if (!user) {
|
|
4293
|
+
return {
|
|
4294
|
+
status: 401,
|
|
4295
|
+
error: "Authentication required to upload files"
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
const sizeError = validateFileSize(file, maxFileSize);
|
|
4299
|
+
if (sizeError) {
|
|
4300
|
+
return { status: 400, error: sizeError };
|
|
4301
|
+
}
|
|
4302
|
+
const mimeError = validateMimeType2(file.mimeType, allowedMimeTypes);
|
|
4303
|
+
if (mimeError) {
|
|
4304
|
+
return { status: 400, error: mimeError };
|
|
4305
|
+
}
|
|
4306
|
+
if (file.buffer && file.buffer.length > 0) {
|
|
4307
|
+
const magicByteResult = validateMimeType(
|
|
4308
|
+
file.buffer,
|
|
4309
|
+
file.mimeType,
|
|
4310
|
+
allowedMimeTypes
|
|
4311
|
+
);
|
|
4312
|
+
if (!magicByteResult.valid) {
|
|
4313
|
+
return {
|
|
4314
|
+
status: 400,
|
|
4315
|
+
error: magicByteResult.error ?? "File content does not match claimed type"
|
|
4316
|
+
};
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
const storedFile = await adapter.upload(file);
|
|
4320
|
+
const docData = {
|
|
4321
|
+
...fields,
|
|
4322
|
+
filename: file.originalName,
|
|
4323
|
+
mimeType: file.mimeType,
|
|
4324
|
+
filesize: file.size,
|
|
4325
|
+
path: storedFile.path,
|
|
4326
|
+
url: storedFile.url
|
|
4327
|
+
};
|
|
4328
|
+
const api = getMomentumAPI().setContext({ user });
|
|
4329
|
+
const doc = await api.collection(collectionSlug).create(docData);
|
|
4330
|
+
return {
|
|
4331
|
+
status: 201,
|
|
4332
|
+
doc
|
|
4333
|
+
};
|
|
4334
|
+
} catch (error) {
|
|
4335
|
+
if (error instanceof Error) {
|
|
4336
|
+
if (error.message.includes("Access denied")) {
|
|
4337
|
+
return { status: 403, error: error.message };
|
|
4338
|
+
}
|
|
4339
|
+
return { status: 500, error: `Upload failed: ${error.message}` };
|
|
4340
|
+
}
|
|
4341
|
+
return { status: 500, error: "Upload failed: Unknown error" };
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
3961
4344
|
async function handleFileGet(adapter, path) {
|
|
3962
4345
|
if (!adapter.read) {
|
|
3963
4346
|
return null;
|
|
@@ -5508,6 +5891,51 @@ function momentumApiMiddleware(config) {
|
|
|
5508
5891
|
// Default 10MB
|
|
5509
5892
|
}
|
|
5510
5893
|
});
|
|
5894
|
+
const uploadCollectionSlugs = new Set(
|
|
5895
|
+
config.collections.filter((c) => isUploadCollection(c)).map((c) => c.slug)
|
|
5896
|
+
);
|
|
5897
|
+
async function handleUploadCollectionPost(req, res) {
|
|
5898
|
+
const slug2 = req.params["collection"];
|
|
5899
|
+
const collectionConfig = config.collections.find((c) => c.slug === slug2);
|
|
5900
|
+
if (!collectionConfig?.upload) {
|
|
5901
|
+
res.status(400).json({ error: "Not an upload collection" });
|
|
5902
|
+
return;
|
|
5903
|
+
}
|
|
5904
|
+
const uploadConfig = getUploadConfig(config);
|
|
5905
|
+
if (!uploadConfig) {
|
|
5906
|
+
res.status(500).json({ error: "Storage not configured" });
|
|
5907
|
+
return;
|
|
5908
|
+
}
|
|
5909
|
+
const multerFile = req.file;
|
|
5910
|
+
if (!multerFile) {
|
|
5911
|
+
res.status(400).json({ error: "No file provided" });
|
|
5912
|
+
return;
|
|
5913
|
+
}
|
|
5914
|
+
const file = {
|
|
5915
|
+
originalName: multerFile.originalname,
|
|
5916
|
+
mimeType: multerFile.mimetype,
|
|
5917
|
+
size: multerFile.size,
|
|
5918
|
+
buffer: multerFile.buffer
|
|
5919
|
+
};
|
|
5920
|
+
const fields = {};
|
|
5921
|
+
if (typeof req.body === "object" && req.body !== null) {
|
|
5922
|
+
for (const [key, value] of Object.entries(
|
|
5923
|
+
req.body
|
|
5924
|
+
)) {
|
|
5925
|
+
if (key !== "file") {
|
|
5926
|
+
fields[key] = value;
|
|
5927
|
+
}
|
|
5928
|
+
}
|
|
5929
|
+
}
|
|
5930
|
+
const response = await handleCollectionUpload(uploadConfig, {
|
|
5931
|
+
file,
|
|
5932
|
+
user: extractUserFromRequest(req),
|
|
5933
|
+
fields,
|
|
5934
|
+
collectionSlug: slug2,
|
|
5935
|
+
collectionUpload: collectionConfig.upload
|
|
5936
|
+
});
|
|
5937
|
+
res.status(response.status).json(response);
|
|
5938
|
+
}
|
|
5511
5939
|
router.post(
|
|
5512
5940
|
"/media/upload",
|
|
5513
5941
|
(req, res, next) => {
|
|
@@ -5557,7 +5985,7 @@ function momentumApiMiddleware(config) {
|
|
|
5557
5985
|
res.status(400).json({ error: "File path required" });
|
|
5558
5986
|
return;
|
|
5559
5987
|
}
|
|
5560
|
-
const { normalize, isAbsolute, resolve, sep } = await import("node:path");
|
|
5988
|
+
const { normalize: normalize2, isAbsolute, resolve: resolve2, sep } = await import("node:path");
|
|
5561
5989
|
let decodedPath;
|
|
5562
5990
|
try {
|
|
5563
5991
|
decodedPath = decodeURIComponent(rawPath);
|
|
@@ -5565,13 +5993,13 @@ function momentumApiMiddleware(config) {
|
|
|
5565
5993
|
res.status(400).json({ error: "Invalid path encoding" });
|
|
5566
5994
|
return;
|
|
5567
5995
|
}
|
|
5568
|
-
const filePath =
|
|
5996
|
+
const filePath = normalize2(decodedPath);
|
|
5569
5997
|
if (isAbsolute(filePath) || filePath.includes("..") || filePath.includes(`${sep}..`)) {
|
|
5570
5998
|
res.status(403).json({ error: "Invalid file path" });
|
|
5571
5999
|
return;
|
|
5572
6000
|
}
|
|
5573
|
-
const fakeRoot =
|
|
5574
|
-
const resolved =
|
|
6001
|
+
const fakeRoot = resolve2("/safe-root");
|
|
6002
|
+
const resolved = resolve2(fakeRoot, filePath);
|
|
5575
6003
|
if (!resolved.startsWith(fakeRoot + sep) && resolved !== fakeRoot) {
|
|
5576
6004
|
res.status(403).json({ error: "Invalid file path" });
|
|
5577
6005
|
return;
|
|
@@ -5891,6 +6319,28 @@ function momentumApiMiddleware(config) {
|
|
|
5891
6319
|
const response = await handlers.routeRequest(request);
|
|
5892
6320
|
res.status(response.status ?? 200).json(response);
|
|
5893
6321
|
});
|
|
6322
|
+
router.post("/:collection", (req, res, next) => {
|
|
6323
|
+
const slug2 = req.params["collection"];
|
|
6324
|
+
if (uploadCollectionSlugs.has(slug2)) {
|
|
6325
|
+
const user = extractUserFromRequest(req);
|
|
6326
|
+
if (!user) {
|
|
6327
|
+
res.status(401).json({ error: "Authentication required to upload files" });
|
|
6328
|
+
return;
|
|
6329
|
+
}
|
|
6330
|
+
upload2.single("file")(req, res, (err) => {
|
|
6331
|
+
if (err) {
|
|
6332
|
+
res.status(400).json({ error: err.message });
|
|
6333
|
+
return;
|
|
6334
|
+
}
|
|
6335
|
+
handleUploadCollectionPost(req, res).catch((e) => {
|
|
6336
|
+
const message = sanitizeErrorMessage(e, "Upload failed");
|
|
6337
|
+
res.status(500).json({ error: message });
|
|
6338
|
+
});
|
|
6339
|
+
});
|
|
6340
|
+
} else {
|
|
6341
|
+
next();
|
|
6342
|
+
}
|
|
6343
|
+
});
|
|
5894
6344
|
router.post("/:collection", async (req, res) => {
|
|
5895
6345
|
if (isManagedCollection(req.params["collection"])) {
|
|
5896
6346
|
res.status(403).json({ error: "Managed collection is read-only" });
|
|
@@ -5905,6 +6355,111 @@ function momentumApiMiddleware(config) {
|
|
|
5905
6355
|
const response = await handlers.routeRequest(request);
|
|
5906
6356
|
res.status(response.status ?? 200).json(response);
|
|
5907
6357
|
});
|
|
6358
|
+
router.patch("/:collection/:id", (req, res, next) => {
|
|
6359
|
+
const slug2 = req.params["collection"];
|
|
6360
|
+
if (uploadCollectionSlugs.has(slug2)) {
|
|
6361
|
+
const user = extractUserFromRequest(req);
|
|
6362
|
+
if (!user) {
|
|
6363
|
+
res.status(401).json({ error: "Authentication required to upload files" });
|
|
6364
|
+
return;
|
|
6365
|
+
}
|
|
6366
|
+
upload2.single("file")(req, res, async (err) => {
|
|
6367
|
+
if (err) {
|
|
6368
|
+
res.status(400).json({ error: err.message });
|
|
6369
|
+
return;
|
|
6370
|
+
}
|
|
6371
|
+
if (req.file) {
|
|
6372
|
+
const collectionConfig = config.collections.find((c) => c.slug === slug2);
|
|
6373
|
+
if (!collectionConfig?.upload) {
|
|
6374
|
+
res.status(400).json({ error: "Not an upload collection" });
|
|
6375
|
+
return;
|
|
6376
|
+
}
|
|
6377
|
+
const uploadConfig = getUploadConfig(config);
|
|
6378
|
+
if (!uploadConfig) {
|
|
6379
|
+
res.status(500).json({ error: "Storage not configured" });
|
|
6380
|
+
return;
|
|
6381
|
+
}
|
|
6382
|
+
const file = {
|
|
6383
|
+
originalName: req.file.originalname,
|
|
6384
|
+
mimeType: req.file.mimetype,
|
|
6385
|
+
size: req.file.size,
|
|
6386
|
+
buffer: req.file.buffer
|
|
6387
|
+
};
|
|
6388
|
+
const fields = {};
|
|
6389
|
+
if (typeof req.body === "object" && req.body !== null) {
|
|
6390
|
+
for (const [key, value] of Object.entries(
|
|
6391
|
+
req.body
|
|
6392
|
+
)) {
|
|
6393
|
+
if (key !== "file") {
|
|
6394
|
+
fields[key] = value;
|
|
6395
|
+
}
|
|
6396
|
+
}
|
|
6397
|
+
}
|
|
6398
|
+
try {
|
|
6399
|
+
const { validateMimeType: validateMimeByMagicBytes } = await Promise.resolve().then(() => (init_src(), src_exports));
|
|
6400
|
+
const maxFileSize = collectionConfig.upload.maxFileSize ?? uploadConfig.maxFileSize ?? 10 * 1024 * 1024;
|
|
6401
|
+
const allowedMimeTypes = collectionConfig.upload.mimeTypes ?? uploadConfig.allowedMimeTypes ?? [];
|
|
6402
|
+
if (file.size > maxFileSize) {
|
|
6403
|
+
const maxMB = (maxFileSize / (1024 * 1024)).toFixed(1);
|
|
6404
|
+
const fileMB = (file.size / (1024 * 1024)).toFixed(1);
|
|
6405
|
+
res.status(400).json({
|
|
6406
|
+
error: `File size ${fileMB}MB exceeds maximum allowed size of ${maxMB}MB`
|
|
6407
|
+
});
|
|
6408
|
+
return;
|
|
6409
|
+
}
|
|
6410
|
+
const mimeError = validateMimeType2(file.mimeType, allowedMimeTypes);
|
|
6411
|
+
if (mimeError) {
|
|
6412
|
+
res.status(400).json({ error: mimeError });
|
|
6413
|
+
return;
|
|
6414
|
+
}
|
|
6415
|
+
if (file.buffer && file.buffer.length > 0) {
|
|
6416
|
+
const magicByteResult = validateMimeByMagicBytes(
|
|
6417
|
+
file.buffer,
|
|
6418
|
+
file.mimeType,
|
|
6419
|
+
allowedMimeTypes
|
|
6420
|
+
);
|
|
6421
|
+
if (!magicByteResult.valid) {
|
|
6422
|
+
res.status(400).json({
|
|
6423
|
+
error: magicByteResult.error ?? "File content does not match claimed type"
|
|
6424
|
+
});
|
|
6425
|
+
return;
|
|
6426
|
+
}
|
|
6427
|
+
}
|
|
6428
|
+
const storedFile = await uploadConfig.adapter.upload(file);
|
|
6429
|
+
const updateData = {
|
|
6430
|
+
...fields,
|
|
6431
|
+
filename: file.originalName,
|
|
6432
|
+
mimeType: file.mimeType,
|
|
6433
|
+
filesize: file.size,
|
|
6434
|
+
path: storedFile.path,
|
|
6435
|
+
url: storedFile.url
|
|
6436
|
+
};
|
|
6437
|
+
const api = getMomentumAPI();
|
|
6438
|
+
const contextApi = api.setContext({ user });
|
|
6439
|
+
const doc = await contextApi.collection(slug2).update(req.params["id"], updateData);
|
|
6440
|
+
res.json({ doc });
|
|
6441
|
+
} catch (error) {
|
|
6442
|
+
const message = sanitizeErrorMessage(error, "Upload update failed");
|
|
6443
|
+
res.status(500).json({ error: message });
|
|
6444
|
+
}
|
|
6445
|
+
} else {
|
|
6446
|
+
const body = typeof req.body === "object" && req.body !== null ? req.body : {};
|
|
6447
|
+
const request = {
|
|
6448
|
+
method: "PATCH",
|
|
6449
|
+
collectionSlug: slug2,
|
|
6450
|
+
id: req.params["id"],
|
|
6451
|
+
body,
|
|
6452
|
+
user
|
|
6453
|
+
// already extracted and validated before multer
|
|
6454
|
+
};
|
|
6455
|
+
const response = await handlers.routeRequest(request);
|
|
6456
|
+
res.status(response.status ?? 200).json(response);
|
|
6457
|
+
}
|
|
6458
|
+
});
|
|
6459
|
+
} else {
|
|
6460
|
+
next();
|
|
6461
|
+
}
|
|
6462
|
+
});
|
|
5908
6463
|
router.patch("/:collection/:id", async (req, res) => {
|
|
5909
6464
|
if (isManagedCollection(req.params["collection"])) {
|
|
5910
6465
|
res.status(403).json({ error: "Managed collection is read-only" });
|