@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.
Files changed (113) hide show
  1. package/README.md +24 -0
  2. package/dist/authenticator.d.ts +38 -0
  3. package/dist/authenticator.d.ts.map +1 -0
  4. package/dist/authenticator.js +51 -0
  5. package/dist/cleanupStaleUploads.d.ts +50 -0
  6. package/dist/cleanupStaleUploads.d.ts.map +1 -0
  7. package/dist/cleanupStaleUploads.js +80 -0
  8. package/dist/constants.d.ts +7 -0
  9. package/dist/constants.d.ts.map +1 -0
  10. package/dist/constants.js +4 -0
  11. package/dist/deleteFile.d.ts +14 -0
  12. package/dist/deleteFile.d.ts.map +1 -0
  13. package/dist/deleteFile.js +18 -0
  14. package/dist/directives.d.ts +55 -0
  15. package/dist/directives.d.ts.map +1 -0
  16. package/dist/directives.js +174 -0
  17. package/dist/errors.d.ts +12 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +33 -0
  20. package/dist/fastify/plugin.d.ts +49 -0
  21. package/dist/fastify/plugin.d.ts.map +1 -0
  22. package/dist/fastify/plugin.js +241 -0
  23. package/dist/index.d.ts +34 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +88 -0
  26. package/dist/keys.d.ts +18 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +25 -0
  29. package/dist/pending.d.ts +39 -0
  30. package/dist/pending.d.ts.map +1 -0
  31. package/dist/pending.js +94 -0
  32. package/dist/profiles.d.ts +49 -0
  33. package/dist/profiles.d.ts.map +1 -0
  34. package/dist/profiles.js +68 -0
  35. package/dist/providers/db.d.ts +9 -0
  36. package/dist/providers/db.d.ts.map +1 -0
  37. package/dist/providers/db.js +41 -0
  38. package/dist/providers/fs.d.ts +34 -0
  39. package/dist/providers/fs.d.ts.map +1 -0
  40. package/dist/providers/fs.js +125 -0
  41. package/dist/providers/s3.d.ts +25 -0
  42. package/dist/providers/s3.d.ts.map +1 -0
  43. package/dist/providers/s3.js +117 -0
  44. package/dist/s3.d.ts +3 -0
  45. package/dist/s3.d.ts.map +1 -0
  46. package/dist/s3.js +4 -0
  47. package/dist/serialize.d.ts +10 -0
  48. package/dist/serialize.d.ts.map +1 -0
  49. package/dist/serialize.js +22 -0
  50. package/dist/serveToken.d.ts +36 -0
  51. package/dist/serveToken.d.ts.map +1 -0
  52. package/dist/serveToken.js +45 -0
  53. package/dist/services.d.ts +70 -0
  54. package/dist/services.d.ts.map +1 -0
  55. package/dist/services.js +141 -0
  56. package/dist/storeFile.d.ts +27 -0
  57. package/dist/storeFile.d.ts.map +1 -0
  58. package/dist/storeFile.js +53 -0
  59. package/dist/targets.d.ts +14 -0
  60. package/dist/targets.d.ts.map +1 -0
  61. package/dist/targets.js +21 -0
  62. package/dist/types.d.ts +212 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +0 -0
  65. package/dist/uploadLoader.d.ts +11 -0
  66. package/dist/uploadLoader.d.ts.map +1 -0
  67. package/dist/uploadLoader.js +47 -0
  68. package/dist/uploadToken.d.ts +43 -0
  69. package/dist/uploadToken.d.ts.map +1 -0
  70. package/dist/uploadToken.js +61 -0
  71. package/dist/web/components/DbInput.d.ts +18 -0
  72. package/dist/web/components/DbInput.d.ts.map +1 -0
  73. package/dist/web/components/DbInput.js +46 -0
  74. package/dist/web/components/FsUploader.d.ts +6 -0
  75. package/dist/web/components/FsUploader.d.ts.map +1 -0
  76. package/dist/web/components/FsUploader.js +21 -0
  77. package/dist/web/components/S3Uploader.d.ts +6 -0
  78. package/dist/web/components/S3Uploader.d.ts.map +1 -0
  79. package/dist/web/components/S3Uploader.js +21 -0
  80. package/dist/web/components/UppyUploader.d.ts +23 -0
  81. package/dist/web/components/UppyUploader.d.ts.map +1 -0
  82. package/dist/web/components/UppyUploader.js +72 -0
  83. package/dist/web/createUppy.d.ts +62 -0
  84. package/dist/web/createUppy.d.ts.map +1 -0
  85. package/dist/web/createUppy.js +82 -0
  86. package/dist/web/graphql.d.ts +48 -0
  87. package/dist/web/graphql.d.ts.map +1 -0
  88. package/dist/web/graphql.js +36 -0
  89. package/dist/web/hooks/useDbUpload.d.ts +31 -0
  90. package/dist/web/hooks/useDbUpload.d.ts.map +1 -0
  91. package/dist/web/hooks/useDbUpload.js +80 -0
  92. package/dist/web/hooks/useFsUpload.d.ts +20 -0
  93. package/dist/web/hooks/useFsUpload.d.ts.map +1 -0
  94. package/dist/web/hooks/useFsUpload.js +58 -0
  95. package/dist/web/hooks/useS3Upload.d.ts +23 -0
  96. package/dist/web/hooks/useS3Upload.d.ts.map +1 -0
  97. package/dist/web/hooks/useS3Upload.js +99 -0
  98. package/dist/web/hooks/useUploadToken.d.ts +34 -0
  99. package/dist/web/hooks/useUploadToken.d.ts.map +1 -0
  100. package/dist/web/hooks/useUploadToken.js +60 -0
  101. package/dist/web/hooks/useUppyUpload.d.ts +23 -0
  102. package/dist/web/hooks/useUppyUpload.d.ts.map +1 -0
  103. package/dist/web/hooks/useUppyUpload.js +76 -0
  104. package/dist/web/index.d.ts +22 -0
  105. package/dist/web/index.d.ts.map +1 -0
  106. package/dist/web/index.js +36 -0
  107. package/dist/webhooks/s3.d.ts +52 -0
  108. package/dist/webhooks/s3.d.ts.map +1 -0
  109. package/dist/webhooks/s3.js +122 -0
  110. package/dist/webhooks/sns.d.ts +34 -0
  111. package/dist/webhooks/sns.d.ts.map +1 -0
  112. package/dist/webhooks/sns.js +131 -0
  113. package/package.json +147 -0
