@cedarjs/uploads 7.0.1-next.333 → 7.0.1-next.466

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 (47) hide show
  1. package/README.md +11 -0
  2. package/dist/authenticator.d.ts +2 -2
  3. package/dist/authenticator.d.ts.map +1 -1
  4. package/dist/cleanupStaleUploads.d.ts +4 -3
  5. package/dist/cleanupStaleUploads.d.ts.map +1 -1
  6. package/dist/cleanupStaleUploads.js +8 -2
  7. package/dist/contentTypes.d.ts +74 -0
  8. package/dist/contentTypes.d.ts.map +1 -0
  9. package/dist/contentTypes.js +78 -0
  10. package/dist/deleteFile.d.ts +19 -4
  11. package/dist/deleteFile.d.ts.map +1 -1
  12. package/dist/deleteFile.js +49 -8
  13. package/dist/detectContentType.d.ts +168 -0
  14. package/dist/detectContentType.d.ts.map +1 -0
  15. package/dist/detectContentType.js +443 -0
  16. package/dist/errors.d.ts +1 -1
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +2 -1
  19. package/dist/fastify/plugin.d.ts +30 -2
  20. package/dist/fastify/plugin.d.ts.map +1 -1
  21. package/dist/fastify/plugin.js +38 -10
  22. package/dist/index.d.ts +6 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +35 -0
  25. package/dist/keys.d.ts +3 -1
  26. package/dist/keys.d.ts.map +1 -1
  27. package/dist/keys.js +2 -1
  28. package/dist/profiles.d.ts +8 -2
  29. package/dist/profiles.d.ts.map +1 -1
  30. package/dist/profiles.js +5 -4
  31. package/dist/providers/s3.d.ts.map +1 -1
  32. package/dist/providers/s3.js +16 -0
  33. package/dist/services.d.ts +24 -6
  34. package/dist/services.d.ts.map +1 -1
  35. package/dist/services.js +45 -10
  36. package/dist/types.d.ts +116 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/variants.d.ts +207 -0
  39. package/dist/variants.d.ts.map +1 -0
  40. package/dist/variants.js +498 -0
  41. package/dist/verifyStoredContentType.d.ts +19 -0
  42. package/dist/verifyStoredContentType.d.ts.map +1 -0
  43. package/dist/verifyStoredContentType.js +41 -0
  44. package/dist/webhooks/s3.d.ts +17 -8
  45. package/dist/webhooks/s3.d.ts.map +1 -1
  46. package/dist/webhooks/s3.js +22 -3
  47. package/package.json +18 -18
package/README.md CHANGED
@@ -14,6 +14,17 @@ File uploads and storage for CedarJS apps.
14
14
  uploads, and confirming them.
15
15
  - **Lifecycle utilities** (`storeFile`, `deleteFile`, `cleanupStaleUploads`) for
16
16
  server-generated files and explicit cleanup.
17
+ - **Derived files (variants)**, experimental: thumbnails, previews, a video's
18
+ poster frame. `defineUploadVariants()` takes one generator function per
19
+ variant name and keeps the bookkeeping: variants are `Upload` rows linked
20
+ to their source, made once on first read even under concurrency, failures
21
+ remembered with a retry-after, `regenerateVariants()` to remake one name,
22
+ and deletion with the source.
23
+ - **Content detection** (`detectContentType`, `acceptedContentType`) that
24
+ checks what an uploaded file really is from its bytes, not from its name or
25
+ the type the browser claimed: images, PDF, audio/video, and OpenDocument and
26
+ Office files. Uploads use it by default (`verifyContentType`), and store
27
+ each file under one canonical spelling of its type.
17
28
  - **Web hooks and components** at `@cedarjs/uploads/web`, built on Uppy.
18
29
 
