@cedarjs/uploads 7.0.0-canary.3114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/authenticator.d.ts +38 -0
- package/dist/authenticator.d.ts.map +1 -0
- package/dist/authenticator.js +51 -0
- package/dist/cleanupStaleUploads.d.ts +50 -0
- package/dist/cleanupStaleUploads.d.ts.map +1 -0
- package/dist/cleanupStaleUploads.js +80 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +4 -0
- package/dist/deleteFile.d.ts +14 -0
- package/dist/deleteFile.d.ts.map +1 -0
- package/dist/deleteFile.js +18 -0
- package/dist/directives.d.ts +55 -0
- package/dist/directives.d.ts.map +1 -0
- package/dist/directives.js +174 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +33 -0
- package/dist/fastify/plugin.d.ts +49 -0
- package/dist/fastify/plugin.d.ts.map +1 -0
- package/dist/fastify/plugin.js +241 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/keys.d.ts +18 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +25 -0
- package/dist/pending.d.ts +39 -0
- package/dist/pending.d.ts.map +1 -0
- package/dist/pending.js +94 -0
- package/dist/profiles.d.ts +49 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +68 -0
- package/dist/providers/db.d.ts +9 -0
- package/dist/providers/db.d.ts.map +1 -0
- package/dist/providers/db.js +41 -0
- package/dist/providers/fs.d.ts +34 -0
- package/dist/providers/fs.d.ts.map +1 -0
- package/dist/providers/fs.js +125 -0
- package/dist/providers/s3.d.ts +25 -0
- package/dist/providers/s3.d.ts.map +1 -0
- package/dist/providers/s3.js +117 -0
- package/dist/s3.d.ts +3 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +4 -0
- package/dist/serialize.d.ts +10 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +22 -0
- package/dist/serveToken.d.ts +36 -0
- package/dist/serveToken.d.ts.map +1 -0
- package/dist/serveToken.js +45 -0
- package/dist/services.d.ts +70 -0
- package/dist/services.d.ts.map +1 -0
- package/dist/services.js +141 -0
- package/dist/storeFile.d.ts +27 -0
- package/dist/storeFile.d.ts.map +1 -0
- package/dist/storeFile.js +53 -0
- package/dist/targets.d.ts +14 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +21 -0
- package/dist/types.d.ts +212 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/uploadLoader.d.ts +11 -0
- package/dist/uploadLoader.d.ts.map +1 -0
- package/dist/uploadLoader.js +47 -0
- package/dist/uploadToken.d.ts +43 -0
- package/dist/uploadToken.d.ts.map +1 -0
- package/dist/uploadToken.js +61 -0
- package/dist/web/components/DbInput.d.ts +18 -0
- package/dist/web/components/DbInput.d.ts.map +1 -0
- package/dist/web/components/DbInput.js +46 -0
- package/dist/web/components/FsUploader.d.ts +6 -0
- package/dist/web/components/FsUploader.d.ts.map +1 -0
- package/dist/web/components/FsUploader.js +21 -0
- package/dist/web/components/S3Uploader.d.ts +6 -0
- package/dist/web/components/S3Uploader.d.ts.map +1 -0
- package/dist/web/components/S3Uploader.js +21 -0
- package/dist/web/components/UppyUploader.d.ts +23 -0
- package/dist/web/components/UppyUploader.d.ts.map +1 -0
- package/dist/web/components/UppyUploader.js +72 -0
- package/dist/web/createUppy.d.ts +62 -0
- package/dist/web/createUppy.d.ts.map +1 -0
- package/dist/web/createUppy.js +82 -0
- package/dist/web/graphql.d.ts +48 -0
- package/dist/web/graphql.d.ts.map +1 -0
- package/dist/web/graphql.js +36 -0
- package/dist/web/hooks/useDbUpload.d.ts +31 -0
- package/dist/web/hooks/useDbUpload.d.ts.map +1 -0
- package/dist/web/hooks/useDbUpload.js +80 -0
- package/dist/web/hooks/useFsUpload.d.ts +20 -0
- package/dist/web/hooks/useFsUpload.d.ts.map +1 -0
- package/dist/web/hooks/useFsUpload.js +58 -0
- package/dist/web/hooks/useS3Upload.d.ts +23 -0
- package/dist/web/hooks/useS3Upload.d.ts.map +1 -0
- package/dist/web/hooks/useS3Upload.js +99 -0
- package/dist/web/hooks/useUploadToken.d.ts +34 -0
- package/dist/web/hooks/useUploadToken.d.ts.map +1 -0
- package/dist/web/hooks/useUploadToken.js +60 -0
- package/dist/web/hooks/useUppyUpload.d.ts +23 -0
- package/dist/web/hooks/useUppyUpload.d.ts.map +1 -0
- package/dist/web/hooks/useUppyUpload.js +76 -0
- package/dist/web/index.d.ts +22 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +36 -0
- package/dist/webhooks/s3.d.ts +52 -0
- package/dist/webhooks/s3.d.ts.map +1 -0
- package/dist/webhooks/s3.js +122 -0
- package/dist/webhooks/sns.d.ts +34 -0
- package/dist/webhooks/sns.d.ts.map +1 -0
- package/dist/webhooks/sns.js +131 -0
- package/package.json +147 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { createUppy, DEFAULT_FS_UPLOAD_PATH } from "../createUppy.js";
|
|
3
|
+
import { useUploadToken } from "./useUploadToken.js";
|
|
4
|
+
import { useUppyUpload } from "./useUppyUpload.js";
|
|
5
|
+
function defaultEndpoint() {
|
|
6
|
+
const apiUrl = typeof RWJS_API_URL === "string" ? RWJS_API_URL.replace(/\/+$/, "") : "";
|
|
7
|
+
return `${apiUrl}${DEFAULT_FS_UPLOAD_PATH}`;
|
|
8
|
+
}
|
|
9
|
+
function useFsUpload({
|
|
10
|
+
profile,
|
|
11
|
+
endpoint,
|
|
12
|
+
onUploadComplete,
|
|
13
|
+
onUploadError
|
|
14
|
+
}) {
|
|
15
|
+
const { requestToken, getToken, constraints } = useUploadToken({ profile });
|
|
16
|
+
const endpointRef = useRef(endpoint ?? defaultEndpoint());
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
endpointRef.current = endpoint ?? defaultEndpoint();
|
|
19
|
+
}, [endpoint]);
|
|
20
|
+
const upload = useUppyUpload(
|
|
21
|
+
() => createUppy({
|
|
22
|
+
provider: "fs",
|
|
23
|
+
constraints,
|
|
24
|
+
endpoint: () => endpointRef.current,
|
|
25
|
+
getUploadToken: getToken
|
|
26
|
+
}),
|
|
27
|
+
constraints,
|
|
28
|
+
{ onUploadComplete, onUploadError }
|
|
29
|
+
);
|
|
30
|
+
const { uppy } = upload;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!uppy) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const onFileAdded = () => {
|
|
36
|
+
if (!getToken()) {
|
|
37
|
+
requestToken().catch((e) => {
|
|
38
|
+
onUploadError?.(e instanceof Error ? e : new Error(String(e)));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const ensureToken = async () => {
|
|
43
|
+
if (!getToken()) {
|
|
44
|
+
await requestToken();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
uppy.on("file-added", onFileAdded);
|
|
48
|
+
uppy.addPreProcessor(ensureToken);
|
|
49
|
+
return () => {
|
|
50
|
+
uppy.off("file-added", onFileAdded);
|
|
51
|
+
uppy.removePreProcessor(ensureToken);
|
|
52
|
+
};
|
|
53
|
+
}, [uppy, getToken, requestToken, onUploadError]);
|
|
54
|
+
return { ...upload, requestToken };
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
useFsUpload
|
|
58
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { UppyUploadCallbacks, UppyUploadResult } from './useUppyUpload.js';
|
|
2
|
+
export interface UseS3UploadOptions extends UppyUploadCallbacks {
|
|
3
|
+
/** Name of a server-defined upload profile. */
|
|
4
|
+
profile: string;
|
|
5
|
+
/**
|
|
6
|
+
* Confirm each upload through the `confirmUpload` mutation once the PUT
|
|
7
|
+
* succeeds, before the batch is reported complete. Defaults to `true`.
|
|
8
|
+
* Turn it off when an S3 event webhook confirms uploads instead.
|
|
9
|
+
*/
|
|
10
|
+
confirm?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type UseS3UploadResult = UppyUploadResult & {
|
|
13
|
+
/** Fetches a fresh upload token. Called automatically before uploads. */
|
|
14
|
+
requestToken: () => Promise<string>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Direct-to-S3 uploads: fetches an upload token, asks the api for a
|
|
18
|
+
* presigned URL per file, PUTs the bytes with `@uppy/aws-s3`, and confirms
|
|
19
|
+
* each upload before the batch counts as complete. The returned `uppy`
|
|
20
|
+
* instance plugs into any Uppy UI.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useS3Upload({ profile, confirm, onUploadComplete, onUploadError, }: UseS3UploadOptions): UseS3UploadResult;
|
|
23
|
+
//# sourceMappingURL=useS3Upload.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useMutation } from "@apollo/client/react";
|
|
3
|
+
import { createUppy, UPLOAD_TOKEN_HEADER } from "../createUppy.js";
|
|
4
|
+
import { CONFIRM_UPLOAD, CREATE_PRESIGNED_UPLOAD_URL } from "../graphql.js";
|
|
5
|
+
import { useUploadToken } from "./useUploadToken.js";
|
|
6
|
+
import { useUppyUpload } from "./useUppyUpload.js";
|
|
7
|
+
function useS3Upload({
|
|
8
|
+
profile,
|
|
9
|
+
confirm = true,
|
|
10
|
+
onUploadComplete,
|
|
11
|
+
onUploadError
|
|
12
|
+
}) {
|
|
13
|
+
const { requestToken, getToken, constraints } = useUploadToken({ profile });
|
|
14
|
+
const [createPresignedUploadUrl] = useMutation(CREATE_PRESIGNED_UPLOAD_URL);
|
|
15
|
+
const [confirmUpload] = useMutation(CONFIRM_UPLOAD);
|
|
16
|
+
const getUploadParameters = useCallback(
|
|
17
|
+
async (file) => {
|
|
18
|
+
const uploadToken = getToken() ?? await requestToken();
|
|
19
|
+
const result = await createPresignedUploadUrl({
|
|
20
|
+
variables: {
|
|
21
|
+
input: {
|
|
22
|
+
filename: file.name ?? "file",
|
|
23
|
+
contentType: file.type || "application/octet-stream",
|
|
24
|
+
size: String(file.size ?? 0)
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
context: { headers: { [UPLOAD_TOKEN_HEADER]: uploadToken } }
|
|
28
|
+
});
|
|
29
|
+
const data = result.data?.createPresignedUploadUrl;
|
|
30
|
+
if (!data) {
|
|
31
|
+
throw new Error("The presigned URL mutation returned no data.");
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
},
|
|
35
|
+
[createPresignedUploadUrl, getToken, requestToken]
|
|
36
|
+
);
|
|
37
|
+
const getUploadParametersRef = useRef(getUploadParameters);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
getUploadParametersRef.current = getUploadParameters;
|
|
40
|
+
}, [getUploadParameters]);
|
|
41
|
+
const upload = useUppyUpload(
|
|
42
|
+
() => createUppy({
|
|
43
|
+
provider: "s3",
|
|
44
|
+
constraints,
|
|
45
|
+
getUploadParameters: (file) => getUploadParametersRef.current(file)
|
|
46
|
+
}),
|
|
47
|
+
constraints,
|
|
48
|
+
{ onUploadComplete, onUploadError }
|
|
49
|
+
);
|
|
50
|
+
const { uppy } = upload;
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!uppy) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const onFileAdded = () => {
|
|
56
|
+
if (!getToken()) {
|
|
57
|
+
requestToken().catch((e) => {
|
|
58
|
+
onUploadError?.(e instanceof Error ? e : new Error(String(e)));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const confirmAll = async (fileIDs) => {
|
|
63
|
+
if (!confirm) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const fileID of fileIDs) {
|
|
67
|
+
const file = uppy.getFile(fileID);
|
|
68
|
+
const uploadId = file?.meta.cedarUploadId;
|
|
69
|
+
if (!file || typeof uploadId !== "string") {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const result = await confirmUpload({ variables: { uploadId } });
|
|
74
|
+
if (result.data?.confirmUpload.status !== "completed") {
|
|
75
|
+
throw new Error(`Upload ${uploadId} could not be confirmed.`);
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
79
|
+
uppy.setFileMeta(fileID, { cedarUploadId: void 0 });
|
|
80
|
+
uppy.setFileState(fileID, {
|
|
81
|
+
error: error.message,
|
|
82
|
+
response: void 0
|
|
83
|
+
});
|
|
84
|
+
uppy.emit("upload-error", file, error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
uppy.on("file-added", onFileAdded);
|
|
89
|
+
uppy.addPostProcessor(confirmAll);
|
|
90
|
+
return () => {
|
|
91
|
+
uppy.off("file-added", onFileAdded);
|
|
92
|
+
uppy.removePostProcessor(confirmAll);
|
|
93
|
+
};
|
|
94
|
+
}, [uppy, confirm, confirmUpload, getToken, requestToken, onUploadError]);
|
|
95
|
+
return { ...upload, requestToken };
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
useS3Upload
|
|
99
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ErrorLike } from '@apollo/client';
|
|
2
|
+
import type { UploadConstraints } from '../graphql.js';
|
|
3
|
+
export interface UseUploadTokenOptions {
|
|
4
|
+
/** Name of a server-defined upload profile. */
|
|
5
|
+
profile: string;
|
|
6
|
+
}
|
|
7
|
+
export interface UseUploadTokenResult {
|
|
8
|
+
/**
|
|
9
|
+
* Fetches a fresh token and resolves with it. Concurrent calls share one
|
|
10
|
+
* in-flight request.
|
|
11
|
+
*/
|
|
12
|
+
requestToken: () => Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Returns the most recently fetched token, or `null`, without triggering
|
|
15
|
+
* a render. Meant for event handlers and Uppy callbacks.
|
|
16
|
+
*/
|
|
17
|
+
getToken: () => string | null;
|
|
18
|
+
/** The most recently fetched token, or `null`. */
|
|
19
|
+
token: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The profile's constraints, echoed by the server alongside the token for
|
|
22
|
+
* client-side UX. `null` until the first successful fetch.
|
|
23
|
+
*/
|
|
24
|
+
constraints: UploadConstraints | null;
|
|
25
|
+
loading: boolean;
|
|
26
|
+
error: ErrorLike | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fetches upload tokens for a profile from the `requestUploadToken` query.
|
|
30
|
+
* Tokens expire, so the query runs with `fetchPolicy: 'no-cache'` and only
|
|
31
|
+
* when `requestToken()` is called, never on mount.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useUploadToken({ profile, }: UseUploadTokenOptions): UseUploadTokenResult;
|
|
34
|
+
//# sourceMappingURL=useUploadToken.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useLazyQuery } from "@apollo/client/react";
|
|
3
|
+
import { REQUEST_UPLOAD_TOKEN } from "../graphql.js";
|
|
4
|
+
function useUploadToken({
|
|
5
|
+
profile
|
|
6
|
+
}) {
|
|
7
|
+
const [token, setToken] = useState(null);
|
|
8
|
+
const [constraints, setConstraints] = useState(null);
|
|
9
|
+
const tokenRef = useRef(null);
|
|
10
|
+
const inFlight = useRef(null);
|
|
11
|
+
const profileRef = useRef(profile);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
profileRef.current = profile;
|
|
14
|
+
tokenRef.current = null;
|
|
15
|
+
inFlight.current = null;
|
|
16
|
+
setToken(null);
|
|
17
|
+
setConstraints(null);
|
|
18
|
+
}, [profile]);
|
|
19
|
+
const [execute, { loading, error }] = useLazyQuery(REQUEST_UPLOAD_TOKEN, { fetchPolicy: "no-cache" });
|
|
20
|
+
const requestToken = useCallback(() => {
|
|
21
|
+
if (inFlight.current) {
|
|
22
|
+
return inFlight.current;
|
|
23
|
+
}
|
|
24
|
+
const requestedProfile = profile;
|
|
25
|
+
const request = (async () => {
|
|
26
|
+
const result = await execute({ variables: { profile: requestedProfile } });
|
|
27
|
+
if (requestedProfile !== profileRef.current) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"The upload profile changed while a token was requested."
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (result.error) {
|
|
33
|
+
throw result.error instanceof Error ? result.error : new Error(result.error.message);
|
|
34
|
+
}
|
|
35
|
+
const data = result.data?.requestUploadToken;
|
|
36
|
+
if (!data) {
|
|
37
|
+
throw new Error("The upload token query returned no data.");
|
|
38
|
+
}
|
|
39
|
+
tokenRef.current = data.token;
|
|
40
|
+
setToken(data.token);
|
|
41
|
+
setConstraints({
|
|
42
|
+
allowedMimeTypes: data.allowedMimeTypes,
|
|
43
|
+
maxFileSize: Number(data.maxFileSize),
|
|
44
|
+
maxFiles: data.maxFiles
|
|
45
|
+
});
|
|
46
|
+
return data.token;
|
|
47
|
+
})().finally(() => {
|
|
48
|
+
if (inFlight.current === request) {
|
|
49
|
+
inFlight.current = null;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
inFlight.current = request;
|
|
53
|
+
return request;
|
|
54
|
+
}, [execute, profile]);
|
|
55
|
+
const getToken = useCallback(() => tokenRef.current, []);
|
|
56
|
+
return { requestToken, getToken, token, constraints, loading, error };
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
useUploadToken
|
|
60
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CedarUppy } from '../createUppy.js';
|
|
2
|
+
import type { UploadConstraints } from '../graphql.js';
|
|
3
|
+
export interface UppyUploadCallbacks {
|
|
4
|
+
/** Called with the `Upload` ids of every file once a batch finishes. */
|
|
5
|
+
onUploadComplete?: (uploadIds: string[]) => void;
|
|
6
|
+
onUploadError?: (error: Error) => void;
|
|
7
|
+
}
|
|
8
|
+
export interface UppyUploadResult {
|
|
9
|
+
/** The Uppy instance, or `null` while it is being created. */
|
|
10
|
+
uppy: CedarUppy | null;
|
|
11
|
+
/** `Upload` ids of every file that finished, across batches. */
|
|
12
|
+
completedUploads: string[];
|
|
13
|
+
isUploading: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Shared plumbing for `useS3Upload` and `useFsUpload`: creates the Uppy
|
|
17
|
+
* instance once per mount, re-applies restrictions when the token's
|
|
18
|
+
* constraints arrive, tracks completion, and destroys the instance on
|
|
19
|
+
* unmount. `create` runs once, so anything it closes over (an endpoint, a
|
|
20
|
+
* presign callback) must read changing values through refs.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useUppyUpload(create: () => Promise<CedarUppy>, constraints: UploadConstraints | null, { onUploadComplete, onUploadError }: UppyUploadCallbacks): UppyUploadResult;
|
|
23
|
+
//# sourceMappingURL=useUppyUpload.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { getCedarUploadId } from "../createUppy.js";
|
|
3
|
+
function useUppyUpload(create, constraints, { onUploadComplete, onUploadError }) {
|
|
4
|
+
const [uppy, setUppy] = useState(null);
|
|
5
|
+
const [completedUploads, setCompletedUploads] = useState([]);
|
|
6
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
7
|
+
const callbacks = useRef({ onUploadComplete, onUploadError });
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
callbacks.current = { onUploadComplete, onUploadError };
|
|
10
|
+
}, [onUploadComplete, onUploadError]);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let instance = null;
|
|
13
|
+
let cancelled = false;
|
|
14
|
+
create().then((created) => {
|
|
15
|
+
if (cancelled) {
|
|
16
|
+
created.destroy();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
instance = created;
|
|
20
|
+
setUppy(created);
|
|
21
|
+
}).catch((e) => {
|
|
22
|
+
callbacks.current.onUploadError?.(
|
|
23
|
+
e instanceof Error ? e : new Error(String(e))
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
cancelled = true;
|
|
28
|
+
instance?.destroy();
|
|
29
|
+
setUppy(null);
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!uppy || !constraints) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
uppy.setOptions({
|
|
37
|
+
restrictions: {
|
|
38
|
+
allowedFileTypes: constraints.allowedMimeTypes,
|
|
39
|
+
maxFileSize: constraints.maxFileSize,
|
|
40
|
+
maxNumberOfFiles: constraints.maxFiles
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}, [uppy, constraints]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!uppy) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const onStart = () => setIsUploading(true);
|
|
49
|
+
const onComplete = (result) => {
|
|
50
|
+
setIsUploading(false);
|
|
51
|
+
const ids = (result.successful ?? []).map((file) => getCedarUploadId(uppy.getFile(file.id) ?? file)).filter((id) => Boolean(id));
|
|
52
|
+
if (ids.length > 0) {
|
|
53
|
+
setCompletedUploads((prev) => [...prev, ...ids]);
|
|
54
|
+
callbacks.current.onUploadComplete?.(ids);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const onError = (_file, error) => {
|
|
58
|
+
setIsUploading(false);
|
|
59
|
+
callbacks.current.onUploadError?.(
|
|
60
|
+
error instanceof Error ? error : new Error(error.message)
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
uppy.on("upload-start", onStart);
|
|
64
|
+
uppy.on("complete", onComplete);
|
|
65
|
+
uppy.on("upload-error", onError);
|
|
66
|
+
return () => {
|
|
67
|
+
uppy.off("upload-start", onStart);
|
|
68
|
+
uppy.off("complete", onComplete);
|
|
69
|
+
uppy.off("upload-error", onError);
|
|
70
|
+
};
|
|
71
|
+
}, [uppy]);
|
|
72
|
+
return { uppy, completedUploads, isUploading };
|
|
73
|
+
}
|
|
74
|
+
export {
|
|
75
|
+
useUppyUpload
|
|
76
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { createUppy, DEFAULT_FS_UPLOAD_PATH, getCedarUploadId, UPLOAD_TOKEN_HEADER, } from './createUppy.js';
|
|
2
|
+
export type { BaseUppyOptions, CedarUppy, CedarUppyFile, CreateUppyOptions, FsUploadResponseBody, FsUppyOptions, PresignedUploadParameters, S3UppyOptions, } from './createUppy.js';
|
|
3
|
+
export { CONFIRM_UPLOAD, CREATE_PRESIGNED_UPLOAD_URL, REQUEST_UPLOAD_TOKEN, } from './graphql.js';
|
|
4
|
+
export type { ConfirmUploadData, ConfirmUploadVariables, CreatePresignedUploadUrlData, CreatePresignedUploadUrlVariables, RequestUploadTokenData, RequestUploadTokenVariables, UploadConstraints, } from './graphql.js';
|
|
5
|
+
export { useUploadToken } from './hooks/useUploadToken.js';
|
|
6
|
+
export type { UseUploadTokenOptions, UseUploadTokenResult, } from './hooks/useUploadToken.js';
|
|
7
|
+
export { useS3Upload } from './hooks/useS3Upload.js';
|
|
8
|
+
export type { UseS3UploadOptions, UseS3UploadResult, } from './hooks/useS3Upload.js';
|
|
9
|
+
export { useFsUpload } from './hooks/useFsUpload.js';
|
|
10
|
+
export type { UseFsUploadOptions, UseFsUploadResult, } from './hooks/useFsUpload.js';
|
|
11
|
+
export { useDbUpload } from './hooks/useDbUpload.js';
|
|
12
|
+
export type { DbUploadFile, UseDbUploadOptions, UseDbUploadResult, } from './hooks/useDbUpload.js';
|
|
13
|
+
export type { UppyUploadCallbacks, UppyUploadResult, } from './hooks/useUppyUpload.js';
|
|
14
|
+
export { UppyUploader } from './components/UppyUploader.js';
|
|
15
|
+
export type { UploaderVariant, UppyUploaderProps, } from './components/UppyUploader.js';
|
|
16
|
+
export { S3Uploader } from './components/S3Uploader.js';
|
|
17
|
+
export type { S3UploaderProps } from './components/S3Uploader.js';
|
|
18
|
+
export { FsUploader } from './components/FsUploader.js';
|
|
19
|
+
export type { FsUploaderProps } from './components/FsUploader.js';
|
|
20
|
+
export { DbInput } from './components/DbInput.js';
|
|
21
|
+
export type { DbInputProps } from './components/DbInput.js';
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,sBAAsB,EACtB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EACV,eAAe,EACf,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,EACb,yBAAyB,EACzB,aAAa,GACd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,cAAc,EACd,2BAA2B,EAC3B,oBAAoB,GACrB,MAAM,cAAc,CAAA;AACrB,YAAY,EACV,iBAAiB,EACjB,sBAAsB,EACtB,4BAA4B,EAC5B,iCAAiC,EACjC,sBAAsB,EACtB,2BAA2B,EAC3B,iBAAiB,GAClB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAC1D,YAAY,EACV,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACpD,YAAY,EACV,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACpD,YAAY,EACV,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACpD,YAAY,EACV,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wBAAwB,CAAA;AAC/B,YAAY,EACV,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAC3D,YAAY,EACV,eAAe,EACf,iBAAiB,GAClB,MAAM,8BAA8B,CAAA;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AACvD,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AACjE,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AACvD,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AACjE,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACjD,YAAY,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createUppy,
|
|
3
|
+
DEFAULT_FS_UPLOAD_PATH,
|
|
4
|
+
getCedarUploadId,
|
|
5
|
+
UPLOAD_TOKEN_HEADER
|
|
6
|
+
} from "./createUppy.js";
|
|
7
|
+
import {
|
|
8
|
+
CONFIRM_UPLOAD,
|
|
9
|
+
CREATE_PRESIGNED_UPLOAD_URL,
|
|
10
|
+
REQUEST_UPLOAD_TOKEN
|
|
11
|
+
} from "./graphql.js";
|
|
12
|
+
import { useUploadToken } from "./hooks/useUploadToken.js";
|
|
13
|
+
import { useS3Upload } from "./hooks/useS3Upload.js";
|
|
14
|
+
import { useFsUpload } from "./hooks/useFsUpload.js";
|
|
15
|
+
import { useDbUpload } from "./hooks/useDbUpload.js";
|
|
16
|
+
import { UppyUploader } from "./components/UppyUploader.js";
|
|
17
|
+
import { S3Uploader } from "./components/S3Uploader.js";
|
|
18
|
+
import { FsUploader } from "./components/FsUploader.js";
|
|
19
|
+
import { DbInput } from "./components/DbInput.js";
|
|
20
|
+
export {
|
|
21
|
+
CONFIRM_UPLOAD,
|
|
22
|
+
CREATE_PRESIGNED_UPLOAD_URL,
|
|
23
|
+
DEFAULT_FS_UPLOAD_PATH,
|
|
24
|
+
DbInput,
|
|
25
|
+
FsUploader,
|
|
26
|
+
REQUEST_UPLOAD_TOKEN,
|
|
27
|
+
S3Uploader,
|
|
28
|
+
UPLOAD_TOKEN_HEADER,
|
|
29
|
+
UppyUploader,
|
|
30
|
+
createUppy,
|
|
31
|
+
getCedarUploadId,
|
|
32
|
+
useDbUpload,
|
|
33
|
+
useFsUpload,
|
|
34
|
+
useS3Upload,
|
|
35
|
+
useUploadToken
|
|
36
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { StorageTargets, UploadDatabase } from '../types.js';
|
|
2
|
+
import type { FetchCertificate } from './sns.js';
|
|
3
|
+
/** The parts of an S3 event notification record this handler reads. */
|
|
4
|
+
export interface S3EventRecord {
|
|
5
|
+
eventName: string;
|
|
6
|
+
s3: {
|
|
7
|
+
bucket: {
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
10
|
+
object: {
|
|
11
|
+
key: string;
|
|
12
|
+
size?: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface S3Event {
|
|
17
|
+
Records: S3EventRecord[];
|
|
18
|
+
}
|
|
19
|
+
export type S3EventOutcome = 'completed' | 'failed' | 'ignored' | 'not-found' | 'no-target';
|
|
20
|
+
/**
|
|
21
|
+
* Settles the `pending` row for one `s3:ObjectCreated:*` record. The row is
|
|
22
|
+
* 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.
|
|
27
|
+
*/
|
|
28
|
+
export declare function processS3EventRecord(record: S3EventRecord, { db, targets }: {
|
|
29
|
+
db: UploadDatabase;
|
|
30
|
+
targets: StorageTargets;
|
|
31
|
+
}): Promise<S3EventOutcome>;
|
|
32
|
+
export interface S3WebhookOptions {
|
|
33
|
+
db: UploadDatabase;
|
|
34
|
+
targets: StorageTargets;
|
|
35
|
+
/** The trusted SNS topic. Messages from any other topic are rejected. */
|
|
36
|
+
topicArn: string;
|
|
37
|
+
fetchCertificate?: FetchCertificate;
|
|
38
|
+
/** Used to confirm subscriptions. Defaults to global `fetch`. */
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
}
|
|
41
|
+
export interface S3WebhookResult {
|
|
42
|
+
type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation';
|
|
43
|
+
outcomes: S3EventOutcome[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Handles one SNS delivery carrying S3 event notifications: verifies the
|
|
47
|
+
* signature and topic, confirms subscriptions, and settles every
|
|
48
|
+
* `ObjectCreated` record. Throws an `UploadError` for anything that must be
|
|
49
|
+
* rejected; the plugin turns that into the HTTP status.
|
|
50
|
+
*/
|
|
51
|
+
export declare function handleS3Webhook(rawBody: string, options: S3WebhookOptions): Promise<S3WebhookResult>;
|
|
52
|
+
//# sourceMappingURL=s3.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { UploadError } from "../errors.js";
|
|
2
|
+
import {
|
|
3
|
+
assertTrustedSnsUrl,
|
|
4
|
+
isSnsMessage,
|
|
5
|
+
SNS_FETCH_TIMEOUT_MS,
|
|
6
|
+
verifySnsMessage
|
|
7
|
+
} from "./sns.js";
|
|
8
|
+
function isS3Event(value) {
|
|
9
|
+
if (typeof value !== "object" || value === null || !("Records" in value)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const records = value.Records;
|
|
13
|
+
return Array.isArray(records) && records.every(
|
|
14
|
+
(r) => typeof r === "object" && r !== null && typeof r.eventName === "string" && typeof r.s3?.bucket?.name === "string" && typeof r.s3?.object?.key === "string"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
function matchTarget(targets, bucket, key) {
|
|
18
|
+
let best = null;
|
|
19
|
+
for (const [name, target] of Object.entries(targets)) {
|
|
20
|
+
if (target.providerType !== "s3") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const config = target.getConfig();
|
|
24
|
+
const keyPrefix = typeof config.keyPrefix === "string" ? config.keyPrefix : "";
|
|
25
|
+
if (config.bucket === bucket && key.startsWith(keyPrefix) && (!best || keyPrefix.length > best.keyPrefix.length)) {
|
|
26
|
+
best = { name, keyPrefix };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return best ? { name: best.name, storageKey: key.slice(best.keyPrefix.length) } : null;
|
|
30
|
+
}
|
|
31
|
+
async function processS3EventRecord(record, { db, targets }) {
|
|
32
|
+
if (!record.eventName.startsWith("ObjectCreated")) {
|
|
33
|
+
return "ignored";
|
|
34
|
+
}
|
|
35
|
+
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
|
|
36
|
+
const match = matchTarget(targets, record.s3.bucket.name, key);
|
|
37
|
+
if (!match) {
|
|
38
|
+
return "no-target";
|
|
39
|
+
}
|
|
40
|
+
const upload = await db.upload.findFirst({
|
|
41
|
+
where: {
|
|
42
|
+
target: match.name,
|
|
43
|
+
storageKey: match.storageKey,
|
|
44
|
+
status: "pending"
|
|
45
|
+
},
|
|
46
|
+
omit: { data: true }
|
|
47
|
+
});
|
|
48
|
+
if (!upload) {
|
|
49
|
+
return "not-found";
|
|
50
|
+
}
|
|
51
|
+
const target = targets[match.name];
|
|
52
|
+
const reportedSize = record.s3.object.size;
|
|
53
|
+
const actualSize = typeof reportedSize === "number" ? reportedSize : await target.getObjectSize(match.storageKey);
|
|
54
|
+
if (actualSize === null || BigInt(actualSize) !== upload.size) {
|
|
55
|
+
const { count: count2 } = await db.upload.updateMany({
|
|
56
|
+
where: { id: upload.id, status: "pending" },
|
|
57
|
+
data: { status: "failed" }
|
|
58
|
+
});
|
|
59
|
+
if (count2 === 1) {
|
|
60
|
+
await target.delete(match.storageKey);
|
|
61
|
+
}
|
|
62
|
+
return "failed";
|
|
63
|
+
}
|
|
64
|
+
const { count } = await db.upload.updateMany({
|
|
65
|
+
where: { id: upload.id, status: "pending" },
|
|
66
|
+
data: { status: "completed", size: BigInt(actualSize) }
|
|
67
|
+
});
|
|
68
|
+
return count === 1 ? "completed" : "ignored";
|
|
69
|
+
}
|
|
70
|
+
async function handleS3Webhook(rawBody, options) {
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(rawBody);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new UploadError("INVALID_KEY", "Webhook body is not JSON.");
|
|
76
|
+
}
|
|
77
|
+
if (!isSnsMessage(parsed)) {
|
|
78
|
+
throw new UploadError("INVALID_KEY", "Webhook body is not an SNS message.");
|
|
79
|
+
}
|
|
80
|
+
await verifySnsMessage(parsed, {
|
|
81
|
+
topicArn: options.topicArn,
|
|
82
|
+
fetchCertificate: options.fetchCertificate
|
|
83
|
+
});
|
|
84
|
+
if (parsed.Type === "SubscriptionConfirmation") {
|
|
85
|
+
if (!parsed.SubscribeURL) {
|
|
86
|
+
throw new UploadError("INVALID_KEY", "Missing SubscribeURL.");
|
|
87
|
+
}
|
|
88
|
+
assertTrustedSnsUrl(parsed.SubscribeURL);
|
|
89
|
+
const doFetch = options.fetch ?? fetch;
|
|
90
|
+
const res = await doFetch(parsed.SubscribeURL, {
|
|
91
|
+
signal: AbortSignal.timeout(SNS_FETCH_TIMEOUT_MS)
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new UploadError(
|
|
95
|
+
"CONFIGURATION",
|
|
96
|
+
`Could not confirm the SNS subscription (HTTP ${res.status}).`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return { type: parsed.Type, outcomes: [] };
|
|
100
|
+
}
|
|
101
|
+
if (parsed.Type === "UnsubscribeConfirmation") {
|
|
102
|
+
return { type: parsed.Type, outcomes: [] };
|
|
103
|
+
}
|
|
104
|
+
let event;
|
|
105
|
+
try {
|
|
106
|
+
event = JSON.parse(parsed.Message);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new UploadError("INVALID_KEY", "SNS message is not an S3 event.");
|
|
109
|
+
}
|
|
110
|
+
if (!isS3Event(event)) {
|
|
111
|
+
return { type: parsed.Type, outcomes: [] };
|
|
112
|
+
}
|
|
113
|
+
const outcomes = [];
|
|
114
|
+
for (const record of event.Records) {
|
|
115
|
+
outcomes.push(await processS3EventRecord(record, options));
|
|
116
|
+
}
|
|
117
|
+
return { type: parsed.Type, outcomes };
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
handleS3Webhook,
|
|
121
|
+
processS3EventRecord
|
|
122
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** An Amazon SNS HTTP(S) delivery, as posted to a subscribed endpoint. */
|
|
2
|
+
export interface SnsMessage {
|
|
3
|
+
Type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation';
|
|
4
|
+
MessageId: string;
|
|
5
|
+
TopicArn: string;
|
|
6
|
+
Message: string;
|
|
7
|
+
Timestamp: string;
|
|
8
|
+
SignatureVersion: string;
|
|
9
|
+
Signature: string;
|
|
10
|
+
SigningCertURL: string;
|
|
11
|
+
Subject?: string;
|
|
12
|
+
SubscribeURL?: string;
|
|
13
|
+
Token?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Deadline for the outbound calls the webhook makes to SNS. */
|
|
16
|
+
export declare const SNS_FETCH_TIMEOUT_MS = 10000;
|
|
17
|
+
export declare function isSnsMessage(value: unknown): value is SnsMessage;
|
|
18
|
+
/** Only `https://sns.<region>.amazonaws.com/...` may supply a signing cert. */
|
|
19
|
+
export declare function assertTrustedSnsUrl(url: string): void;
|
|
20
|
+
/** Builds the canonical string SNS signed, per the SNS signature spec. */
|
|
21
|
+
export declare function snsStringToSign(message: SnsMessage): string;
|
|
22
|
+
export type FetchCertificate = (url: string) => Promise<string>;
|
|
23
|
+
export interface VerifySnsMessageOptions {
|
|
24
|
+
/** The only topic whose messages are accepted. */
|
|
25
|
+
topicArn: string;
|
|
26
|
+
fetchCertificate?: FetchCertificate;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Verifies an SNS message's signature against the certificate it names and
|
|
30
|
+
* requires its `TopicArn` to exactly match the configured topic. A validly
|
|
31
|
+
* signed message from any other topic is rejected.
|
|
32
|
+
*/
|
|
33
|
+
export declare function verifySnsMessage(message: SnsMessage, { topicArn, fetchCertificate, }: VerifySnsMessageOptions): Promise<void>;
|
|
34
|
+
//# sourceMappingURL=sns.d.ts.map
|