@cedarjs/uploads 7.0.1-next.314 → 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 (59) 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/web/createUppy.d.ts +19 -2
  45. package/dist/web/createUppy.d.ts.map +1 -1
  46. package/dist/web/createUppy.js +37 -15
  47. package/dist/web/hooks/useFsUpload.d.ts.map +1 -1
  48. package/dist/web/hooks/useFsUpload.js +21 -9
  49. package/dist/web/hooks/useS3Upload.d.ts.map +1 -1
  50. package/dist/web/hooks/useS3Upload.js +12 -5
  51. package/dist/web/hooks/useUploadToken.d.ts +27 -1
  52. package/dist/web/hooks/useUploadToken.d.ts.map +1 -1
  53. package/dist/web/hooks/useUploadToken.js +72 -3
  54. package/dist/web/hooks/useUppyUpload.d.ts.map +1 -1
  55. package/dist/web/hooks/useUppyUpload.js +2 -8
  56. package/dist/webhooks/s3.d.ts +17 -8
  57. package/dist/webhooks/s3.d.ts.map +1 -1
  58. package/dist/webhooks/s3.js +22 -3
  59. package/package.json +18 -18
@@ -12,7 +12,8 @@ function useFsUpload({
12
12
  onUploadComplete,
13
13
  onUploadError
14
14
  }) {
15
- const { requestToken, getToken, constraints } = useUploadToken({ profile });
15
+ const { requestToken, acquireToken, hasUsableToken, constraints } = useUploadToken({ profile });
16
+ const fileTokens = useRef(/* @__PURE__ */ new Map());
16
17
  const endpointRef = useRef(endpoint ?? defaultEndpoint());
17
18
  useEffect(() => {
18
19
  endpointRef.current = endpoint ?? defaultEndpoint();
@@ -22,7 +23,7 @@ function useFsUpload({
22
23
  provider: "fs",
23
24
  constraints,
24
25
  endpoint: () => endpointRef.current,
25
- getUploadToken: getToken
26
+ getUploadToken: (file) => fileTokens.current.get(file.id) ?? null
26
27
  }),
27
28
  constraints,
28
29
  { onUploadComplete, onUploadError }
@@ -33,24 +34,35 @@ function useFsUpload({
33
34
  return;
34
35
  }
35
36
  const onFileAdded = () => {
36
- if (!getToken()) {
37
+ if (!hasUsableToken()) {
37
38
  requestToken().catch((e) => {
38
39
  onUploadError?.(e instanceof Error ? e : new Error(String(e)));
39
40
  });
40
41
  }
41
42
  };
42
- const ensureToken = async () => {
43
- if (!getToken()) {
44
- await requestToken();
43
+ const assignTokens = async (fileIDs) => {
44
+ if (fileIDs.length === 0) {
45
+ return;
46
+ }
47
+ const token = await acquireToken(fileIDs.length);
48
+ for (const fileID of fileIDs) {
49
+ fileTokens.current.set(fileID, token);
50
+ }
51
+ };
52
+ const onFileRemoved = (file) => {
53
+ if (file) {
54
+ fileTokens.current.delete(file.id);
45
55
  }
46
56
  };
47
57
  uppy.on("file-added", onFileAdded);
48
- uppy.addPreProcessor(ensureToken);
58
+ uppy.on("file-removed", onFileRemoved);
59
+ uppy.addPreProcessor(assignTokens);
49
60
  return () => {
50
61
  uppy.off("file-added", onFileAdded);
51
- uppy.removePreProcessor(ensureToken);
62
+ uppy.off("file-removed", onFileRemoved);
63
+ uppy.removePreProcessor(assignTokens);
52
64
  };
53
- }, [uppy, getToken, requestToken, onUploadError]);
65
+ }, [uppy, acquireToken, hasUsableToken, requestToken, onUploadError]);
54
66
  return { ...upload, requestToken };
55
67
  }
56
68
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"useS3Upload.d.ts","sourceRoot":"","sources":["../../../src/web/hooks/useS3Upload.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAE/E,MAAM,WAAW,kBAAmB,SAAQ,mBAAmB;IAC7D,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,MAAM,iBAAiB,GAAG,gBAAgB,GAAG;IACjD,yEAAyE;IACzE,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;CACpC,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,OAAc,EACd,gBAAgB,EAChB,aAAa,GACd,EAAE,kBAAkB,GAAG,iBAAiB,CAwHxC"}
1
+ {"version":3,"file":"useS3Upload.d.ts","sourceRoot":"","sources":["../../../src/web/hooks/useS3Upload.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAE/E,MAAM,WAAW,kBAAmB,SAAQ,mBAAmB;IAC7D,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,MAAM,iBAAiB,GAAG,gBAAgB,GAAG;IACjD,yEAAyE;IACzE,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;CACpC,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,OAAc,EACd,gBAAgB,EAChB,aAAa,GACd,EAAE,kBAAkB,GAAG,iBAAiB,CAiIxC"}
@@ -10,12 +10,12 @@ function useS3Upload({
10
10
  onUploadComplete,
11
11
  onUploadError
12
12
  }) {
13
- const { requestToken, getToken, constraints } = useUploadToken({ profile });
13
+ const { requestToken, acquireToken, hasUsableToken, constraints } = useUploadToken({ profile });
14
14
  const [createPresignedUploadUrl] = useMutation(CREATE_PRESIGNED_UPLOAD_URL);
15
15
  const [confirmUpload] = useMutation(CONFIRM_UPLOAD);
16
16
  const getUploadParameters = useCallback(
17
17
  async (file) => {
18
- const uploadToken = getToken() ?? await requestToken();
18
+ const uploadToken = await acquireToken();
19
19
  const result = await createPresignedUploadUrl({
20
20
  variables: {
21
21
  input: {
@@ -32,7 +32,7 @@ function useS3Upload({
32
32
  }
33
33
  return data;
34
34
  },
35
- [createPresignedUploadUrl, getToken, requestToken]
35
+ [createPresignedUploadUrl, acquireToken]
36
36
  );
37
37
  const getUploadParametersRef = useRef(getUploadParameters);
38
38
  useEffect(() => {
@@ -53,7 +53,7 @@ function useS3Upload({
53
53
  return;
54
54
  }
55
55
  const onFileAdded = () => {
56
- if (!getToken()) {
56
+ if (!hasUsableToken()) {
57
57
  requestToken().catch((e) => {
58
58
  onUploadError?.(e instanceof Error ? e : new Error(String(e)));
59
59
  });
@@ -91,7 +91,14 @@ function useS3Upload({
91
91
  uppy.off("file-added", onFileAdded);
92
92
  uppy.removePostProcessor(confirmAll);
93
93
  };
94
- }, [uppy, confirm, confirmUpload, getToken, requestToken, onUploadError]);
94
+ }, [
95
+ uppy,
96
+ confirm,
97
+ confirmUpload,
98
+ hasUsableToken,
99
+ requestToken,
100
+ onUploadError
101
+ ]);
95
102
  return { ...upload, requestToken };
96
103
  }
97
104
  export {
@@ -10,6 +10,19 @@ export interface UseUploadTokenResult {
10
10
  * in-flight request.
11
11
  */
12
12
  requestToken: () => Promise<string>;
13
+ /**
14
+ * Resolves with a token that can still create `fileCount` files (default
15
+ * 1) and counts them against it. Reuses the current token while it has
16
+ * files left and is not about to expire, and fetches a fresh one
17
+ * otherwise. Call it once per file (or once per batch, with the batch
18
+ * size) right before the token is sent.
19
+ */
20
+ acquireToken: (fileCount?: number) => Promise<string>;
21
+ /**
22
+ * Whether the current token is unexpired and has at least one file left,
23
+ * so the next `acquireToken()` call can resolve without a request.
24
+ */
25
+ hasUsableToken: () => boolean;
13
26
  /**
14
27
  * Returns the most recently fetched token, or `null`, without triggering
15
28
  * a render. Meant for event handlers and Uppy callbacks.
@@ -25,10 +38,23 @@ export interface UseUploadTokenResult {
25
38
  loading: boolean;
26
39
  error: ErrorLike | undefined;
27
40
  }
41
+ /**
42
+ * Reads the lifetime (`exp - iat`, in milliseconds) from a signed token's
43
+ * claims without verifying it. The lifetime is anchored to the client's
44
+ * clock when the token is requested, so clock skew between browser and server
45
+ * does not matter. Returns `null` for a token whose claims cannot be read.
46
+ */
47
+ export declare function readTokenLifetimeMs(token: string): number | null;
28
48
  /**
29
49
  * Fetches upload tokens for a profile from the `requestUploadToken` query.
30
50
  * Tokens expire, so the query runs with `fetchPolicy: 'no-cache'` and only
31
- * when `requestToken()` is called, never on mount.
51
+ * when a token is needed, never on mount.
52
+ *
53
+ * A token expires and can only create `maxFiles` files, so the hook tracks
54
+ * both for the current token. `acquireToken()` hands the token out only
55
+ * while it has files left and is not about to expire, and fetches a fresh
56
+ * one otherwise, which lets one mounted component upload any number of
57
+ * times.
32
58
  */
33
59
  export declare function useUploadToken({ profile, }: UseUploadTokenOptions): UseUploadTokenResult;
34
60
  //# sourceMappingURL=useUploadToken.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useUploadToken.d.ts","sourceRoot":"","sources":["../../../src/web/hooks/useUploadToken.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAI/C,OAAO,KAAK,EAGV,iBAAiB,EAClB,MAAM,eAAe,CAAA;AAEtB,MAAM,WAAW,qBAAqB;IACpC,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACnC;;;OAGG;IACH,QAAQ,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IAC7B,kDAAkD;IAClD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;OAGG;IACH,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,SAAS,GAAG,SAAS,CAAA;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,GACR,EAAE,qBAAqB,GAAG,oBAAoB,CA4E9C"}
1
+ {"version":3,"file":"useUploadToken.d.ts","sourceRoot":"","sources":["../../../src/web/hooks/useUploadToken.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAI/C,OAAO,KAAK,EAGV,iBAAiB,EAClB,MAAM,eAAe,CAAA;AAEtB,MAAM,WAAW,qBAAqB;IACpC,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACnC;;;;;;OAMG;IACH,YAAY,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACrD;;;OAGG;IACH,cAAc,EAAE,MAAM,OAAO,CAAA;IAC7B;;;OAGG;IACH,QAAQ,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IAC7B,kDAAkD;IAClD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;OAGG;IACH,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,SAAS,GAAG,SAAS,CAAA;CAC7B;AA+BD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA0BhE;AA2BD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,GACR,EAAE,qBAAqB,GAAG,oBAAoB,CAoH9C"}
@@ -1,6 +1,45 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useLazyQuery } from "@apollo/client/react";
3
3
  import { REQUEST_UPLOAD_TOKEN } from "../graphql.js";
4
+ const EXPIRY_MARGIN_MS = 3e4;
5
+ function decodeBase64Url(value) {
6
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
7
+ const padded = base64.padEnd(
8
+ base64.length + (4 - base64.length % 4) % 4,
9
+ "="
10
+ );
11
+ return atob(padded);
12
+ }
13
+ function readTokenLifetimeMs(token) {
14
+ const [, claims] = token.split(".");
15
+ if (!claims) {
16
+ return null;
17
+ }
18
+ try {
19
+ const parsed = JSON.parse(decodeBase64Url(claims));
20
+ if (typeof parsed === "object" && parsed !== null && "exp" in parsed && "iat" in parsed && typeof parsed.exp === "number" && typeof parsed.iat === "number" && parsed.exp > parsed.iat) {
21
+ return (parsed.exp - parsed.iat) * 1e3;
22
+ }
23
+ } catch {
24
+ }
25
+ return null;
26
+ }
27
+ function expiresAtFor(token, requestedAt) {
28
+ const lifetime = readTokenLifetimeMs(token);
29
+ if (lifetime === null) {
30
+ return null;
31
+ }
32
+ return requestedAt + lifetime - Math.min(EXPIRY_MARGIN_MS, lifetime / 2);
33
+ }
34
+ function canCreate(state, fileCount) {
35
+ if (!state) {
36
+ return false;
37
+ }
38
+ if (state.expiresAt !== null && Date.now() >= state.expiresAt) {
39
+ return false;
40
+ }
41
+ return state.used === 0 || state.used + fileCount <= state.maxFiles;
42
+ }
4
43
  function useUploadToken({
5
44
  profile
6
45
  }) {
@@ -23,6 +62,7 @@ function useUploadToken({
23
62
  }
24
63
  const requestedProfile = profile;
25
64
  const request = (async () => {
65
+ const requestedAt = Date.now();
26
66
  const result = await execute({ variables: { profile: requestedProfile } });
27
67
  if (requestedProfile !== profileRef.current) {
28
68
  throw new Error(
@@ -36,7 +76,12 @@ function useUploadToken({
36
76
  if (!data) {
37
77
  throw new Error("The upload token query returned no data.");
38
78
  }
39
- tokenRef.current = data.token;
79
+ tokenRef.current = {
80
+ token: data.token,
81
+ maxFiles: data.maxFiles,
82
+ used: 0,
83
+ expiresAt: expiresAtFor(data.token, requestedAt)
84
+ };
40
85
  setToken(data.token);
41
86
  setConstraints({
42
87
  allowedMimeTypes: data.allowedMimeTypes,
@@ -52,9 +97,33 @@ function useUploadToken({
52
97
  inFlight.current = request;
53
98
  return request;
54
99
  }, [execute, profile]);
55
- const getToken = useCallback(() => tokenRef.current, []);
56
- return { requestToken, getToken, token, constraints, loading, error };
100
+ const acquireToken = useCallback(
101
+ async (fileCount = 1) => {
102
+ for (; ; ) {
103
+ const current = tokenRef.current;
104
+ if (current && canCreate(current, fileCount)) {
105
+ current.used += fileCount;
106
+ return current.token;
107
+ }
108
+ await requestToken();
109
+ }
110
+ },
111
+ [requestToken]
112
+ );
113
+ const hasUsableToken = useCallback(() => canCreate(tokenRef.current, 1), []);
114
+ const getToken = useCallback(() => tokenRef.current?.token ?? null, []);
115
+ return {
116
+ requestToken,
117
+ acquireToken,
118
+ hasUsableToken,
119
+ getToken,
120
+ token,
121
+ constraints,
122
+ loading,
123
+ error
124
+ };
57
125
  }
58
126
  export {
127
+ readTokenLifetimeMs,
59
128
  useUploadToken
60
129
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useUppyUpload.d.ts","sourceRoot":"","sources":["../../../src/web/hooks/useUppyUpload.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,kBAAkB,CAAA;AAChE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAEtD,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;CACvC;AAED,MAAM,WAAW,gBAAgB;IAC/B,8DAA8D;IAC9D,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;IACtB,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,WAAW,EAAE,OAAO,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,EAChC,WAAW,EAAE,iBAAiB,GAAG,IAAI,EACrC,EAAE,gBAAgB,EAAE,aAAa,EAAE,EAAE,mBAAmB,GACvD,gBAAgB,CAoGlB"}
1
+ {"version":3,"file":"useUppyUpload.d.ts","sourceRoot":"","sources":["../../../src/web/hooks/useUppyUpload.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,kBAAkB,CAAA;AAChE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAEtD,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;CACvC;AAED,MAAM,WAAW,gBAAgB;IAC/B,8DAA8D;IAC9D,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;IACtB,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,WAAW,EAAE,OAAO,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,EAChC,WAAW,EAAE,iBAAiB,GAAG,IAAI,EACrC,EAAE,gBAAgB,EAAE,aAAa,EAAE,EAAE,mBAAmB,GACvD,gBAAgB,CA8FlB"}
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { getCedarUploadId } from "../createUppy.js";
2
+ import { applyUploadConstraints, getCedarUploadId } from "../createUppy.js";
3
3
  function useUppyUpload(create, constraints, { onUploadComplete, onUploadError }) {
4
4
  const [uppy, setUppy] = useState(null);
5
5
  const [completedUploads, setCompletedUploads] = useState([]);
@@ -33,13 +33,7 @@ function useUppyUpload(create, constraints, { onUploadComplete, onUploadError })
33
33
  if (!uppy || !constraints) {
34
34
  return;
35
35
  }
36
- uppy.setOptions({
37
- restrictions: {
38
- allowedFileTypes: constraints.allowedMimeTypes,
39
- maxFileSize: constraints.maxFileSize,
40
- maxNumberOfFiles: constraints.maxFiles
41
- }
42
- });
36
+ applyUploadConstraints(uppy, constraints);
43
37
  }, [uppy, constraints]);
44
38
  useEffect(() => {
45
39
  if (!uppy) {
@@ -17,21 +17,30 @@ export interface S3Event {
17
17
  Records: S3EventRecord[];
18
18
  }
19
19
  export type S3EventOutcome = 'completed' | 'failed' | 'ignored' | 'not-found' | 'no-target';
20
+ export interface ProcessS3EventRecordOptions {
21
+ db: UploadDatabase;
22
+ targets: StorageTargets;
23
+ /**
24
+ * Checks the object's bytes against the type declared when the upload URL
25
+ * was issued, as `confirmUpload` does. Defaults to `true`.
26
+ */
27
+ verifyContentType?: boolean;
28
+ }
20
29
  /**
21
30
  * Settles the `pending` row for one `s3:ObjectCreated:*` record. The row is
22
31
  * matched on target plus storage key, the object's size is compared with
23
- * the size authorized at presign time, and the row is flipped `pending` to
24
- * `completed` (or claimed `failed` and the object deleted on a mismatch).
25
- * Both transitions are conditional, so a duplicate notification or a row
26
- * the cleanup job already claimed is left alone.
32
+ * the size authorized at presign time and, with `verifyContentType` (the
33
+ * default), its bytes with the type declared then, and the row is flipped
34
+ * `pending` to `completed` (or claimed `failed` and the object deleted on a
35
+ * mismatch). Both transitions are conditional, so a duplicate notification
36
+ * or a row the cleanup job already claimed is left alone.
27
37
  */
28
- export declare function processS3EventRecord(record: S3EventRecord, { db, targets }: {
29
- db: UploadDatabase;
30
- targets: StorageTargets;
31
- }): Promise<S3EventOutcome>;
38
+ export declare function processS3EventRecord(record: S3EventRecord, { db, targets, verifyContentType }: ProcessS3EventRecordOptions): Promise<S3EventOutcome>;
32
39
  export interface S3WebhookOptions {
33
40
  db: UploadDatabase;
34
41
  targets: StorageTargets;
42
+ /** See `ProcessS3EventRecordOptions`. Defaults to `true`. */
43
+ verifyContentType?: boolean;
35
44
  /** The trusted SNS topic. Messages from any other topic are rejected. */
36
45
  topicArn: string;
37
46
  fetchCertificate?: FetchCertificate;
@@ -1 +1 @@
1
- {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../../src/webhooks/s3.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAQjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAEhD,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,EAAE,EAAE;QACF,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;QACxB,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KACvC,CAAA;CACF;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,aAAa,EAAE,CAAA;CACzB;AAsBD,MAAM,MAAM,cAAc,GACxB,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,CAAA;AAkChE;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,aAAa,EACrB,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE;IAAE,EAAE,EAAE,cAAc,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAC/D,OAAO,CAAC,cAAc,CAAC,CAoDzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;IACvB,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC,iEAAiE;IACjE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,cAAc,GAAG,0BAA0B,GAAG,yBAAyB,CAAA;IAC7E,QAAQ,EAAE,cAAc,EAAE,CAAA;CAC3B;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,eAAe,CAAC,CA+D1B"}
1
+ {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../../src/webhooks/s3.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AASjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAEhD,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,EAAE,EAAE;QACF,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;QACxB,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KACvC,CAAA;CACF;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,aAAa,EAAE,CAAA;CACzB;AAsBD,MAAM,MAAM,cAAc,GACxB,WAAW,GAAG,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,CAAA;AAkChE,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;IACvB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,aAAa,EACrB,EAAE,EAAE,EAAE,OAAO,EAAE,iBAAwB,EAAE,EAAE,2BAA2B,GACrE,OAAO,CAAC,cAAc,CAAC,CA2EzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;IACvB,6DAA6D;IAC7D,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC,iEAAiE;IACjE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,cAAc,GAAG,0BAA0B,GAAG,yBAAyB,CAAA;IAC7E,QAAQ,EAAE,cAAc,EAAE,CAAA;CAC3B;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,eAAe,CAAC,CA+D1B"}
@@ -1,4 +1,5 @@
1
1
  import { UploadError } from "../errors.js";
2
+ import { verifyStoredContentType } from "../verifyStoredContentType.js";
2
3
  import {
3
4
  assertTrustedSnsUrl,
4
5
  isSnsMessage,
@@ -28,7 +29,7 @@ function matchTarget(targets, bucket, key) {
28
29
  }
29
30
  return best ? { name: best.name, storageKey: key.slice(best.keyPrefix.length) } : null;
30
31
  }
31
- async function processS3EventRecord(record, { db, targets }) {
32
+ async function processS3EventRecord(record, { db, targets, verifyContentType = true }) {
32
33
  if (!record.eventName.startsWith("ObjectCreated")) {
33
34
  return "ignored";
34
35
  }
@@ -51,7 +52,7 @@ async function processS3EventRecord(record, { db, targets }) {
51
52
  const target = targets[match.name];
52
53
  const reportedSize = record.s3.object.size;
53
54
  const actualSize = typeof reportedSize === "number" ? reportedSize : await target.getObjectSize(match.storageKey);
54
- if (actualSize === null || BigInt(actualSize) !== upload.size) {
55
+ const fail = async () => {
55
56
  const { count: count2 } = await db.upload.updateMany({
56
57
  where: { id: upload.id, status: "pending" },
57
58
  data: { status: "failed" }
@@ -60,10 +61,28 @@ async function processS3EventRecord(record, { db, targets }) {
60
61
  await target.delete(match.storageKey);
61
62
  }
62
63
  return "failed";
64
+ };
65
+ if (actualSize === null || BigInt(actualSize) !== upload.size) {
66
+ return fail();
67
+ }
68
+ let mimeType = upload.mimeType;
69
+ if (verifyContentType) {
70
+ const verified = await verifyStoredContentType(target, match.storageKey, {
71
+ size: actualSize,
72
+ declaredType: upload.mimeType
73
+ });
74
+ if (!verified) {
75
+ return fail();
76
+ }
77
+ mimeType = verified;
63
78
  }
64
79
  const { count } = await db.upload.updateMany({
65
80
  where: { id: upload.id, status: "pending" },
66
- data: { status: "completed", size: BigInt(actualSize) }
81
+ data: {
82
+ status: "completed",
83
+ size: BigInt(actualSize),
84
+ ...mimeType !== upload.mimeType ? { mimeType } : {}
85
+ }
67
86
  });
68
87
  return count === 1 ? "completed" : "ignored";
69
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/uploads",
3
- "version": "7.0.1-next.314",
3
+ "version": "7.0.1-next.466",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/cedarjs/cedar.git",
@@ -43,43 +43,43 @@
43
43
  "test:watch": "vitest watch"
44
44
  },
45
45
  "dependencies": {
46
- "@cedarjs/api": "7.0.1-next.314",
47
- "@cedarjs/context": "7.0.1-next.314",
48
- "@cedarjs/graphql-server": "7.0.1-next.314",
46
+ "@cedarjs/api": "7.0.1-next.466",
47
+ "@cedarjs/context": "7.0.1-next.466",
48
+ "@cedarjs/graphql-server": "7.0.1-next.466",
49
49
  "@fastify/multipart": "9.4.0",
50
50
  "graphql": "16.14.2",
51
51
  "mime-types": "2.1.35"
52
52
  },
53
53
  "devDependencies": {
54
- "@apollo/client": "4.3.1",
54
+ "@apollo/client": "4.3.3",
55
55
  "@arethetypeswrong/cli": "0.18.5",
56
- "@aws-sdk/client-s3": "3.1139.0",
57
- "@aws-sdk/s3-request-presigner": "3.1139.0",
58
- "@cedarjs/framework-tools": "7.0.1-next.314",
59
- "@cedarjs/web": "7.0.1-next.314",
56
+ "@aws-sdk/client-s3": "3.1149.0",
57
+ "@aws-sdk/s3-request-presigner": "3.1149.0",
58
+ "@cedarjs/framework-tools": "7.0.1-next.466",
59
+ "@cedarjs/web": "7.0.1-next.466",
60
60
  "@prisma/adapter-better-sqlite3": "7.10.0",
61
61
  "@prisma/client": "7.10.0",
62
62
  "@testing-library/dom": "10.4.2",
63
63
  "@testing-library/jest-dom": "6.9.1",
64
64
  "@testing-library/react": "16.3.3",
65
- "@types/aws-lambda": "8.10.163",
65
+ "@types/aws-lambda": "8.10.164",
66
66
  "@types/better-sqlite3": "7.6.13",
67
67
  "@types/mime-types": "2.1.4",
68
68
  "@types/react": "^19.2.0",
69
69
  "@types/react-dom": "^19.2.0",
70
- "@uppy/aws-s3": "6.1.0",
71
- "@uppy/core": "6.0.1",
72
- "@uppy/dashboard": "6.0.0",
70
+ "@uppy/aws-s3": "6.2.2",
71
+ "@uppy/core": "6.2.0",
72
+ "@uppy/dashboard": "6.0.1",
73
73
  "@uppy/drag-drop": "6.0.0",
74
74
  "@uppy/react": "6.0.0",
75
- "@uppy/xhr-upload": "6.0.0",
75
+ "@uppy/xhr-upload": "6.0.1",
76
76
  "better-sqlite3": "12.11.1",
77
- "concurrently": "9.2.4",
77
+ "concurrently": "9.2.5",
78
78
  "esbuild": "0.28.2",
79
79
  "fastify": "5.12.5",
80
80
  "jsdom": "27.4.0",
81
81
  "prisma": "7.10.0",
82
- "publint": "0.3.24",
82
+ "publint": "0.3.25",
83
83
  "react": "19.3.0",
84
84
  "react-dom": "19.3.0",
85
85
  "typescript": "5.9.3",
@@ -87,10 +87,10 @@
87
87
  "zx": "8.8.5"
88
88
  },
89
89
  "peerDependencies": {
90
- "@apollo/client": "4.3.1",
90
+ "@apollo/client": "4.3.3",
91
91
  "@aws-sdk/client-s3": "^3.0.0",
92
92
  "@aws-sdk/s3-request-presigner": "^3.0.0",
93
- "@cedarjs/web": "7.0.1-next.314",
93
+ "@cedarjs/web": "7.0.1-next.466",
94
94
  "@uppy/aws-s3": "^6.0.0",
95
95
  "@uppy/core": "^6.0.0",
96
96
  "@uppy/dashboard": "^6.0.0",