19
30
  ```bash
@@ -1,5 +1,5 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
- import type { Decoder } from '@cedarjs/api';
2
+ import type { Decoded, Decoder } from '@cedarjs/api';
3
3
  /** The identity an upload route resolves for a request. */
4
4
  export interface UploadRequestUser {
5
5
  id: string;
@@ -13,7 +13,7 @@ export interface CreateUploadAuthenticatorOptions {
13
13
  * The app's `getCurrentUser`, as exported from `api/src/lib/auth`. It
14
14
  * receives the decoded session exactly as the GraphQL server passes it.
15
15
  */
16
- getCurrentUser: (decoded: unknown, raw: {
16
+ getCurrentUser: (decoded: Decoded, raw: {
17
17
  type: string;
18
18
  schema: string;
19
19
  token: string;
@@ -1 +1 @@
1
- {"version":3,"file":"authenticator.d.ts","sourceRoot":"","sources":["../src/authenticator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAG7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAE3C,2DAA2D;AAC3D,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAChC,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAA;AAEtC,MAAM,WAAW,gCAAgC;IAC/C,2EAA2E;IAC3E,WAAW,EAAE,OAAO,GAAG,OAAO,EAAE,CAAA;IAChC;;;OAGG;IACH,cAAc,EAAE,CACd,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EACpD,GAAG,EAAE;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,KACtC,OAAO,CAAC,OAAO,CAAC,CAAA;IACrB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAA;CACjE;AAkCD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,EACxC,WAAW,EACX,cAAc,EACd,iBAAyC,GAC1C,EAAE,gCAAgC,GAAG,mBAAmB,CA+BxD"}
1
+ {"version":3,"file":"authenticator.d.ts","sourceRoot":"","sources":["../src/authenticator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAG7C,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEpD,2DAA2D;AAC3D,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAChC,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAA;AAEtC,MAAM,WAAW,gCAAgC;IAC/C,2EAA2E;IAC3E,WAAW,EAAE,OAAO,GAAG,OAAO,EAAE,CAAA;IAChC;;;OAGG;IACH,cAAc,EAAE,CACd,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EACpD,GAAG,EAAE;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,KACtC,OAAO,CAAC,OAAO,CAAC,CAAA;IACrB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAA;CACjE;AAkCD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,EACxC,WAAW,EACX,cAAc,EACd,iBAAyC,GAC1C,EAAE,gCAAgC,GAAG,mBAAmB,CA+BxD"}
@@ -3,9 +3,10 @@ export interface CleanupStaleUploadsOptions {
3
3
  db: UploadDatabase;
4
4
  targets: StorageTargets;
5
5
  /**
6
- * Rows still `pending` after this many milliseconds are claimed as
7
- * `failed`. Defaults to one hour, well past the five-minute validity of a
8
- * presigned upload URL.
6
+ * Rows that have been `pending` and unchanged for this many milliseconds
7
+ * are claimed as `failed`. Measured from the row's last update, so a
8
+ * variant whose retry claimed an old row gets the full time. Defaults to
9
+ * one hour, well past the five-minute validity of a presigned upload URL.
9
10
  */
10
11
  olderThan?: number;
11
12
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"cleanupStaleUploads.d.ts","sourceRoot":"","sources":["../src/cleanupStaleUploads.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAK9E,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,KAAK,IAAI,CAAA;CACzD;AAED,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAA;IACf,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,mBAAmB,CAAC,EACxC,EAAE,EACF,OAAO,EACP,SAAoB,EACpB,WAAqB,EACrB,SAAe,EACf,OAC0E,GAC3E,EAAE,0BAA0B,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAmFjE"}
1
+ {"version":3,"file":"cleanupStaleUploads.d.ts","sourceRoot":"","sources":["../src/cleanupStaleUploads.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAK9E,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,KAAK,IAAI,CAAA;CACzD;AAED,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAA;IACf,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,mBAAmB,CAAC,EACxC,EAAE,EACF,OAAO,EACP,SAAoB,EACpB,WAAqB,EACrB,SAAe,EACf,OAC0E,GAC3E,EAAE,0BAA0B,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAyFjE"}
@@ -10,6 +10,7 @@ async function cleanupStaleUploads({
10
10
  onError = (error, upload) => console.error(`[cedar uploads] cleanup failed for ${upload.id}:`, error)
11
11
  }) {
12
12
  const now = Date.now();
13
+ const staleBefore = new Date(now - olderThan);
13
14
  let claimed = 0;
14
15
  let deleted = 0;
15
16
  let errors = 0;
@@ -36,14 +37,19 @@ async function cleanupStaleUploads({
36
37
  }
37
38
  };
38
39
  const stale = await db.upload.findMany({
39
- where: { status: "pending", createdAt: { lt: new Date(now - olderThan) } },
40
+ where: { status: "pending", updatedAt: { lt: staleBefore } },
40
41
  omit: { data: true },
41
42
  orderBy: { createdAt: "asc" },
42
43
  take: batchSize
43
44
  });
44
45
  for (const upload of stale) {
45
46
  const { count } = await db.upload.updateMany({
46
- where: { id: upload.id, status: "pending" },
47
+ // A row claimed again since it was read is not stale any more
48
+ where: {
49
+ id: upload.id,
50
+ status: "pending",
51
+ updatedAt: { lt: staleBefore }
52
+ },
47
53
  data: { status: "failed" }
48
54
  });
49
55
  if (count !== 1) {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Content-type spellings and classes shared by every place uploads compare
3
+ * types: profile `allowedMimeTypes`, `acceptedContentType()`'s accept list,
4
+ * and the type a file is stored under.
5
+ */
6
+ /**
7
+ * Other spellings of types `detectContentType()` returns, mapped to the
8
+ * spelling it returns (the canonical one). Comparisons treat both spellings
9
+ * as the same type, so an accept list naming either accepts the file, and
10
+ * files are stored under the canonical spelling.
11
+ *
12
+ * - `image/jpg`, `image/pjpeg`: common misspelling, and what old Internet
13
+ * Explorer sent for progressive JPEGs.
14
+ * - `image/x-png`: what old Internet Explorer sent for PNGs.
15
+ * - `image/x-ms-bmp`, `image/x-bmp`: what `file`/libmagic and older systems
16
+ * report for BMPs. `image/bmp` is the registered type (RFC 7903).
17
+ * - `application/x-pdf`: pre-registration PDF type some systems still send.
18
+ * - `video/x-matroska`: the type browsers and most tools report for `.mkv`.
19
+ * `video/matroska` is the registered one (RFC 9559).
20
+ * - `audio/x-m4a`: what browsers on macOS report for `.m4a`. `audio/mp4` is
21
+ * the registered type (RFC 4337).
22
+ * - `video/x-m4v`: what browsers on macOS report for `.m4v`, an MP4 video.
23
+ */
24
+ export declare const CONTENT_TYPE_ALIASES: ReadonlyMap<string, string>;
25
+ /**
26
+ * Types a browser runs script in, or renders as a document that can run
27
+ * script, when it is served from the app's origin. `acceptedContentType()`
28
+ * never accepts one of these through a wildcard: an accept list has to name
29
+ * it exactly. Every XML type (`text/xml`, `application/xml`, and anything
30
+ * with a `+xml` suffix, such as `image/svg+xml` or `application/xhtml+xml`)
31
+ * counts too, whether or not it is listed here.
32
+ */
33
+ export declare const SCRIPTABLE_CONTENT_TYPES: ReadonlySet<string>;
34
+ /**
35
+ * Office types that can carry macros. Macros run in Office on the machine of
36
+ * whoever downloads the file, so accepting one is a way to deliver malware
37
+ * to the app's other users, not an XSS risk, and a profile meant for
38
+ * "documents" almost never means files with macros. `acceptedContentType()`
39
+ * accepts these only when an accept list names them exactly, never through
40
+ * a wildcard such as `application/*` or `*\/*`, whether the type was
41
+ * detected from the bytes or claimed for bytes it doesn't recognise.
42
+ *
43
+ * The macro-enabled Office Open XML types (documents, templates, add-ins and
44
+ * slide shows, and the binary `.xlsb` workbook), and the legacy binary
45
+ * `.doc`, `.xls` and `.ppt` types, which can carry VBA too and which
46
+ * detection doesn't recognise. Lower case, like every canonical type.
47
+ */
48
+ export declare const MACRO_CONTENT_TYPES: ReadonlySet<string>;
49
+ /** `type/subtype`, lower-cased, without parameters such as `charset`. */
50
+ export declare function normalizeContentType(contentType: string): string;
51
+ /**
52
+ * The canonical spelling of `contentType`: normalized (lower-cased, without
53
+ * parameters), with aliases from `CONTENT_TYPE_ALIASES` resolved.
54
+ */
55
+ export declare function canonicalContentType(contentType: string): string;
56
+ /** Every spelling of a canonical type: itself first, then its aliases. */
57
+ export declare function contentTypeSpellings(canonical: string): string[];
58
+ /**
59
+ * Whether a browser could run script in `contentType` served inline: one of
60
+ * `SCRIPTABLE_CONTENT_TYPES`, or any XML type.
61
+ */
62
+ export declare function isScriptableContentType(contentType: string): boolean;
63
+ /**
64
+ * Whether `contentType` is only accepted when listed exactly, never through a
65
+ * wildcard: a type that can carry script (`isScriptableContentType()`) or
66
+ * macros (`MACRO_CONTENT_TYPES`).
67
+ */
68
+ export declare function isExplicitOnlyContentType(contentType: string): boolean;
69
+ /**
70
+ * Whether `accept` names `contentType` exactly, not through a wildcard.
71
+ * Aliases count as the same type.
72
+ */
73
+ export declare function isContentTypeListed(accept: string[], contentType: string): boolean;
74
+ //# sourceMappingURL=contentTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contentTypes.d.ts","sourceRoot":"","sources":["../src/contentTypes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAU3D,CAAA;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,EAAE,WAAW,CAAC,MAAM,CAYvD,CAAA;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAelD,CAAA;AAEF,yEAAyE;AACzE,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAIhE;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAUhE;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAIpE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAKtE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EAAE,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAIT"}
@@ -0,0 +1,78 @@
1
+ const CONTENT_TYPE_ALIASES = /* @__PURE__ */ new Map([
2
+ ["image/jpg", "image/jpeg"],
3
+ ["image/pjpeg", "image/jpeg"],
4
+ ["image/x-png", "image/png"],
5
+ ["image/x-ms-bmp", "image/bmp"],
6
+ ["image/x-bmp", "image/bmp"],
7
+ ["application/x-pdf", "application/pdf"],
8
+ ["video/x-matroska", "video/matroska"],
9
+ ["audio/x-m4a", "audio/mp4"],
10
+ ["video/x-m4v", "video/mp4"]
11
+ ]);
12
+ const SCRIPTABLE_CONTENT_TYPES = /* @__PURE__ */ new Set([
13
+ "image/svg+xml",
14
+ "text/html",
15
+ "application/xhtml+xml",
16
+ "text/xml",
17
+ "application/xml",
18
+ "text/xsl",
19
+ "text/javascript",
20
+ "application/javascript",
21
+ "application/x-javascript",
22
+ "text/ecmascript",
23
+ "application/ecmascript"
24
+ ]);
25
+ const MACRO_CONTENT_TYPES = /* @__PURE__ */ new Set([
26
+ "application/vnd.ms-word.document.macroenabled.12",
27
+ "application/vnd.ms-word.template.macroenabled.12",
28
+ "application/vnd.ms-excel.sheet.macroenabled.12",
29
+ "application/vnd.ms-excel.template.macroenabled.12",
30
+ "application/vnd.ms-excel.addin.macroenabled.12",
31
+ "application/vnd.ms-excel.sheet.binary.macroenabled.12",
32
+ "application/vnd.ms-powerpoint.presentation.macroenabled.12",
33
+ "application/vnd.ms-powerpoint.template.macroenabled.12",
34
+ "application/vnd.ms-powerpoint.slideshow.macroenabled.12",
35
+ "application/vnd.ms-powerpoint.addin.macroenabled.12",
36
+ "application/vnd.ms-powerpoint.slide.macroenabled.12",
37
+ "application/msword",
38
+ "application/vnd.ms-excel",
39
+ "application/vnd.ms-powerpoint"
40
+ ]);
41
+ function normalizeContentType(contentType) {
42
+ return contentType.split(";")[0].trim().toLowerCase();
43
+ }
44
+ function canonicalContentType(contentType) {
45
+ const normalized = normalizeContentType(contentType);
46
+ return CONTENT_TYPE_ALIASES.get(normalized) ?? normalized;
47
+ }
48
+ function contentTypeSpellings(canonical) {
49
+ const spellings = [canonical];
50
+ for (const [alias, target] of CONTENT_TYPE_ALIASES) {
51
+ if (target === canonical) {
52
+ spellings.push(alias);
53
+ }
54
+ }
55
+ return spellings;
56
+ }
57
+ function isScriptableContentType(contentType) {
58
+ const type = canonicalContentType(contentType);
59
+ return SCRIPTABLE_CONTENT_TYPES.has(type) || type.endsWith("+xml");
60
+ }
61
+ function isExplicitOnlyContentType(contentType) {
62
+ return isScriptableContentType(contentType) || MACRO_CONTENT_TYPES.has(canonicalContentType(contentType));
63
+ }
64
+ function isContentTypeListed(accept, contentType) {
65
+ const type = canonicalContentType(contentType);
66
+ return accept.some((entry) => canonicalContentType(entry) === type);
67
+ }
68
+ export {
69
+ CONTENT_TYPE_ALIASES,
70
+ MACRO_CONTENT_TYPES,
71
+ SCRIPTABLE_CONTENT_TYPES,
72
+ canonicalContentType,
73
+ contentTypeSpellings,
74
+ isContentTypeListed,
75
+ isExplicitOnlyContentType,
76
+ isScriptableContentType,
77
+ normalizeContentType
78
+ };
@@ -1,14 +1,29 @@
1
- import type { StorageProvider, UploadDatabase, UploadRecord } from './types.js';
2
- export interface DeleteFileOptions {
1
+ import type { StorageProvider, StorageTargets, UploadDatabase, UploadRecord, UploadVariantDatabase } from './types.js';
2
+ export type DeleteFileOptions = {
3
3
  db: UploadDatabase;
4
4
  upload: Pick<UploadRecord, 'id' | 'storageKey'>;
5
- }
5
+ targets?: undefined;
6
+ } | {
7
+ db: UploadDatabase & UploadVariantDatabase;
8
+ upload: Pick<UploadRecord, 'id' | 'storageKey'>;
9
+ /**
10
+ * Every configured storage target. When given, the upload's derived
11
+ * files (variants: thumbnails, previews) are deleted first, from
12
+ * whichever targets hold them. Required for an upload that has
13
+ * variants.
14
+ */
15
+ targets: StorageTargets;
16
+ };
6
17
  /**
7
18
  * Deletes a stored file: bytes first, then the `Upload` row. Bytes-first
8
19
  * ordering means a crash between the two steps leaves a row whose object is
9
20
  * gone, which the app can see and fix by calling again, whereas the reverse
10
21
  * order would leave an unreachable object nothing references. A missing
11
22
  * object or row is tolerated, so the call is idempotent.
23
+ *
24
+ * Pass `targets` to delete the upload's derived files (variants) with it.
25
+ * Without it, deleting an upload that has variants throws an `UploadError`
26
+ * with code `HAS_VARIANTS`, because the variant rows reference their source.
12
27
  */