@@ -0,0 +1,241 @@
1
+ import fastifyMultipart from "@fastify/multipart";
2
+ import { deleteFile } from "../deleteFile.js";
3
+ import { UploadError } from "../errors.js";
4
+ import { generateStorageKey, trimTrailingSlashes } from "../keys.js";
5
+ import {
6
+ assertOwnership,
7
+ assertTokenAllowsFile,
8
+ createPendingUpload
9
+ } from "../pending.js";
10
+ import { serializeUpload } from "../serialize.js";
11
+ import {
12
+ SERVE_ROUTE_PATH,
13
+ SERVE_TOKEN_PARAM,
14
+ verifyServeToken
15
+ } from "../serveToken.js";
16
+ import { resolveTarget } from "../targets.js";
17
+ import { UPLOAD_TOKEN_HEADER, verifyUploadToken } from "../uploadToken.js";
18
+ import { handleS3Webhook } from "../webhooks/s3.js";
19
+ const FIVE_HUNDRED_MB = 500 * 1024 * 1024;
20
+ const MULTIPART_ENVELOPE_ALLOWANCE = 64 * 1024;
21
+ const ACTIVE_CONTENT_TYPES = /* @__PURE__ */ new Set([
22
+ "text/html",
23
+ "application/xhtml+xml",
24
+ "image/svg+xml",
25
+ "text/xml",
26
+ "application/xml",
27
+ "text/javascript",
28
+ "application/javascript",
29
+ "application/ecmascript",
30
+ "text/ecmascript",
31
+ "application/pdf"
32
+ ]);
33
+ function sendError(reply, e) {
34
+ if (e instanceof UploadError) {
35
+ return reply.code(e.statusCode).send({ error: { code: e.code, message: e.message } });
36
+ }
37
+ throw e;
38
+ }
39
+ function headerValue(req, name) {
40
+ const value = req.headers[name];
41
+ return Array.isArray(value) ? value[0] : value;
42
+ }
43
+ function isActiveContent(mimeType) {
44
+ return ACTIVE_CONTENT_TYPES.has(mimeType.split(";")[0].trim().toLowerCase());
45
+ }
46
+ function contentDispositionHeader(disposition, filename) {
47
+ const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
48
+ const encoded = encodeURIComponent(filename);
49
+ return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
50
+ }
51
+ async function cedarUploadsPlugin(fastify, options) {
52
+ const {
53
+ tokenSecret,
54
+ targets,
55
+ db,
56
+ prefix = "/upload",
57
+ bodyLimit = FIVE_HUNDRED_MB,
58
+ authenticate,
59
+ s3Webhook,
60
+ serveCacheControl = "private, max-age=3600"
61
+ } = options;
62
+ if (!tokenSecret) {
63
+ throw new UploadError(
64
+ "CONFIGURATION",
65
+ "cedarUploadsPlugin needs a `tokenSecret`. Set UPLOAD_TOKEN_SECRET in .env (generate one with `yarn cedar generate secret`)."
66
+ );
67
+ }
68
+ const base = trimTrailingSlashes(prefix);
69
+ await fastify.register(fastifyMultipart, {
70
+ throwFileSizeLimit: true,
71
+ limits: { fileSize: bodyLimit }
72
+ });
73
+ const verifyRequestToken = async (req) => {
74
+ const payload = verifyUploadToken(headerValue(req, UPLOAD_TOKEN_HEADER), {
75
+ secret: tokenSecret
76
+ });
77
+ if (authenticate) {
78
+ const user = await authenticate(req);
79
+ assertOwnership(
80
+ {
81
+ userId: payload.sub,
82
+ organizationId: payload.organizationId ?? null
83
+ },
84
+ user,
85
+ "this upload token"
86
+ );
87
+ }
88
+ return payload;
89
+ };
90
+ fastify.post(`${base}/fs`, { bodyLimit }, async (req, reply) => {
91
+ const stored = [];
92
+ try {
93
+ const payload = await verifyRequestToken(req);
94
+ const target = resolveTarget(targets, payload.target);
95
+ if (target.providerType === "db") {
96
+ throw new UploadError(
97
+ "NOT_SUPPORTED",
98
+ `Upload profile '${payload.profile}' stores files inline in the database. Send them as base64 through GraphQL instead.`
99
+ );
100
+ }
101
+ const requestBound = payload.maxFiles * payload.maxFileSize + MULTIPART_ENVELOPE_ALLOWANCE;
102
+ const contentLength = Number(headerValue(req, "content-length"));
103
+ if (Number.isFinite(contentLength) && contentLength > requestBound) {
104
+ throw new UploadError(
105
+ "FILE_TOO_LARGE",
106
+ `Request body of ${contentLength} bytes exceeds what upload profile '${payload.profile}' allows.`
107
+ );
108
+ }
109
+ if (!req.isMultipart()) {
110
+ throw new UploadError(
111
+ "NOT_SUPPORTED",
112
+ "Upload requests must be multipart/form-data."
113
+ );
114
+ }
115
+ for await (const part of req.parts({
116
+ limits: { fileSize: payload.maxFileSize, files: payload.maxFiles }
117
+ })) {
118
+ if (part.type !== "file") {
119
+ continue;
120
+ }
121
+ assertTokenAllowsFile(payload, { contentType: part.mimetype, size: 0n });
122
+ let data;
123
+ try {
124
+ data = await part.toBuffer();
125
+ } catch (e) {
126
+ if (typeof e === "object" && e !== null && "code" in e && e.code === "FST_REQ_FILE_TOO_LARGE") {
127
+ throw new UploadError(
128
+ "FILE_TOO_LARGE",
129
+ `File '${part.filename}' exceeds the ${payload.maxFileSize} byte limit for upload profile '${payload.profile}'.`
130
+ );
131
+ }
132
+ throw e;
133
+ }
134
+ const size = BigInt(data.byteLength);
135
+ assertTokenAllowsFile(payload, { contentType: part.mimetype, size });
136
+ const storageKey = generateStorageKey(part.mimetype);
137
+ const upload = await createPendingUpload({
138
+ db,
139
+ payload,
140
+ filename: part.filename,
141
+ mimeType: part.mimetype,
142
+ size,
143
+ storageKey
144
+ });
145
+ stored.push({ target: payload.target, upload });
146
+ await target.write(storageKey, data, { contentType: part.mimetype });
147
+ const { count } = await db.upload.updateMany({
148
+ where: { id: upload.id, status: "pending" },
149
+ data: { status: "completed" }
150
+ });
151
+ if (count !== 1) {
152
+ throw new UploadError(
153
+ "NOT_PENDING",
154
+ `Upload ${upload.id} was settled before its bytes were written.`
155
+ );
156
+ }
157
+ }
158
+ if (stored.length === 0) {
159
+ throw new UploadError("NOT_SUPPORTED", "No file was included.");
160
+ }
161
+ return reply.code(201).send({
162
+ uploads: stored.map(
163
+ ({ upload }) => serializeUpload({ ...upload, status: "completed" })
164
+ )
165
+ });
166
+ } catch (e) {
167
+ for (const { target, upload } of stored) {
168
+ await deleteFile(resolveTarget(targets, target), {
169
+ db,
170
+ upload
171
+ }).catch(() => void 0);
172
+ }
173
+ return sendError(reply, e);
174
+ }
175
+ });
176
+ fastify.get(
177
+ `${base}${SERVE_ROUTE_PATH}`,
178
+ async (req, reply) => {
179
+ try {
180
+ const {
181
+ target: targetName,
182
+ key,
183
+ disposition: requested
184
+ } = verifyServeToken(req.query[SERVE_TOKEN_PARAM], {
185
+ secret: tokenSecret
186
+ });
187
+ const target = resolveTarget(targets, targetName);
188
+ const upload = await db.upload.findFirst({
189
+ where: { target: targetName, storageKey: key, status: "completed" },
190
+ omit: { data: true }
191
+ });
192
+ if (!upload) {
193
+ throw new UploadError("NOT_FOUND", "File not found.");
194
+ }
195
+ const etag = `"${upload.id}-${upload.updatedAt.getTime()}"`;
196
+ if (headerValue(req, "if-none-match") === etag) {
197
+ return reply.code(304).send();
198
+ }
199
+ let body;
200
+ try {
201
+ body = target.readStream ? await target.readStream(key) : await target.read(key);
202
+ } catch (e) {
203
+ throw new UploadError("NOT_FOUND", "File not found.", e);
204
+ }
205
+ const disposition = isActiveContent(upload.mimeType) ? "attachment" : requested;
206
+ return reply.code(200).header("Content-Type", upload.mimeType).header("Content-Length", upload.size.toString()).header(
207
+ "Content-Disposition",
208
+ contentDispositionHeader(disposition, upload.filename)
209
+ ).header("X-Content-Type-Options", "nosniff").header("Cache-Control", serveCacheControl).header("ETag", etag).send(body);
210
+ } catch (e) {
211
+ return sendError(reply, e);
212
+ }
213
+ }
214
+ );
215
+ if (s3Webhook) {
216
+ fastify.addContentTypeParser(
217
+ ["text/plain", "application/json"],
218
+ { parseAs: "string" },
219
+ (_req, body, done) => done(null, body)
220
+ );
221
+ fastify.post(`${base}/webhook/s3`, async (req, reply) => {
222
+ try {
223
+ const body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
224
+ const result = await handleS3Webhook(body, {
225
+ db,
226
+ targets,
227
+ topicArn: s3Webhook.topicArn,
228
+ fetchCertificate: s3Webhook.fetchCertificate,
229
+ fetch: s3Webhook.fetch
230
+ });
231
+ return reply.code(200).send(result);
232
+ } catch (e) {
233
+ return sendError(reply, e);
234
+ }
235
+ });
236
+ }
237
+ fastify.get(`${base}/health`, async () => ({ ok: true }));
238
+ }
239
+ export {
240
+ cedarUploadsPlugin
241
+ };
@@ -0,0 +1,34 @@
1
+ export * from './types.js';
2
+ export { UploadError } from './errors.js';
3
+ export type { UploadErrorCode } from './errors.js';
4
+ export { defineStorageTargets, resolveTarget } from './targets.js';
5
+ export { DB_MAX_FILE_SIZE, DEFAULT_UPLOAD_TOKEN_EXPIRES_IN, defineUploadProfiles, isMimeTypeAllowed, resolveProfile, } from './profiles.js';
6
+ export type { ResolvedUploadProfile, UploadProfile, UploadProfiles, } from './profiles.js';
7
+ export { createFsProvider } from './providers/fs.js';
8
+ export type { FsProviderOptions } from './providers/fs.js';
9
+ export { createDbProvider } from './providers/db.js';
10
+ export { generateStorageKey } from './keys.js';
11
+ export { storeFile } from './storeFile.js';
12
+ export type { StoreFileOptions } from './storeFile.js';
13
+ export { deleteFile } from './deleteFile.js';
14
+ export type { DeleteFileOptions } from './deleteFile.js';
15
+ export { cleanupStaleUploads } from './cleanupStaleUploads.js';
16
+ export type { CleanupStaleUploadsOptions, CleanupStaleUploadsResult, } from './cleanupStaleUploads.js';
17
+ export { createUploadToken, UPLOAD_TOKEN_HEADER, UPLOAD_TOKEN_PURPOSE, verifyUploadToken, } from './uploadToken.js';
18
+ export type { CreateUploadTokenOptions, UploadTokenPayload, } from './uploadToken.js';
19
+ export { createServeToken, SERVE_TOKEN_PURPOSE, verifyServeToken, } from './serveToken.js';
20
+ export type { ServeTokenPayload } from './serveToken.js';
21
+ export { serializeUpload, toDataUri } from './serialize.js';
22
+ export { confirmUpload, createPresignedUpload, issueUploadToken, } from './services.js';
23
+ export type { ConfirmUploadOptions, CreatePresignedUploadInput, CreatePresignedUploadOptions, IssueUploadTokenOptions, PresignedUploadResponse, UploadActor, UploadTokenResponse, } from './services.js';
24
+ export { createRequireUploadTokenDirective, createWithDataUriDirective, createWithSignedUrlDirective, getUploadTokenPayload, requireUploadTokenSchema, UPLOAD_TOKEN_CONTEXT_KEY, withDataUriSchema, withSignedUrlSchema, } from './directives.js';
25
+ export type { RequireUploadTokenDirectiveOptions, UploadUrlDirectiveOptions, WithSignedUrlDirectiveOptions, } from './directives.js';
26
+ export { createUploadAuthenticator } from './authenticator.js';
27
+ export type { CreateUploadAuthenticatorOptions, UploadAuthenticator, UploadRequestUser, } from './authenticator.js';
28
+ export { cedarUploadsPlugin } from './fastify/plugin.js';
29
+ export type { UploadPluginOptions } from './fastify/plugin.js';
30
+ export { handleS3Webhook, processS3EventRecord } from './webhooks/s3.js';
31
+ export type { S3Event, S3EventOutcome, S3EventRecord, S3WebhookOptions, S3WebhookResult, } from './webhooks/s3.js';
32
+ export { verifySnsMessage } from './webhooks/sns.js';
33
+ export type { SnsMessage } from './webhooks/sns.js';
34
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAClD,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAClE,OAAO,EACL,gBAAgB,EAChB,+BAA+B,EAC/B,oBAAoB,EACpB,iBAAiB,EACjB,cAAc,GACf,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,cAAc,GACf,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,YAAY,EACV,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,kBAAkB,CAAA;AACzB,YAAY,EACV,wBAAwB,EACxB,kBAAkB,GACnB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,eAAe,CAAA;AACtB,YAAY,EACV,oBAAoB,EACpB,0BAA0B,EAC1B,4BAA4B,EAC5B,uBAAuB,EACvB,uBAAuB,EACvB,WAAW,EACX,mBAAmB,GACpB,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,iCAAiC,EACjC,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EACV,kCAAkC,EAClC,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAA;AAC9D,YAAY,EACV,gCAAgC,EAChC,mBAAmB,EACnB,iBAAiB,GAClB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAC9D,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AACxE,YAAY,EACV,OAAO,EACP,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,eAAe,GAChB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ export * from "./types.js";
2
+ import { UploadError } from "./errors.js";
3
+ import { defineStorageTargets, resolveTarget } from "./targets.js";
4
+ import {
5
+ DB_MAX_FILE_SIZE,
6
+ DEFAULT_UPLOAD_TOKEN_EXPIRES_IN,
7
+ defineUploadProfiles,
8
+ isMimeTypeAllowed,
9
+ resolveProfile
10
+ } from "./profiles.js";
11
+ import { createFsProvider } from "./providers/fs.js";
12
+ import { createDbProvider } from "./providers/db.js";
13
+ import { generateStorageKey } from "./keys.js";
14
+ import { storeFile } from "./storeFile.js";
15
+ import { deleteFile } from "./deleteFile.js";
16
+ import { cleanupStaleUploads } from "./cleanupStaleUploads.js";
17
+ import {
18
+ createUploadToken,
19
+ UPLOAD_TOKEN_HEADER,
20
+ UPLOAD_TOKEN_PURPOSE,
21
+ verifyUploadToken
22
+ } from "./uploadToken.js";
23
+ import {
24
+ createServeToken,
25
+ SERVE_TOKEN_PURPOSE,
26
+ verifyServeToken
27
+ } from "./serveToken.js";
28
+ import { serializeUpload, toDataUri } from "./serialize.js";
29
+ import {
30
+ confirmUpload,
31
+ createPresignedUpload,
32
+ issueUploadToken
33
+ } from "./services.js";
34
+ import {
35
+ createRequireUploadTokenDirective,
36
+ createWithDataUriDirective,
37
+ createWithSignedUrlDirective,
38
+ getUploadTokenPayload,
39
+ requireUploadTokenSchema,
40
+ UPLOAD_TOKEN_CONTEXT_KEY,
41
+ withDataUriSchema,
42
+ withSignedUrlSchema
43
+ } from "./directives.js";
44
+ import { createUploadAuthenticator } from "./authenticator.js";
45
+ import { cedarUploadsPlugin } from "./fastify/plugin.js";
46
+ import { handleS3Webhook, processS3EventRecord } from "./webhooks/s3.js";
47
+ import { verifySnsMessage } from "./webhooks/sns.js";
48
+ export {
49
+ DB_MAX_FILE_SIZE,
50
+ DEFAULT_UPLOAD_TOKEN_EXPIRES_IN,
51
+ SERVE_TOKEN_PURPOSE,
52
+ UPLOAD_TOKEN_CONTEXT_KEY,
53
+ UPLOAD_TOKEN_HEADER,
54
+ UPLOAD_TOKEN_PURPOSE,
55
+ UploadError,
56
+ cedarUploadsPlugin,
57
+ cleanupStaleUploads,
58
+ confirmUpload,
59
+ createDbProvider,
60
+ createFsProvider,
61
+ createPresignedUpload,
62
+ createRequireUploadTokenDirective,
63
+ createServeToken,
64
+ createUploadAuthenticator,
65
+ createUploadToken,
66
+ createWithDataUriDirective,
67
+ createWithSignedUrlDirective,
68
+ defineStorageTargets,
69
+ defineUploadProfiles,
70
+ deleteFile,
71
+ generateStorageKey,
72
+ getUploadTokenPayload,
73
+ handleS3Webhook,
74
+ isMimeTypeAllowed,
75
+ issueUploadToken,
76
+ processS3EventRecord,
77
+ requireUploadTokenSchema,
78
+ resolveProfile,
79
+ resolveTarget,
80
+ serializeUpload,
81
+ storeFile,
82
+ toDataUri,
83
+ verifyServeToken,
84
+ verifySnsMessage,
85
+ verifyUploadToken,
86
+ withDataUriSchema,
87
+ withSignedUrlSchema
88
+ };
package/dist/keys.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A fresh storage key: a random id plus the extension that matches
3
+ * `mimeType`, when one is known. Keys never derive from the client's
4
+ * filename.
5
+ */
6
+ export declare function generateStorageKey(mimeType: string): string;
7
+ /**
8
+ * Rejects keys that could escape a provider's root: path separators, `..`,
9
+ * leading dots, and anything outside a conservative character set. Providers
10
+ * that map keys onto a filesystem call this before touching disk.
11
+ */
12
+ export declare function assertSafeKey(key: string): void;
13
+ /**
14
+ * Removes trailing slashes without a regex, which keeps the trim linear on
15
+ * any input.
16
+ */
17
+ export declare function trimTrailingSlashes(value: string): string;
18
+ //# sourceMappingURL=keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAMA;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI3D;AAID;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,QAIxC;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQzD"}
package/dist/keys.js ADDED
@@ -0,0 +1,25 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import mime from "mime-types";
3
+ import { UploadError } from "./errors.js";
4
+ function generateStorageKey(mimeType) {
5
+ const ext = mime.extension(mimeType.split(";")[0].trim());
6
+ return ext ? `${randomUUID()}.${ext}` : randomUUID();
7
+ }
8
+ const SAFE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
+ function assertSafeKey(key) {
10
+ if (!SAFE_KEY.test(key) || key.includes("..")) {
11
+ throw new UploadError("INVALID_KEY", `Invalid storage key '${key}'.`);
12
+ }
13
+ }
14
+ function trimTrailingSlashes(value) {
15
+ let end = value.length;
16
+ while (end > 0 && value[end - 1] === "/") {
17
+ end -= 1;
18
+ }
19
+ return value.slice(0, end);
20
+ }
21
+ export {
22
+ assertSafeKey,
23
+ generateStorageKey,
24
+ trimTrailingSlashes
25
+ };
@@ -0,0 +1,39 @@
1
+ import type { UploadDatabase, UploadRecord } from './types.js';
2
+ import type { UploadTokenPayload } from './uploadToken.js';
3
+ /**
4
+ * Checks one file against the constraints signed into an upload token.
5
+ * Throws `MIME_TYPE_NOT_ALLOWED` or `FILE_TOO_LARGE`.
6
+ */
7
+ export declare function assertTokenAllowsFile(payload: UploadTokenPayload, file: {
8
+ contentType: string;
9
+ size: bigint;
10
+ }): void;
11
+ export interface CreatePendingUploadOptions {
12
+ db: UploadDatabase;
13
+ payload: UploadTokenPayload;
14
+ filename: string;
15
+ mimeType: string;
16
+ size: bigint;
17
+ storageKey: string;
18
+ }
19
+ /**
20
+ * Creates a `pending` row for a token, enforcing the token's `maxFiles`
21
+ * across every request that uses it. The count and the insert run in one
22
+ * serializable transaction keyed on `tokenId`, so concurrent requests sharing
23
+ * a token cannot exceed the limit. A transaction the database rejects for
24
+ * conflicting with a concurrent one is retried a bounded number of times.
25
+ */
26
+ export declare function createPendingUpload({ db, payload, filename, mimeType, size, storageKey, }: CreatePendingUploadOptions): Promise<UploadRecord>;
27
+ /**
28
+ * Enforces that the user (and, in multi-tenant apps, the organization) a
29
+ * token or row belongs to is the one acting on it. `null` identity on the
30
+ * row means "unowned" and passes.
31
+ */
32
+ export declare function assertOwnership(owner: {
33
+ userId: string | null;
34
+ organizationId: string | null;
35
+ }, actor: {
36
+ id: string | number;
37
+ organizationId?: string | null;
38
+ } | null | undefined, what: string): void;
39
+ //# sourceMappingURL=pending.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending.d.ts","sourceRoot":"","sources":["../src/pending.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAC9D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAE1D;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,QAqB5C;AAED,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,kBAAkB,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;CACnB;AAiBD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,EACxC,EAAE,EACF,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,IAAI,EACJ,UAAU,GACX,EAAE,0BAA0B,GAAG,OAAO,CAAC,YAAY,CAAC,CA+CpD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EAC/D,KAAK,EACH;IAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,EAC5E,IAAI,EAAE,MAAM,QAmBb"}
@@ -0,0 +1,94 @@
1
+ import { UploadError } from "./errors.js";
2
+ import { isMimeTypeAllowed } from "./profiles.js";
3
+ function assertTokenAllowsFile(payload, file) {
4
+ if (!isMimeTypeAllowed(payload.allowedMimeTypes, file.contentType)) {
5
+ throw new UploadError(
6
+ "MIME_TYPE_NOT_ALLOWED",
7
+ `File type '${file.contentType}' is not allowed for upload profile '${payload.profile}'.`
8
+ );
9
+ }
10
+ if (file.size < 0n) {
11
+ throw new UploadError("FILE_TOO_LARGE", "File size must not be negative.");
12
+ }
13
+ if (file.size > BigInt(payload.maxFileSize)) {
14
+ throw new UploadError(
15
+ "FILE_TOO_LARGE",
16
+ `File is ${file.size} bytes, which exceeds the ${payload.maxFileSize} byte limit for upload profile '${payload.profile}'.`
17
+ );
18
+ }
19
+ }
20
+ const SERIALIZATION_CONFLICT = "P2034";
21
+ const MAX_ATTEMPTS = 3;
22
+ function isSerializationConflict(e) {
23
+ return typeof e === "object" && e !== null && "code" in e && e.code === SERIALIZATION_CONFLICT;
24
+ }
25
+ async function createPendingUpload({
26
+ db,
27
+ payload,
28
+ filename,
29
+ mimeType,
30
+ size,
31
+ storageKey
32
+ }) {
33
+ for (let attempt = 1; ; attempt += 1) {
34
+ try {
35
+ return await db.$transaction(
36
+ async (tx) => {
37
+ const existing = await tx.upload.count({
38
+ where: { tokenId: payload.jti }
39
+ });
40
+ if (existing >= payload.maxFiles) {
41
+ throw new UploadError(
42
+ "TOO_MANY_FILES",
43
+ `This upload token has already been used for ${existing} of its ${payload.maxFiles} allowed files.`
44
+ );
45
+ }
46
+ return tx.upload.create({
47
+ data: {
48
+ target: payload.target,
49
+ status: "pending",
50
+ filename,
51
+ mimeType,
52
+ size,
53
+ storageKey,
54
+ userId: payload.sub,
55
+ tokenId: payload.jti,
56
+ organizationId: payload.organizationId ?? null
57
+ }
58
+ });
59
+ },
60
+ { isolationLevel: "Serializable" }
61
+ );
62
+ } catch (e) {
63
+ if (!isSerializationConflict(e)) {
64
+ throw e;
65
+ }
66
+ if (attempt >= MAX_ATTEMPTS) {
67
+ throw new UploadError(
68
+ "TOO_MANY_FILES",
69
+ "Too many concurrent uploads for this token. Try again.",
70
+ e
71
+ );
72
+ }
73
+ }
74
+ }
75
+ }
76
+ function assertOwnership(owner, actor, what) {
77
+ if (!actor) {
78
+ throw new UploadError(
79
+ "UNAUTHENTICATED",
80
+ `You must be logged in to ${what}.`
81
+ );
82
+ }
83
+ if (owner.userId && owner.userId !== String(actor.id)) {
84
+ throw new UploadError("FORBIDDEN", `You don't have access to ${what}.`);
85
+ }
86
+ if (owner.organizationId && owner.organizationId !== (actor.organizationId ?? null)) {
87
+ throw new UploadError("FORBIDDEN", `You don't have access to ${what}.`);
88
+ }
89
+ }
90
+ export {
91
+ assertOwnership,
92
+ assertTokenAllowsFile,
93
+ createPendingUpload
94
+ };
@@ -0,0 +1,49 @@
1
+ import type { SignedTokenExpiresIn } from '@cedarjs/api';
2
+ /**
3
+ * Default size cap for files stored inline in the database. The DB path
4
+ * buffers base64 through GraphQL, so it is only for small files.
5
+ */
6
+ export declare const DB_MAX_FILE_SIZE: number;
7
+ /** Default lifetime of an upload token. */
8
+ export declare const DEFAULT_UPLOAD_TOKEN_EXPIRES_IN: SignedTokenExpiresIn;
9
+ /**
10
+ * Server-defined constraints for one kind of upload. The client names a
11
+ * profile; the server owns what that profile allows.
12
+ */
13
+ export interface UploadProfile {
14
+ /** Name of the storage target files uploaded under this profile land on. */
15
+ target: string;
16
+ /**
17
+ * MIME types the profile accepts. Exact types (`image/png`) and wildcard
18
+ * subtypes (`image/*`) are supported.
19
+ */
20
+ allowedMimeTypes: string[];
21
+ /** Largest file the profile accepts, in bytes. */
22
+ maxFileSize: number;
23
+ /** Most files one token may create, across every request that uses it. */
24
+ maxFiles: number;
25
+ /** Lifetime of tokens issued for this profile. Defaults to five minutes. */
26
+ expiresIn?: SignedTokenExpiresIn;
27
+ }
28
+ export type UploadProfiles = Record<string, UploadProfile>;
29
+ export interface ResolvedUploadProfile extends UploadProfile {
30
+ name: string;
31
+ }
32
+ /**
33
+ * Validates and returns the app's upload profiles. Returns the same object,
34
+ * typed, so `profiles.avatar` works for direct access.
35
+ */
36
+ export declare function defineUploadProfiles<T extends UploadProfiles>(profiles: T): T;
37
+ /**
38
+ * Looks a profile up by name, throwing an `UploadError` with code
39
+ * `UNKNOWN_PROFILE` when it is missing. Unknown names are a client error, so
40
+ * the message does not list the configured profiles.
41
+ */
42
+ export declare function resolveProfile(profiles: UploadProfiles, name: string): ResolvedUploadProfile;
43
+ /**
44
+ * Whether `mimeType` matches one of `allowed`. Matching is case-insensitive
45
+ * and ignores parameters such as `; charset=utf-8`. An entry of `image/*`
46
+ * matches every `image/` subtype.
47
+ */
48
+ export declare function isMimeTypeAllowed(allowed: string[], mimeType: string): boolean;
49
+ //# sourceMappingURL=profiles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../src/profiles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AAIxD;;;GAGG;AACH,eAAO,MAAM,gBAAgB,QAAc,CAAA;AAE3C,2CAA2C;AAC3C,eAAO,MAAM,+BAA+B,EAAE,oBAA2B,CAAA;AAEzE;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAA;IACnB,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,CAAA;IAChB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,oBAAoB,CAAA;CACjC;AAED,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;AAE1D,MAAM,WAAW,qBAAsB,SAAQ,aAAa;IAC1D,IAAI,EAAE,MAAM,CAAA;CACb;AAiCD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,cAAc,EAAE,QAAQ,EAAE,CAAC,GAAG,CAAC,CAM7E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,cAAc,EACxB,IAAI,EAAE,MAAM,GACX,qBAAqB,CAavB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,WAoBpE"}
@@ -0,0 +1,68 @@
1
+ import { UploadError } from "./errors.js";
2
+ const DB_MAX_FILE_SIZE = 1024 * 1024;
3
+ const DEFAULT_UPLOAD_TOKEN_EXPIRES_IN = "5m";
4
+ function validateProfile(name, profile) {
5
+ if (!profile.target) {
6
+ throw new UploadError(
7
+ "CONFIGURATION",
8
+ `Upload profile '${name}' needs a \`target\`.`
9
+ );
10
+ }
11
+ if (!profile.allowedMimeTypes?.length) {
12
+ throw new UploadError(
13
+ "CONFIGURATION",
14
+ `Upload profile '${name}' needs at least one entry in \`allowedMimeTypes\`.`
15
+ );
16
+ }
17
+ if (!(profile.maxFileSize > 0)) {
18
+ throw new UploadError(
19
+ "CONFIGURATION",
20
+ `Upload profile '${name}' needs a positive \`maxFileSize\`.`
21
+ );
22
+ }
23
+ if (!Number.isInteger(profile.maxFiles) || profile.maxFiles < 1) {
24
+ throw new UploadError(
25
+ "CONFIGURATION",
26
+ `Upload profile '${name}' needs \`maxFiles\` of at least 1.`
27
+ );
28
+ }
29
+ }
30
+ function defineUploadProfiles(profiles) {
31
+ for (const [name, profile] of Object.entries(profiles)) {
32
+ validateProfile(name, profile);
33
+ }
34
+ return profiles;
35
+ }
36
+ function resolveProfile(profiles, name) {
37
+ const profile = Object.hasOwn(profiles, name) ? profiles[name] : void 0;
38
+ if (!profile) {
39
+ throw new UploadError(
40
+ "UNKNOWN_PROFILE",
41
+ `Unknown upload profile '${name}'.`
42
+ );
43
+ }
44
+ return { ...profile, name };
45
+ }
46
+ function isMimeTypeAllowed(allowed, mimeType) {
47
+ const normalized = mimeType.split(";")[0].trim().toLowerCase();
48
+ if (!normalized) {
49
+ return false;
50
+ }
51
+ return allowed.some((entry) => {
52
+ const pattern = entry.trim().toLowerCase();
53
+ if (pattern === "*/*") {
54
+ return true;
55
+ }
56
+ if (pattern.endsWith("/*")) {
57
+ return normalized.startsWith(pattern.slice(0, -1));
58
+ }
59
+ return pattern === normalized;
60
+ });
61
+ }
62
+ export {
63
+ DB_MAX_FILE_SIZE,
64
+ DEFAULT_UPLOAD_TOKEN_EXPIRES_IN,
65
+ defineUploadProfiles,
66
+ isMimeTypeAllowed,
67
+ resolveProfile
68
+ };