@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.
Files changed (4) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/index.cjs +762 -213
  3. package/index.js +766 -211
  4. 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
- processedData[field.name] = value;
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((resolve) => setTimeout(resolve, delay));
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((resolve) => setTimeout(resolve, delay));
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 = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, "");
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 = resolve("/safe-root");
5574
- const resolved = resolve(fakeRoot, filePath);
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" });