@cedarjs/uploads 8.0.0-canary.3402 → 8.0.0-canary.3403

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.
@@ -0,0 +1,443 @@
1
+ import {
2
+ canonicalContentType,
3
+ isContentTypeListed,
4
+ isExplicitOnlyContentType
5
+ } from "./contentTypes.js";
6
+ import { isMimeTypeAllowed } from "./profiles.js";
7
+ const DETECT_CONTENT_TYPE_BYTES = 4096;
8
+ const DETECT_CONTENT_TYPE_TAIL_BYTES = 64 * 1024;
9
+ const MAX_ZIP_DIRECTORY_BYTES = 4 * 1024 * 1024;
10
+ const DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
11
+ const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
12
+ const PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
13
+ const DOCM = "application/vnd.ms-word.document.macroenabled.12";
14
+ const XLSM = "application/vnd.ms-excel.sheet.macroenabled.12";
15
+ const PPTM = "application/vnd.ms-powerpoint.presentation.macroenabled.12";
16
+ const ODT = "application/vnd.oasis.opendocument.text";
17
+ const ODS = "application/vnd.oasis.opendocument.spreadsheet";
18
+ const ODP = "application/vnd.oasis.opendocument.presentation";
19
+ const DETECTABLE_CONTENT_TYPES = /* @__PURE__ */ new Set([
20
+ "image/jpeg",
21
+ "image/png",
22
+ "image/gif",
23
+ "image/webp",
24
+ "image/bmp",
25
+ "image/tiff",
26
+ "image/avif",
27
+ "image/heic",
28
+ "image/heic-sequence",
29
+ "image/heif",
30
+ "image/heif-sequence",
31
+ "application/pdf",
32
+ "video/mp4",
33
+ "audio/mp4",
34
+ "video/quicktime",
35
+ "video/webm",
36
+ "video/matroska",
37
+ DOCX,
38
+ XLSX,
39
+ PPTX,
40
+ DOCM,
41
+ XLSM,
42
+ PPTM,
43
+ ODT,
44
+ ODS,
45
+ ODP
46
+ ]);
47
+ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
48
+ const EBML_SIGNATURE = [26, 69, 223, 163];
49
+ const BMP_INFO_HEADER_SIZES = /* @__PURE__ */ new Set([12, 40, 52, 56, 64, 108, 124]);
50
+ const AVIF_BRANDS = /* @__PURE__ */ new Set(["avif", "avis"]);
51
+ const HEIC_BRANDS = /* @__PURE__ */ new Set(["heic", "heix", "heim", "heis"]);
52
+ const HEIC_SEQUENCE_BRANDS = /* @__PURE__ */ new Set(["hevc", "hevx", "hevm", "hevs"]);
53
+ const MP4_BRANDS = /* @__PURE__ */ new Set([
54
+ "isom",
55
+ "iso2",
56
+ "iso3",
57
+ "iso4",
58
+ "iso5",
59
+ "iso6",
60
+ "iso7",
61
+ "iso8",
62
+ "iso9",
63
+ "mp41",
64
+ "mp42",
65
+ "mp71",
66
+ "avc1",
67
+ "dash",
68
+ "M4V "
69
+ ]);
70
+ const MP4_AUDIO_BRANDS = /* @__PURE__ */ new Set(["M4A ", "M4B "]);
71
+ const QUICKTIME_BRAND = "qt ";
72
+ const MAX_FTYP_BOX_SIZE = 1024;
73
+ const EBML_DOC_TYPE_ID = 17026;
74
+ const MAX_DOC_TYPE_LENGTH = 32;
75
+ function hasBytesAt(bytes, offset, expected) {
76
+ if (bytes.length < offset + expected.length) {
77
+ return false;
78
+ }
79
+ return expected.every((b, i) => bytes[offset + i] === b);
80
+ }
81
+ function ascii(bytes, start, end) {
82
+ if (end > bytes.length) {
83
+ return null;
84
+ }
85
+ let text = "";
86
+ for (let i = start; i < end; i++) {
87
+ text += String.fromCharCode(bytes[i]);
88
+ }
89
+ return text;
90
+ }
91
+ function readUint32BE(bytes, offset) {
92
+ if (bytes.length < offset + 4) {
93
+ return null;
94
+ }
95
+ return bytes[offset] * 16777216 + bytes[offset + 1] * 65536 + bytes[offset + 2] * 256 + bytes[offset + 3];
96
+ }
97
+ function readUint32LE(bytes, offset) {
98
+ if (bytes.length < offset + 4) {
99
+ return null;
100
+ }
101
+ return bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536 + bytes[offset + 3] * 16777216;
102
+ }
103
+ function detectIsoBmff(bytes) {
104
+ if (ascii(bytes, 4, 8) !== "ftyp") {
105
+ return null;
106
+ }
107
+ const boxSize = readUint32BE(bytes, 0);
108
+ if (boxSize === null || boxSize < 16 || boxSize % 4 !== 0 || boxSize > MAX_FTYP_BOX_SIZE) {
109
+ return null;
110
+ }
111
+ const major = ascii(bytes, 8, 12);
112
+ if (major === null) {
113
+ return null;
114
+ }
115
+ const fromMajor = classifyMajorBrand(major);
116
+ if (fromMajor) {
117
+ return fromMajor;
118
+ }
119
+ if (bytes.length < boxSize) {
120
+ return null;
121
+ }
122
+ const compatible = [];
123
+ for (let offset = 16; offset + 4 <= boxSize; offset += 4) {
124
+ const brand = ascii(bytes, offset, offset + 4);
125
+ if (brand !== null) {
126
+ compatible.push(brand);
127
+ }
128
+ }
129
+ return classifyCompatibleBrands(major, compatible);
130
+ }
131
+ function classifyMajorBrand(major) {
132
+ if (AVIF_BRANDS.has(major)) {
133
+ return "image/avif";
134
+ }
135
+ if (HEIC_BRANDS.has(major)) {
136
+ return "image/heic";
137
+ }
138
+ if (HEIC_SEQUENCE_BRANDS.has(major)) {
139
+ return "image/heic-sequence";
140
+ }
141
+ if (major === QUICKTIME_BRAND) {
142
+ return "video/quicktime";
143
+ }
144
+ if (MP4_AUDIO_BRANDS.has(major)) {
145
+ return "audio/mp4";
146
+ }
147
+ if (MP4_BRANDS.has(major)) {
148
+ return "video/mp4";
149
+ }
150
+ return null;
151
+ }
152
+ function classifyCompatibleBrands(major, compatible) {
153
+ const has = (brands) => compatible.some((brand) => brands.has(brand));
154
+ if (has(AVIF_BRANDS)) {
155
+ return "image/avif";
156
+ }
157
+ if (has(HEIC_BRANDS)) {
158
+ return "image/heic";
159
+ }
160
+ if (has(HEIC_SEQUENCE_BRANDS)) {
161
+ return "image/heic-sequence";
162
+ }
163
+ if (major === "mif1" || major === "mif2" || compatible.includes("mif1")) {
164
+ return "image/heif";
165
+ }
166
+ if (major === "msf1" || compatible.includes("msf1")) {
167
+ return "image/heif-sequence";
168
+ }
169
+ if (compatible.includes(QUICKTIME_BRAND)) {
170
+ return "video/quicktime";
171
+ }
172
+ if (has(MP4_AUDIO_BRANDS)) {
173
+ return "audio/mp4";
174
+ }
175
+ if (has(MP4_BRANDS)) {
176
+ return "video/mp4";
177
+ }
178
+ return null;
179
+ }
180
+ function readVint(bytes, offset, keepMarker) {
181
+ if (offset >= bytes.length) {
182
+ return null;
183
+ }
184
+ const first = bytes[offset];
185
+ if (first === 0) {
186
+ return null;
187
+ }
188
+ let length = 1;
189
+ while (!(first & 128 >> length - 1)) {
190
+ length++;
191
+ }
192
+ if (offset + length > bytes.length) {
193
+ return null;
194
+ }
195
+ const marker = 128 >> length - 1;
196
+ let value = keepMarker ? first : first & marker - 1;
197
+ let allOnes = (first & marker - 1) === marker - 1;
198
+ for (let i = 1; i < length; i++) {
199
+ const byte = bytes[offset + i];
200
+ value = value * 256 + byte;
201
+ allOnes = allOnes && byte === 255;
202
+ }
203
+ return { value, length, unknown: !keepMarker && allOnes };
204
+ }
205
+ function detectEbml(bytes) {
206
+ if (!hasBytesAt(bytes, 0, EBML_SIGNATURE)) {
207
+ return null;
208
+ }
209
+ const headerSize = readVint(bytes, EBML_SIGNATURE.length, false);
210
+ if (!headerSize || headerSize.unknown) {
211
+ return null;
212
+ }
213
+ const headerStart = EBML_SIGNATURE.length + headerSize.length;
214
+ const headerEnd = headerStart + headerSize.value;
215
+ let offset = headerStart;
216
+ while (offset < Math.min(headerEnd, bytes.length)) {
217
+ const id = readVint(bytes, offset, true);
218
+ if (!id || id.length > 4 || offset + id.length > headerEnd) {
219
+ return null;
220
+ }
221
+ const size = readVint(bytes, offset + id.length, false);
222
+ if (!size || size.unknown) {
223
+ return null;
224
+ }
225
+ const dataStart = offset + id.length + size.length;
226
+ if (dataStart + size.value > headerEnd) {
227
+ return null;
228
+ }
229
+ if (id.value === EBML_DOC_TYPE_ID) {
230
+ let docTypeEnd = dataStart + size.value;
231
+ if (size.value > MAX_DOC_TYPE_LENGTH || docTypeEnd > bytes.length) {
232
+ return null;
233
+ }
234
+ while (docTypeEnd > dataStart && bytes[docTypeEnd - 1] === 0) {
235
+ docTypeEnd--;
236
+ }
237
+ switch (ascii(bytes, dataStart, docTypeEnd)) {
238
+ case "webm":
239
+ return "video/webm";
240
+ case "matroska":
241
+ return "video/matroska";
242
+ default:
243
+ return null;
244
+ }
245
+ }
246
+ offset = dataStart + size.value;
247
+ }
248
+ return null;
249
+ }
250
+ function detectBmp(bytes) {
251
+ if (ascii(bytes, 0, 2) !== "BM") {
252
+ return null;
253
+ }
254
+ const reserved = readUint32LE(bytes, 6);
255
+ const infoHeaderSize = readUint32LE(bytes, 14);
256
+ if (reserved === 0 && infoHeaderSize !== null && BMP_INFO_HEADER_SIZES.has(infoHeaderSize)) {
257
+ return "image/bmp";
258
+ }
259
+ return null;
260
+ }
261
+ const ZIP_LOCAL_HEADER = [80, 75, 3, 4];
262
+ const ZIP_CENTRAL_HEADER = [80, 75, 1, 2];
263
+ const ZIP_END_OF_DIRECTORY = [80, 75, 5, 6];
264
+ const ZIP_LOCAL_HEADER_SIZE = 30;
265
+ const ZIP_CENTRAL_HEADER_SIZE = 46;
266
+ const ZIP_END_OF_DIRECTORY_SIZE = 22;
267
+ const ZIP_MAX_COMMENT_LENGTH = 65535;
268
+ const OPEN_DOCUMENT_TYPES = /* @__PURE__ */ new Set([ODT, ODS, ODP]);
269
+ function readUint16LE(bytes, offset) {
270
+ if (bytes.length < offset + 2) {
271
+ return null;
272
+ }
273
+ return bytes[offset] + bytes[offset + 1] * 256;
274
+ }
275
+ function detectOpenDocument(bytes) {
276
+ const flags = readUint16LE(bytes, 6);
277
+ const method = readUint16LE(bytes, 8);
278
+ const compressedSize = readUint32LE(bytes, 18);
279
+ const size = readUint32LE(bytes, 22);
280
+ const nameLength = readUint16LE(bytes, 26);
281
+ const extraLength = readUint16LE(bytes, 28);
282
+ if (flags === null || method === null || compressedSize === null || size === null || nameLength === null || extraLength === null) {
283
+ return null;
284
+ }
285
+ if (method !== 0 || flags & 8 || compressedSize !== size || size > 64) {
286
+ return null;
287
+ }
288
+ const nameEnd = ZIP_LOCAL_HEADER_SIZE + nameLength;
289
+ if (ascii(bytes, ZIP_LOCAL_HEADER_SIZE, nameEnd) !== "mimetype") {
290
+ return null;
291
+ }
292
+ const contentStart = nameEnd + extraLength;
293
+ const content = ascii(bytes, contentStart, contentStart + size);
294
+ return content !== null && OPEN_DOCUMENT_TYPES.has(content) ? content : null;
295
+ }
296
+ const MAX_ZIP_END_RECORD_CANDIDATES = 16;
297
+ function* zipEndRecords(end) {
298
+ const last = end.length - ZIP_END_OF_DIRECTORY_SIZE;
299
+ const first = Math.max(0, last - ZIP_MAX_COMMENT_LENGTH);
300
+ let candidates = 0;
301
+ for (let offset = last; offset >= first; offset--) {
302
+ if (!hasBytesAt(end, offset, ZIP_END_OF_DIRECTORY) || readUint16LE(end, offset + 20) !== last - offset) {
303
+ continue;
304
+ }
305
+ if (++candidates > MAX_ZIP_END_RECORD_CANDIDATES) {
306
+ return;
307
+ }
308
+ const disk = readUint16LE(end, offset + 4);
309
+ const directoryDisk = readUint16LE(end, offset + 6);
310
+ const entries = readUint16LE(end, offset + 10);
311
+ const directorySize = readUint32LE(end, offset + 12);
312
+ const directoryOffset = readUint32LE(end, offset + 16);
313
+ if (disk !== 0 || directoryDisk !== 0 || entries === null || entries === 65535 || directorySize === null || directorySize === 4294967295 || directorySize > MAX_ZIP_DIRECTORY_BYTES || directoryOffset === null || directoryOffset === 4294967295) {
314
+ continue;
315
+ }
316
+ yield { recordOffset: offset, entries, directorySize, directoryOffset };
317
+ }
318
+ }
319
+ function findZipCentralDirectory(tail) {
320
+ for (const record of zipEndRecords(tail)) {
321
+ if (record.entries > 0 && record.directorySize > 0) {
322
+ return { offset: record.directoryOffset, size: record.directorySize };
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+ function readZipEntryNames(end, { recordOffset, entries, directorySize }) {
328
+ let offset = recordOffset - directorySize;
329
+ if (offset < 0) {
330
+ return null;
331
+ }
332
+ const names = [];
333
+ for (let i = 0; i < entries; i++) {
334
+ if (!hasBytesAt(end, offset, ZIP_CENTRAL_HEADER)) {
335
+ return null;
336
+ }
337
+ const nameLength = readUint16LE(end, offset + 28);
338
+ const extraLength = readUint16LE(end, offset + 30);
339
+ const commentLength = readUint16LE(end, offset + 32);
340
+ if (nameLength === null || extraLength === null || commentLength === null) {
341
+ return null;
342
+ }
343
+ const nameStart = offset + ZIP_CENTRAL_HEADER_SIZE;
344
+ const name = ascii(end, nameStart, nameStart + nameLength);
345
+ offset = nameStart + nameLength + extraLength + commentLength;
346
+ if (name === null || offset > recordOffset) {
347
+ return null;
348
+ }
349
+ names.push(name);
350
+ }
351
+ return offset === recordOffset ? names : null;
352
+ }
353
+ function classifyOfficeOpenXml(entryNames) {
354
+ const names = entryNames.map((name) => name.toLowerCase());
355
+ if (!names.includes("[content_types].xml")) {
356
+ return null;
357
+ }
358
+ const families = [
359
+ { prefix: "word/", plain: DOCX, macro: DOCM },
360
+ { prefix: "xl/", plain: XLSX, macro: XLSM },
361
+ { prefix: "ppt/", plain: PPTX, macro: PPTM }
362
+ ].filter(({ prefix }) => names.some((name) => name.startsWith(prefix)));
363
+ if (families.length !== 1) {
364
+ return null;
365
+ }
366
+ const hasMacros = names.some(
367
+ (name) => name.endsWith("vbaproject.bin") || name.endsWith("vbadata.xml") || name.startsWith("xl/macrosheets/")
368
+ );
369
+ return hasMacros ? families[0].macro : families[0].plain;
370
+ }
371
+ function detectZip(bytes, tail) {
372
+ if (!hasBytesAt(bytes, 0, ZIP_LOCAL_HEADER)) {
373
+ return null;
374
+ }
375
+ const openDocument = detectOpenDocument(bytes);
376
+ if (openDocument) {
377
+ return openDocument;
378
+ }
379
+ const end = tail ?? bytes;
380
+ for (const record of zipEndRecords(end)) {
381
+ const names = readZipEntryNames(end, record);
382
+ const type = names && classifyOfficeOpenXml(names);
383
+ if (type) {
384
+ return type;
385
+ }
386
+ }
387
+ return null;
388
+ }
389
+ function detectContentType(bytes, options = {}) {
390
+ if (hasBytesAt(bytes, 0, [255, 216, 255])) {
391
+ return "image/jpeg";
392
+ }
393
+ if (hasBytesAt(bytes, 0, PNG_SIGNATURE)) {
394
+ return "image/png";
395
+ }
396
+ const head6 = ascii(bytes, 0, 6);
397
+ if (head6 === "GIF87a" || head6 === "GIF89a") {
398
+ return "image/gif";
399
+ }
400
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") {
401
+ return "image/webp";
402
+ }
403
+ if (ascii(bytes, 0, 5) === "%PDF-") {
404
+ return "application/pdf";
405
+ }
406
+ const head4 = ascii(bytes, 0, 4);
407
+ if (head4 === "II*\0" || head4 === "MM\0*") {
408
+ return "image/tiff";
409
+ }
410
+ return detectIsoBmff(bytes) ?? detectEbml(bytes) ?? detectBmp(bytes) ?? detectZip(bytes, options.tail);
411
+ }
412
+ function isClaimedContentTypeAllowed(accept, claimedType) {
413
+ const claimed = canonicalContentType(claimedType);
414
+ if (!claimed || claimed.includes("*")) {
415
+ return false;
416
+ }
417
+ return isExplicitOnlyContentType(claimed) ? isContentTypeListed(accept, claimed) : isMimeTypeAllowed(accept, claimed);
418
+ }
419
+ function acceptedContentType(bytes, accept, claimedType, options = {}) {
420
+ const detected = detectContentType(bytes, options);
421
+ if (detected) {
422
+ const allowed = isExplicitOnlyContentType(detected) ? isContentTypeListed(accept, detected) : isMimeTypeAllowed(accept, detected);
423
+ return allowed ? detected : null;
424
+ }
425
+ if (!claimedType) {
426
+ return null;
427
+ }
428
+ const claimed = canonicalContentType(claimedType);
429
+ if (DETECTABLE_CONTENT_TYPES.has(claimed)) {
430
+ return null;
431
+ }
432
+ return isClaimedContentTypeAllowed(accept, claimed) ? claimed : null;
433
+ }
434
+ export {
435
+ DETECTABLE_CONTENT_TYPES,
436
+ DETECT_CONTENT_TYPE_BYTES,
437
+ DETECT_CONTENT_TYPE_TAIL_BYTES,
438
+ MAX_ZIP_DIRECTORY_BYTES,
439
+ acceptedContentType,
440
+ detectContentType,
441
+ findZipCentralDirectory,
442
+ isClaimedContentTypeAllowed
443
+ };
@@ -47,6 +47,22 @@ export interface UploadPluginOptions {
47
47
  * origin, keeping in mind that the stored MIME type is client-asserted.
48
48
  */
49
49
  inlineTypes?: string[];
50
+ /**
51
+ * Checks each file's type by what its bytes are, using
52
+ * `acceptedContentType()`, instead of trusting the type the client
53
+ * claimed. On `POST {prefix}/fs`, a file the detector recognises is
54
+ * accepted when its detected type is in the profile's `allowedMimeTypes`;
55
+ * one it doesn't recognise is accepted on its claimed type, through a
56
+ * wildcard too, except that a claimed type the detector would have
57
+ * recognised is refused, and a type that can carry script (SVG, HTML,
58
+ * JavaScript, XML) must be listed exactly. Files are stored under the
59
+ * canonical spelling of the accepted type (`image/jpeg` for a claimed
60
+ * `image/jpg`). The S3 webhook checks objects' bytes against their
61
+ * declared type the same way `confirmUpload` does.
62
+ *
63
+ * Defaults to `true`. `false` checks the claimed type only.
64
+ */
65
+ verifyContentType?: boolean;
50
66
  }
51
67
  /**
52
68
  * Registers the upload routes on a Fastify server. Register it on the
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/fastify/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAA;AAE5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAgB9D,OAAO,KAAK,EAEV,cAAc,EACd,cAAc,EAEf,MAAM,aAAa,CAAA;AAIpB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AA4B1D,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/B,iCAAiC;IACjC,OAAO,EAAE,cAAc,CAAA;IACvB,EAAE,EAAE,cAAc,CAAA;IAClB,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAA;IAClC;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;QACnC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;KACrB,CAAA;IACD,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CACvB;AAgCD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CA8Qf"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/fastify/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAA;AAE5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAiB9D,OAAO,KAAK,EAEV,cAAc,EACd,cAAc,EAEf,MAAM,aAAa,CAAA;AAIpB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AA4B1D,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/B,iCAAiC;IACjC,OAAO,EAAE,cAAc,CAAA;IACvB,EAAE,EAAE,cAAc,CAAA;IAClB,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAA;IAClC;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;QACnC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;KACrB,CAAA;IACD,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB;;;;;;;;;;;;;;OAcG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AA+DD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CA2Rf"}
@@ -1,5 +1,6 @@
1
1
  import fastifyMultipart from "@fastify/multipart";
2
2
  import { deleteFile } from "../deleteFile.js";
3
+ import { acceptedContentType, detectContentType } from "../detectContentType.js";
3
4
  import { UploadError } from "../errors.js";
4
5
  import { generateStorageKey, trimTrailingSlashes } from "../keys.js";
5
6
  import {
@@ -43,6 +44,22 @@ function headerValue(req, name) {
43
44
  function normalizeMimeType(mimeType) {
44
45
  return mimeType.split(";")[0].trim().toLowerCase();
45
46
  }
47
+ function verifiedContentType(payload, data, claimedType) {
48
+ const mimeType = acceptedContentType(
49
+ data,
50
+ payload.allowedMimeTypes,
51
+ claimedType
52
+ );
53
+ if (mimeType) {
54
+ return mimeType;
55
+ }
56
+ const detected = detectContentType(data);
57
+ const what = detected ? `'${detected}'` : `of a type that couldn't be recognised (claimed '${claimedType}')`;
58
+ throw new UploadError(
59
+ "MIME_TYPE_NOT_ALLOWED",
60
+ `File is ${what}, which is not allowed for upload profile '${payload.profile}'.`
61
+ );
62
+ }
46
63
  function contentDispositionHeader(disposition, filename) {
47
64
  const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
48
65
  const encoded = encodeURIComponent(filename);
@@ -58,7 +75,8 @@ async function cedarUploadsPlugin(fastify, options) {
58
75
  authenticate,
59
76
  s3Webhook,
60
77
  serveCacheControl = "private, max-age=3600",
61
- inlineTypes = []
78
+ inlineTypes = [],
79
+ verifyContentType = true
62
80
  } = options;
63
81
  const allowedInlineTypes = new Set(inlineTypes.map(normalizeMimeType));
64
82
  if (!tokenSecret) {
@@ -120,7 +138,12 @@ async function cedarUploadsPlugin(fastify, options) {
120
138
  if (part.type !== "file") {
121
139
  continue;
122
140
  }
123
- assertTokenAllowsFile(payload, { contentType: part.mimetype, size: 0n });
141
+ if (!verifyContentType) {
142
+ assertTokenAllowsFile(payload, {
143
+ contentType: part.mimetype,
144
+ size: 0n
145
+ });
146
+ }
124
147
  let data;
125
148
  try {
126
149
  data = await part.toBuffer();
@@ -134,18 +157,19 @@ async function cedarUploadsPlugin(fastify, options) {
134
157
  throw e;
135
158
  }
136
159
  const size = BigInt(data.byteLength);
137
- assertTokenAllowsFile(payload, { contentType: part.mimetype, size });
138
- const storageKey = generateStorageKey(part.mimetype);
160
+ const mimeType = verifyContentType ? verifiedContentType(payload, data, part.mimetype) : part.mimetype;
161
+ assertTokenAllowsFile(payload, { contentType: mimeType, size });
162
+ const storageKey = generateStorageKey(mimeType);
139
163
  const upload = await createPendingUpload({
140
164
  db,
141
165
  payload,
142
166
  filename: part.filename,
143
- mimeType: part.mimetype,
167
+ mimeType,
144
168
  size,
145
169
  storageKey
146
170
  });
147
171
  stored.push({ target: payload.target, upload });
148
- await target.write(storageKey, data, { contentType: part.mimetype });
172
+ await target.write(storageKey, data, { contentType: mimeType });
149
173
  const { count } = await db.upload.updateMany({
150
174
  where: { id: upload.id, status: "pending" },
151
175
  data: { status: "completed" }
@@ -229,7 +253,8 @@ async function cedarUploadsPlugin(fastify, options) {
229
253
  targets,
230
254
  topicArn: s3Webhook.topicArn,
231
255
  fetchCertificate: s3Webhook.fetchCertificate,
232
- fetch: s3Webhook.fetch
256
+ fetch: s3Webhook.fetch,
257
+ verifyContentType
233
258
  });
234
259
  return reply.code(200).send(result);
235
260
  } catch (e) {
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ export type { UploadErrorCode } from './errors.js';
4
4
  export { defineStorageTargets, resolveTarget } from './targets.js';
5
5
  export { DB_MAX_FILE_SIZE, DEFAULT_UPLOAD_TOKEN_EXPIRES_IN, defineUploadProfiles, isMimeTypeAllowed, resolveProfile, } from './profiles.js';
6
6
  export type { ResolvedUploadProfile, UploadProfile, UploadProfiles, } from './profiles.js';
7
+ export { acceptedContentType, DETECT_CONTENT_TYPE_BYTES, DETECT_CONTENT_TYPE_TAIL_BYTES, DETECTABLE_CONTENT_TYPES, detectContentType, findZipCentralDirectory, isClaimedContentTypeAllowed, MAX_ZIP_DIRECTORY_BYTES, } from './detectContentType.js';
8
+ export type { DetectContentTypeOptions } from './detectContentType.js';
9
+ export { canonicalContentType, CONTENT_TYPE_ALIASES, isExplicitOnlyContentType, isScriptableContentType, MACRO_CONTENT_TYPES, SCRIPTABLE_CONTENT_TYPES, } from './contentTypes.js';
7
10
  export { createFsProvider } from './providers/fs.js';
8
11
  export type { FsProviderOptions } from './providers/fs.js';
9
12
  export { createDbProvider } from './providers/db.js';
@@ -28,7 +31,7 @@ export type { CreateUploadAuthenticatorOptions, UploadAuthenticator, UploadReque
28
31
  export { cedarUploadsPlugin } from './fastify/plugin.js';
29
32
  export type { UploadPluginOptions } from './fastify/plugin.js';
30
33
  export { handleS3Webhook, processS3EventRecord } from './webhooks/s3.js';
31
- export type { S3Event, S3EventOutcome, S3EventRecord, S3WebhookOptions, S3WebhookResult, } from './webhooks/s3.js';
34
+ export type { ProcessS3EventRecordOptions, S3Event, S3EventOutcome, S3EventRecord, S3WebhookOptions, S3WebhookResult, } from './webhooks/s3.js';
32
35
  export { verifySnsMessage } from './webhooks/sns.js';
33
36
  export type { SnsMessage } from './webhooks/sns.js';
34
37
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAClD,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAClE,OAAO,EACL,gBAAgB,EAChB,+BAA+B,EAC/B,oBAAoB,EACpB,iBAAiB,EACjB,cAAc,GACf,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,cAAc,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,YAAY,EACV,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,kBAAkB,CAAA;AACzB,YAAY,EACV,wBAAwB,EACxB,kBAAkB,GACnB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,oBAAoB,EACpB,0BAA0B,EAC1B,4BAA4B,EAC5B,uBAAuB,EACvB,uBAAuB,EACvB,WAAW,EACX,mBAAmB,GACpB,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,iCAAiC,EACjC,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EACV,kCAAkC,EAClC,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAA;AAC9D,YAAY,EACV,gCAAgC,EAChC,mBAAmB,EACnB,iBAAiB,GAClB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAC9D,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AACxE,YAAY,EACV,OAAO,EACP,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,eAAe,GAChB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -8,6 +8,24 @@ import {
8
8
  isMimeTypeAllowed,
9
9
  resolveProfile
10
10
  } from "./profiles.js";
11
+ import {
12
+ acceptedContentType,
13
+ DETECT_CONTENT_TYPE_BYTES,
14
+ DETECT_CONTENT_TYPE_TAIL_BYTES,
15
+ DETECTABLE_CONTENT_TYPES,
16
+ detectContentType,
17
+ findZipCentralDirectory,
18
+ isClaimedContentTypeAllowed,
19
+ MAX_ZIP_DIRECTORY_BYTES
20
+ } from "./detectContentType.js";
21
+ import {
22
+ canonicalContentType,
23
+ CONTENT_TYPE_ALIASES,
24
+ isExplicitOnlyContentType,
25
+ isScriptableContentType,
26
+ MACRO_CONTENT_TYPES,
27
+ SCRIPTABLE_CONTENT_TYPES
28
+ } from "./contentTypes.js";
11
29
  import { createFsProvider } from "./providers/fs.js";
12
30
  import { createDbProvider } from "./providers/db.js";
13
31
  import { generateStorageKey } from "./keys.js";
@@ -46,13 +64,22 @@ import { cedarUploadsPlugin } from "./fastify/plugin.js";
46
64
  import { handleS3Webhook, processS3EventRecord } from "./webhooks/s3.js";
47
65
  import { verifySnsMessage } from "./webhooks/sns.js";
48
66
  export {
67
+ CONTENT_TYPE_ALIASES,
49
68
  DB_MAX_FILE_SIZE,
50
69
  DEFAULT_UPLOAD_TOKEN_EXPIRES_IN,
70
+ DETECTABLE_CONTENT_TYPES,
71
+ DETECT_CONTENT_TYPE_BYTES,
72
+ DETECT_CONTENT_TYPE_TAIL_BYTES,
73
+ MACRO_CONTENT_TYPES,
74
+ MAX_ZIP_DIRECTORY_BYTES,
75
+ SCRIPTABLE_CONTENT_TYPES,
51
76
  SERVE_TOKEN_PURPOSE,
52
77
  UPLOAD_TOKEN_CONTEXT_KEY,
53
78
  UPLOAD_TOKEN_HEADER,
54
79
  UPLOAD_TOKEN_PURPOSE,
55
80
  UploadError,
81
+ acceptedContentType,
82
+ canonicalContentType,
56
83
  cedarUploadsPlugin,
57
84
  cleanupStaleUploads,
58
85
  confirmUpload,
@@ -68,10 +95,15 @@ export {
68
95
  defineStorageTargets,
69
96
  defineUploadProfiles,
70
97
  deleteFile,
98
+ detectContentType,
99
+ findZipCentralDirectory,
71
100
  generateStorageKey,
72
101
  getUploadTokenPayload,
73
102
  handleS3Webhook,
103
+ isClaimedContentTypeAllowed,
104
+ isExplicitOnlyContentType,
74
105
  isMimeTypeAllowed,
106
+ isScriptableContentType,
75
107
  issueUploadToken,
76
108
  processS3EventRecord,
77
109
  requireUploadTokenSchema,
package/dist/keys.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * A fresh storage key: a random id plus the extension that matches
3
3
  * `mimeType`, when one is known. Keys never derive from the client's
4
- * filename.
4
+ * filename. Aliased types are looked up under every spelling, so the
5
+ * registered `video/matroska` gets the `.mkv` the database only knows for
6
+ * `video/x-matroska`.
5
7
  */
6
8
  export declare function generateStorageKey(mimeType: string): string;
7
9
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAMA;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI3D;AAID;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,QAIxC;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQzD"}
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAOA;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM3D;AAID;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,QAIxC;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQzD"}
package/dist/keys.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import mime from "mime-types";
3
+ import { canonicalContentType, contentTypeSpellings } from "./contentTypes.js";
3
4
  import { UploadError } from "./errors.js";
4
5
  function generateStorageKey(mimeType) {
5
- const ext = mime.extension(mimeType.split(";")[0].trim());
6
+ const ext = contentTypeSpellings(canonicalContentType(mimeType)).map((type) => mime.extension(type)).find(Boolean);
6
7
  return ext ? `${randomUUID()}.${ext}` : randomUUID();
7
8
  }
8
9
  const SAFE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;