13
- export declare function deleteFile(target: StorageProvider, { db, upload }: DeleteFileOptions): Promise<void>;
28
+ export declare function deleteFile(target: StorageProvider, options: DeleteFileOptions): Promise<void>;
14
29
  //# sourceMappingURL=deleteFile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"deleteFile.d.ts","sourceRoot":"","sources":["../src/deleteFile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE/E,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,cAAc,CAAA;IAClB,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,YAAY,CAAC,CAAA;CAChD;AAQD;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,eAAe,EACvB,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,iBAAiB,GAChC,OAAO,CAAC,IAAI,CAAC,CAYf"}
1
+ {"version":3,"file":"deleteFile.d.ts","sourceRoot":"","sources":["../src/deleteFile.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,eAAe,EACf,cAAc,EACd,cAAc,EACd,YAAY,EACZ,qBAAqB,EACtB,MAAM,YAAY,CAAA;AAGnB,MAAM,MAAM,iBAAiB,GACzB;IACE,EAAE,EAAE,cAAc,CAAA;IAClB,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,YAAY,CAAC,CAAA;IAC/C,OAAO,CAAC,EAAE,SAAS,CAAA;CACpB,GACD;IACE,EAAE,EAAE,cAAc,GAAG,qBAAqB,CAAA;IAC1C,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,YAAY,CAAC,CAAA;IAC/C;;;;;OAKG;IACH,OAAO,EAAE,cAAc,CAAA;CACxB,CAAA;AA6BL;;;;;;;;;;GAUG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,eAAe,EACvB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC,CA+Cf"}
@@ -1,15 +1,56 @@
1
- function isRecordNotFound(e) {
2
- return typeof e === "object" && e !== null && "code" in e && e.code === "P2025";
1
+ import { UploadError } from "./errors.js";
2
+ import { deleteVariants } from "./variants.js";
3
+ const MAX_VARIANT_ROUNDS = 3;
4
+ function isPrismaError(e, code) {
5
+ return typeof e === "object" && e !== null && "code" in e && e.code === code;
3
6
  }
4
- async function deleteFile(target, { db, upload }) {
7
+ function hasVariantRows(db, id) {
8
+ return db.upload.count({ where: { sourceId: id } }).then(
9
+ (count) => count > 0,
10
+ () => false
11
+ );
12
+ }
13
+ function hasVariantsError(id, cause) {
14
+ return new UploadError(
15
+ "HAS_VARIANTS",
16
+ `Upload '${id}' has derived files (variants), so its row cannot be deleted on its own. Pass \`targets\` to deleteFile() to delete the variants with it, or call deleteVariants() first.`,
17
+ cause
18
+ );
19
+ }
20
+ async function deleteFile(target, options) {
21
+ const { upload } = options;
22
+ if (!options.targets && await hasVariantRows(options.db, upload.id)) {
23
+ throw hasVariantsError(upload.id);
24
+ }
5
25
  if (upload.storageKey) {
6
26
  await target.delete(upload.storageKey);
7
27
  }
8
- try {
9
- await db.upload.delete({ where: { id: upload.id } });
10
- } catch (e) {
11
- if (!isRecordNotFound(e)) {
12
- throw e;
28
+ for (let round = 1; ; round++) {
29
+ if (options.targets) {
30
+ await deleteVariants(upload, {
31
+ db: options.db,
32
+ targets: options.targets
33
+ });
34
+ }
35
+ try {
36
+ await options.db.upload.delete({ where: { id: upload.id } });
37
+ return;
38
+ } catch (e) {
39
+ if (isPrismaError(e, "P2025")) {
40
+ return;
41
+ }
42
+ if (!isPrismaError(e, "P2003")) {
43
+ throw e;
44
+ }
45
+ if (!options.targets) {
46
+ if (!await hasVariantRows(options.db, upload.id)) {
47
+ throw e;
48
+ }
49
+ throw hasVariantsError(upload.id, e);
50
+ }
51
+ if (round >= MAX_VARIANT_ROUNDS) {
52
+ throw e;
53
+ }
13
54
  }
14
55
  }
15
56
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * How many bytes from the start of a file `detectContentType()` needs to
3
+ * recognise every format it knows. Pass at least this much when reading only
4
+ * the head of a large file, e.g. with a ranged GET. Fewer bytes still work for
5
+ * most formats. An ISO-BMFF file (MP4, HEIC…) whose type depends on a
6
+ * compatible brand needs its whole `ftyp` box, and is reported as `null`
7
+ * when the box is cut off. Office files (`.docx`, `.xlsx`, `.pptx`) also
8
+ * need the end of the file; see `DETECT_CONTENT_TYPE_TAIL_BYTES`.
9
+ */
10
+ export declare const DETECT_CONTENT_TYPE_BYTES = 4096;
11
+ /**
12
+ * How many bytes from the end of a file to pass as `tail` to
13
+ * `detectContentType()` when `bytes` is only the head of the file. Only ZIP
14
+ * files (head starts with `PK\x03\x04`) need it: telling a `.docx` from a
15
+ * `.docm` or any other ZIP means reading the archive's central directory,
16
+ * which is at the end.
17
+ *
18
+ * The end-of-central-directory record is 22 bytes, plus an archive comment
19
+ * that office files don't have. Each directory entry is 46 bytes plus its
20
+ * name (and an extra field some tools add), about 100 bytes for a typical
21
+ * office part, so 64 KiB holds a directory of 600 or so entries: every
22
+ * ordinary document and workbook, and slide decks of a few hundred slides.
23
+ * A directory that doesn't fit is reported as `null`, never guessed; use
24
+ * `findZipCentralDirectory()` to read the rest of it.
25
+ */
26
+ export declare const DETECT_CONTENT_TYPE_TAIL_BYTES: number;
27
+ /**
28
+ * The largest ZIP central directory `detectContentType()` reads: 4 MiB, some
29
+ * 40,000 entries, far beyond any real office file. A ZIP with a bigger
30
+ * directory is reported as `null`, so a crafted archive can't make detection
31
+ * walk (or a caller read) an unbounded directory.
32
+ */
33
+ export declare const MAX_ZIP_DIRECTORY_BYTES: number;
34
+ export interface DetectContentTypeOptions {
35
+ /**
36
+ * The last bytes of the file, when `bytes` is only its head (for example
37
+ * from two ranged reads of an object in storage). Without it, the end of
38
+ * `bytes` is taken as the end of the file, which is right when `bytes` is
39
+ * the whole file. Read `DETECT_CONTENT_TYPE_TAIL_BYTES`, or the whole file
40
+ * when it is smaller. Only ZIP-based formats use it.
41
+ */
42
+ tail?: Uint8Array;
43
+ }
44
+ /**
45
+ * Every MIME type `detectContentType()` can return, in its canonical
46
+ * spelling. A claimed type in this set (or an alias of one) is one the bytes
47
+ * can confirm or refute, which is what `acceptedContentType()` relies on.
48
+ */
49
+ export declare const DETECTABLE_CONTENT_TYPES: ReadonlySet<string>;
50
+ /**
51
+ * Where a ZIP's central directory is, from the start of the file, read from
52
+ * the end-of-central-directory record at the very end of `tail` (the last
53
+ * bytes of the file). `null` when `tail` has no such record describing a
54
+ * non-empty directory, or the archive is one `detectContentType()` won't
55
+ * read (ZIP64, multi-disk, or a directory over `MAX_ZIP_DIRECTORY_BYTES`).
56
+ *
57
+ * For callers that read a large file in ranges: when `detectContentType(head,
58
+ * { tail })` returns `null` for a ZIP because the directory starts before the
59
+ * tail, read the bytes from `offset` up to where the tail starts, and pass
60
+ * them and the tail joined together as the new `tail`.
61
+ */
62
+ export declare function findZipCentralDirectory(tail: Uint8Array): {
63
+ offset: number;
64
+ size: number;
65
+ } | null;
66
+ /**
67
+ * Tells what kind of file `bytes` is from its contents (its magic bytes), not
68
+ * from a filename or the type a browser claimed. This is content sniffing, in
69
+ * the sense of the WHATWG MIME Sniffing standard, limited to binary formats
70
+ * with unambiguous signatures.
71
+ *
72
+ * Use it wherever a file's type matters for safety: before handing a user
73
+ * upload to an image or video decoder, before serving it inline, before
74
+ * trusting an `accept` list. A file's claimed type and extension are chosen
75
+ * by whoever sent it.
76
+ *
77
+ * Returns a MIME type, or `null` when the format isn't one this function
78
+ * recognises. It never throws and never reads past the end of its input, so
79
+ * truncated or garbage input returns `null`. Pass the whole file, or its
80
+ * first `DETECT_CONTENT_TYPE_BYTES` bytes plus, for a ZIP, its last
81
+ * `DETECT_CONTENT_TYPE_TAIL_BYTES` as `tail`. Without the tail, a `.docx`,
82
+ * `.xlsx` or `.pptx` whose head is all there is returns `null`.
83
+ *
84
+ * Recognised: JPEG, PNG, GIF, WebP, BMP, TIFF, AVIF, HEIC/HEIF (stills and
85
+ * sequences), PDF, MP4 (video, and `audio/mp4` for `.m4a`), QuickTime/MOV,
86
+ * WebM, Matroska, OpenDocument (`.odt`, `.ods`, `.odp`) and Office Open XML
87
+ * (`.docx`, `.xlsx`, `.pptx`, and their macro-enabled `.docm`, `.xlsm`,
88
+ * `.pptm`, which are reported as their own types). MP4, MOV, HEIC and AVIF
89
+ * share the ISO-BMFF `ftyp` box and are told apart by its brands; WebM and
90
+ * Matroska share the EBML header and are told apart by its `DocType`. WebM
91
+ * and Matroska are always reported as video, because the header doesn't say
92
+ * whether there is a video track.
93
+ *
94
+ * Returned types are the canonical spellings (`image/jpeg`, `video/matroska`,
95
+ * `audio/mp4`); see `CONTENT_TYPE_ALIASES` for the others.
96
+ *
97
+ * Not recognised, deliberately: SVG and other text formats (an SVG is XML that
98
+ * can carry script, so it must never pass as a safe image, and text has no
99
+ * reliable signature), ZIPs that aren't one of the office formats above, and
100
+ * legacy binary Office files (`.doc`, `.xls`). See `DETECTABLE_CONTENT_TYPES`
101
+ * for the full list of possible results.
102
+ *
103
+ * @example
104
+ * import { detectContentType } from '@cedarjs/uploads'
105
+ *
106
+ * const type = detectContentType(bytes)
107
+ *
108
+ * if (type !== 'image/jpeg' && type !== 'image/png') {
109
+ * throw new Error('Only JPEG and PNG images can be thumbnailed')
110
+ * }
111
+ *
112
+ * @example
113
+ * // A large object in storage: its head, and its tail when it is a ZIP
114
+ * const type = detectContentType(head, { tail })
115
+ */
116
+ export declare function detectContentType(bytes: Uint8Array, options?: DetectContentTypeOptions): string | null;
117
+ /**
118
+ * Whether a claimed type may be accepted before the file's bytes are seen,
119
+ * as when issuing a presigned upload URL: allowed by `accept`, not a
120
+ * wildcard, and, for a type that can carry script or macros, listed exactly.
121
+ * The bytes still have to be checked once they arrive.
122
+ */
123
+ export declare function isClaimedContentTypeAllowed(accept: string[], claimedType: string): boolean;
124
+ /**
125
+ * Decides whether a file is one of the `accept`ed types by what its bytes
126
+ * are, and returns the content type to record for it, or `null` when the
127
+ * file is not accepted. `accept` takes the same entries as an upload
128
+ * profile's `allowedMimeTypes`: exact types and `type/*` wildcards, with
129
+ * either spelling of an aliased type (`CONTENT_TYPE_ALIASES`).
130
+ *
131
+ * - When `detectContentType()` recognises the bytes, the detected type must
132
+ * match `accept`, and is what's returned. The claimed type doesn't matter,
133
+ * so a HEIC photo a browser sent as `application/octet-stream` is accepted
134
+ * as `image/heic`. A macro-enabled Office file (`MACRO_CONTENT_TYPES`)
135
+ * must be listed exactly: `['application/*']` never admits a `.docm`.
136
+ * - When it doesn't, the file is accepted on the strength of `claimedType`
137
+ * when that matches `accept`, wildcards included, except:
138
+ * - a claimed type in `DETECTABLE_CONTENT_TYPES` is refused: a real PNG
139
+ * would have been recognised, so the claim is false;
140
+ * - a type that can carry script (`SCRIPTABLE_CONTENT_TYPES`, and any XML
141
+ * type) or macros (`MACRO_CONTENT_TYPES`, which includes the legacy
142
+ * `.doc`, `.xls` and `.ppt` types) must be listed exactly. `['image/*']`
143
+ * never admits an SVG, `['text/*']` never admits HTML, and `['*\/*']`
144
+ * never admits a `.doc`.
145
+ *
146
+ * No claimed type means not accepted.
147
+ *
148
+ * The returned type is canonical: lower-cased, without parameters, and with
149
+ * aliases resolved, so a JPEG claimed as `image/jpg` is stored as
150
+ * `image/jpeg`.
151
+ *
152
+ * Pass the whole file, or its head with `options.tail` (see
153
+ * `detectContentType()`). An office file checked by its head alone isn't
154
+ * recognised, and its claimed type is then refused as false.
155
+ *
156
+ * @example
157
+ * import { acceptedContentType, storeFile } from '@cedarjs/uploads'
158
+ *
159
+ * const mimeType = acceptedContentType(data, ['image/*'], input.mimeType)
160
+ *
161
+ * if (!mimeType) {
162
+ * throw new Error('That file is not an image')
163
+ * }
164
+ *
165
+ * await storeFile(target, { db, filename, mimeType, data })
166
+ */
167
+ export declare function acceptedContentType(bytes: Uint8Array, accept: string[], claimedType?: string, options?: DetectContentTypeOptions): string | null;
168
+ //# sourceMappingURL=detectContentType.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detectContentType.d.ts","sourceRoot":"","sources":["../src/detectContentType.ts"],"names":[],"mappings":"AAOA;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,OAAO,CAAA;AAE7C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,8BAA8B,QAAY,CAAA;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAkB,CAAA;AAEtD,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAA;CAClB;AAgBD;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,WAAW,CAAC,MAAM,CA2BvD,CAAA;AA+eF;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,UAAU,GACf;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAUzC;AAoHD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,UAAU,EACjB,OAAO,GAAE,wBAA6B,GACrC,MAAM,GAAG,IAAI,CAmCf;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,EAAE,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAUT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,MAAM,EAAE,EAChB,WAAW,CAAC,EAAE,MAAM,EACpB,OAAO,GAAE,wBAA6B,GACrC,MAAM,GAAG,IAAI,CAyBf"}