@uploadcn/server 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/fs.d.ts +36 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +216 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +579 -0
- package/dist/index.js.map +1 -0
- package/dist/ocr.d.ts +154 -0
- package/dist/ocr.d.ts.map +1 -0
- package/dist/ocr.js +534 -0
- package/dist/ocr.js.map +1 -0
- package/dist/scan-B8LojfB0.d.ts +114 -0
- package/dist/scan-B8LojfB0.d.ts.map +1 -0
- package/dist/scan.d.ts +2 -0
- package/dist/scan.js +290 -0
- package/dist/scan.js.map +1 -0
- package/dist/signing-DZiuKRaJ.js +52 -0
- package/dist/signing-DZiuKRaJ.js.map +1 -0
- package/dist/storage-CGGIhRB9.d.ts +77 -0
- package/dist/storage-CGGIhRB9.d.ts.map +1 -0
- package/package.json +56 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { n as sha256Hex, t as createUrlSigner } from "./signing-DZiuKRaJ.js";
|
|
2
|
+
import { matchesAccept } from "@uploadcn/core";
|
|
3
|
+
import { AwsClient } from "aws4fetch";
|
|
4
|
+
//#region src/route.ts
|
|
5
|
+
var UploadRouteError = class extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
name = "UploadRouteError";
|
|
9
|
+
constructor(message, status = 400, code) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const MAX_PARTS = 1e4;
|
|
16
|
+
function sanitizeFileName(name) {
|
|
17
|
+
return name.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^\w.\- ]+/g, "").replace(/\s+/g, "-").replace(/^[.-]+/, "").slice(-120) || "file";
|
|
18
|
+
}
|
|
19
|
+
function json(data, status = 200) {
|
|
20
|
+
return new Response(JSON.stringify(data), {
|
|
21
|
+
status,
|
|
22
|
+
headers: {
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
"cache-control": "no-store"
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
function requireString(body, field) {
|
|
32
|
+
const value = body[field];
|
|
33
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 1024) throw new UploadRouteError(`Invalid "${field}"`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function requireFile(body) {
|
|
37
|
+
const file = body.file;
|
|
38
|
+
if (!isRecord(file)) throw new UploadRouteError("Invalid \"file\"");
|
|
39
|
+
const { name, type, size } = file;
|
|
40
|
+
if (typeof name !== "string" || typeof type !== "string" || typeof size !== "number") throw new UploadRouteError("Invalid \"file\"");
|
|
41
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new UploadRouteError("Invalid file size");
|
|
42
|
+
return {
|
|
43
|
+
name: name.slice(0, 255),
|
|
44
|
+
type: type || "application/octet-stream",
|
|
45
|
+
size
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function readMeta(body) {
|
|
49
|
+
return isRecord(body.meta) ? body.meta : {};
|
|
50
|
+
}
|
|
51
|
+
function parseRequest(body) {
|
|
52
|
+
if (!isRecord(body)) throw new UploadRouteError("Expected a JSON object");
|
|
53
|
+
const action = body.action;
|
|
54
|
+
switch (action) {
|
|
55
|
+
case "presign":
|
|
56
|
+
case "create-multipart": return {
|
|
57
|
+
action,
|
|
58
|
+
file: requireFile(body),
|
|
59
|
+
meta: readMeta(body)
|
|
60
|
+
};
|
|
61
|
+
case "complete": return {
|
|
62
|
+
action,
|
|
63
|
+
key: requireString(body, "key"),
|
|
64
|
+
file: requireFile(body),
|
|
65
|
+
meta: readMeta(body)
|
|
66
|
+
};
|
|
67
|
+
case "sign-part": {
|
|
68
|
+
const partNumber = body.partNumber;
|
|
69
|
+
if (typeof partNumber !== "number" || !Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_PARTS) throw new UploadRouteError("Invalid \"partNumber\"");
|
|
70
|
+
return {
|
|
71
|
+
action,
|
|
72
|
+
key: requireString(body, "key"),
|
|
73
|
+
uploadId: requireString(body, "uploadId"),
|
|
74
|
+
partNumber
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
case "list-parts":
|
|
78
|
+
case "abort-multipart": return {
|
|
79
|
+
action,
|
|
80
|
+
key: requireString(body, "key"),
|
|
81
|
+
uploadId: requireString(body, "uploadId")
|
|
82
|
+
};
|
|
83
|
+
case "complete-multipart": {
|
|
84
|
+
const parts = body.parts;
|
|
85
|
+
if (!Array.isArray(parts) || parts.length === 0 || parts.length > MAX_PARTS) throw new UploadRouteError("Invalid \"parts\"");
|
|
86
|
+
return {
|
|
87
|
+
action,
|
|
88
|
+
key: requireString(body, "key"),
|
|
89
|
+
uploadId: requireString(body, "uploadId"),
|
|
90
|
+
file: requireFile(body),
|
|
91
|
+
meta: readMeta(body),
|
|
92
|
+
parts: parts.map((part) => {
|
|
93
|
+
if (!isRecord(part) || typeof part.partNumber !== "number" || typeof part.etag !== "string") throw new UploadRouteError("Invalid \"parts\"");
|
|
94
|
+
return {
|
|
95
|
+
partNumber: part.partNumber,
|
|
96
|
+
etag: part.etag
|
|
97
|
+
};
|
|
98
|
+
})
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
default: throw new UploadRouteError("Unknown \"action\"");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A single POST endpoint that signs uploads for the browser's `s3Adapter`.
|
|
106
|
+
* It speaks Web standard `Request`/`Response`, so it mounts directly in
|
|
107
|
+
* Next.js route handlers, TanStack Start server routes, Hono, Remix, Bun,
|
|
108
|
+
* Deno and Cloudflare Workers.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* // app/api/upload/route.ts
|
|
112
|
+
* export const { POST } = createUploadRoute({ storage: s3Storage({...}) })
|
|
113
|
+
*/
|
|
114
|
+
function createUploadRoute(options) {
|
|
115
|
+
const { storage } = options;
|
|
116
|
+
const expiresIn = options.expiresIn ?? 3600;
|
|
117
|
+
if (options.scan && !storage.getObject) throw new Error(`[uploadcn] scan needs a storage that can read objects; "${storage.name}" can't`);
|
|
118
|
+
async function scanObject(context, key, file, size) {
|
|
119
|
+
const scan = options.scan;
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
if (scan.maxFileSize != null && (size ?? file.size) > scan.maxFileSize) result = {
|
|
123
|
+
status: "unknown",
|
|
124
|
+
threats: [],
|
|
125
|
+
scanner: scan.scanner.name,
|
|
126
|
+
details: { skipped: "too large" }
|
|
127
|
+
};
|
|
128
|
+
else {
|
|
129
|
+
const stream = await storage.getObject(key);
|
|
130
|
+
if (!stream) throw new UploadRouteError("Upload not found", 409);
|
|
131
|
+
result = await scan.scanner.scan({
|
|
132
|
+
stream,
|
|
133
|
+
file,
|
|
134
|
+
key,
|
|
135
|
+
signal: context.request.signal
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof UploadRouteError) throw error;
|
|
140
|
+
console.error("[uploadcn] virus scan failed", error);
|
|
141
|
+
result = {
|
|
142
|
+
status: "unknown",
|
|
143
|
+
threats: [],
|
|
144
|
+
scanner: scan.scanner.name,
|
|
145
|
+
details: { error: error instanceof Error ? error.message : String(error) }
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
await scan.onResult?.({
|
|
149
|
+
...context,
|
|
150
|
+
key,
|
|
151
|
+
file,
|
|
152
|
+
result
|
|
153
|
+
});
|
|
154
|
+
if (result.status === "infected" || result.status === "unknown" && (scan.onUnknown ?? "reject") === "reject") {
|
|
155
|
+
await storage.deleteObject(key);
|
|
156
|
+
throw new UploadRouteError(result.status === "infected" ? "This file contains malware and was removed" : "This file couldn't be scanned and was removed", 422, "rejected");
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
function checkFile(file) {
|
|
161
|
+
if (options.maxFileSize != null && file.size > options.maxFileSize) throw new UploadRouteError("File is too large", 413);
|
|
162
|
+
if (options.allowedTypes && !matchesAccept(file, options.allowedTypes)) throw new UploadRouteError("File type is not allowed", 415);
|
|
163
|
+
}
|
|
164
|
+
async function finalize(context, key, file, meta, etag) {
|
|
165
|
+
let size;
|
|
166
|
+
if (options.verify ?? true) {
|
|
167
|
+
const info = await storage.headObject(key);
|
|
168
|
+
size = info?.size;
|
|
169
|
+
if (info === null) throw new UploadRouteError("Upload not found", 409);
|
|
170
|
+
if (info && options.maxFileSize != null && info.size > options.maxFileSize) {
|
|
171
|
+
await storage.deleteObject(key);
|
|
172
|
+
throw new UploadRouteError("File is too large", 413);
|
|
173
|
+
}
|
|
174
|
+
etag ??= info?.etag;
|
|
175
|
+
}
|
|
176
|
+
const scan = options.scan ? await scanObject(context, key, file, size) : void 0;
|
|
177
|
+
const url = await storage.getUrl(key);
|
|
178
|
+
const data = await options.onUploadComplete?.({
|
|
179
|
+
...context,
|
|
180
|
+
key,
|
|
181
|
+
url,
|
|
182
|
+
file,
|
|
183
|
+
meta,
|
|
184
|
+
scan
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
key,
|
|
188
|
+
url,
|
|
189
|
+
etag,
|
|
190
|
+
...data === void 0 ? {} : { data },
|
|
191
|
+
...scan ? { scan: {
|
|
192
|
+
status: scan.status === "clean" ? "clean" : "unknown",
|
|
193
|
+
scanner: scan.scanner
|
|
194
|
+
} } : {}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async function handle(request) {
|
|
198
|
+
try {
|
|
199
|
+
if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
200
|
+
let body;
|
|
201
|
+
try {
|
|
202
|
+
body = await request.json();
|
|
203
|
+
} catch {
|
|
204
|
+
throw new UploadRouteError("Expected a JSON body");
|
|
205
|
+
}
|
|
206
|
+
const input = parseRequest(body);
|
|
207
|
+
const auth = await options.authorize?.({
|
|
208
|
+
request,
|
|
209
|
+
action: input.action,
|
|
210
|
+
key: "key" in input ? input.key : void 0
|
|
211
|
+
});
|
|
212
|
+
const context = {
|
|
213
|
+
request,
|
|
214
|
+
action: input.action,
|
|
215
|
+
auth
|
|
216
|
+
};
|
|
217
|
+
switch (input.action) {
|
|
218
|
+
case "presign": {
|
|
219
|
+
checkFile(input.file);
|
|
220
|
+
const key = await resolveKey(context, input.file, input.meta ?? {});
|
|
221
|
+
const signed = await storage.presignPut({
|
|
222
|
+
key,
|
|
223
|
+
contentType: input.file.type,
|
|
224
|
+
contentLength: input.file.size,
|
|
225
|
+
expiresIn
|
|
226
|
+
});
|
|
227
|
+
return json({
|
|
228
|
+
key,
|
|
229
|
+
url: signed.url,
|
|
230
|
+
method: "PUT",
|
|
231
|
+
headers: signed.headers
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
case "complete":
|
|
235
|
+
checkFile(input.file);
|
|
236
|
+
return json(await finalize(context, input.key, input.file, input.meta ?? {}));
|
|
237
|
+
case "create-multipart": {
|
|
238
|
+
checkFile(input.file);
|
|
239
|
+
const key = await resolveKey(context, input.file, input.meta ?? {});
|
|
240
|
+
const { uploadId } = await storage.createMultipart({
|
|
241
|
+
key,
|
|
242
|
+
contentType: input.file.type
|
|
243
|
+
});
|
|
244
|
+
return json({
|
|
245
|
+
key,
|
|
246
|
+
uploadId
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
case "sign-part": return json({ url: await storage.presignPart({
|
|
250
|
+
...input,
|
|
251
|
+
expiresIn
|
|
252
|
+
}) });
|
|
253
|
+
case "list-parts": return json({ parts: await storage.listParts(input) });
|
|
254
|
+
case "complete-multipart": {
|
|
255
|
+
checkFile(input.file);
|
|
256
|
+
const { etag } = await storage.completeMultipart(input);
|
|
257
|
+
return json(await finalize(context, input.key, input.file, input.meta ?? {}, etag));
|
|
258
|
+
}
|
|
259
|
+
case "abort-multipart":
|
|
260
|
+
await storage.abortMultipart(input);
|
|
261
|
+
return json({ ok: true });
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof UploadRouteError) return json(error.code ? {
|
|
265
|
+
error: error.message,
|
|
266
|
+
code: error.code
|
|
267
|
+
} : { error: error.message }, error.status);
|
|
268
|
+
console.error("[uploadcn] upload route error", error);
|
|
269
|
+
return json({ error: "Upload failed" }, 500);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function resolveKey(context, file, meta) {
|
|
273
|
+
if (options.getKey) return options.getKey({
|
|
274
|
+
...context,
|
|
275
|
+
file,
|
|
276
|
+
meta
|
|
277
|
+
});
|
|
278
|
+
return `${crypto.randomUUID()}/${sanitizeFileName(file.name)}`;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
POST: handle,
|
|
282
|
+
handler: handle
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/s3.ts
|
|
287
|
+
function encodeKey(key) {
|
|
288
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
289
|
+
}
|
|
290
|
+
function escapeXml(value) {
|
|
291
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
292
|
+
}
|
|
293
|
+
function unescapeXml(value) {
|
|
294
|
+
return value.replace(/"/g, "\"").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
295
|
+
}
|
|
296
|
+
function readTag(xml, tag) {
|
|
297
|
+
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
|
298
|
+
return match ? unescapeXml(match[1]) : void 0;
|
|
299
|
+
}
|
|
300
|
+
function readAll(xml, tag) {
|
|
301
|
+
return [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))].map((match) => match[1]);
|
|
302
|
+
}
|
|
303
|
+
var S3StorageError = class extends Error {
|
|
304
|
+
status;
|
|
305
|
+
code;
|
|
306
|
+
name = "S3StorageError";
|
|
307
|
+
constructor(message, status, code) {
|
|
308
|
+
super(message);
|
|
309
|
+
this.status = status;
|
|
310
|
+
this.code = code;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
async function assertOk(response, operation) {
|
|
314
|
+
const body = await response.text();
|
|
315
|
+
if (response.ok && !body.includes("<Error>")) return body;
|
|
316
|
+
const code = readTag(body, "Code");
|
|
317
|
+
const message = readTag(body, "Message") ?? response.statusText;
|
|
318
|
+
throw new S3StorageError(`${operation} failed: ${code ?? response.status} ${message}`, response.status, code);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Amazon S3 and any S3-compatible service, signed with SigV4 via aws4fetch.
|
|
322
|
+
* Runs on Node, Bun, Deno, Cloudflare Workers and edge runtimes.
|
|
323
|
+
*/
|
|
324
|
+
function s3Storage(options) {
|
|
325
|
+
const client = new AwsClient({
|
|
326
|
+
accessKeyId: options.accessKeyId,
|
|
327
|
+
secretAccessKey: options.secretAccessKey,
|
|
328
|
+
sessionToken: options.sessionToken,
|
|
329
|
+
service: "s3",
|
|
330
|
+
region: options.region
|
|
331
|
+
});
|
|
332
|
+
const pathStyle = options.forcePathStyle ?? !!options.endpoint;
|
|
333
|
+
const endpoint = (options.endpoint ?? `https://s3.${options.region}.amazonaws.com`).replace(/\/+$/, "");
|
|
334
|
+
const readExpiry = options.readUrlExpiresIn ?? 3600;
|
|
335
|
+
function objectUrl(key, query = {}) {
|
|
336
|
+
const base = pathStyle ? `${endpoint}/${options.bucket}/${encodeKey(key)}` : `${endpoint.replace("://", `://${options.bucket}.`)}/${encodeKey(key)}`;
|
|
337
|
+
const url = new URL(base);
|
|
338
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
|
|
339
|
+
return url;
|
|
340
|
+
}
|
|
341
|
+
async function presign(method, url, expiresIn, headers = {}) {
|
|
342
|
+
url.searchParams.set("X-Amz-Expires", String(expiresIn));
|
|
343
|
+
return (await client.sign(new Request(url, {
|
|
344
|
+
method,
|
|
345
|
+
headers
|
|
346
|
+
}), { aws: {
|
|
347
|
+
signQuery: true,
|
|
348
|
+
allHeaders: true
|
|
349
|
+
} })).url;
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
name: "s3",
|
|
353
|
+
async presignPut({ key, contentType, contentLength, expiresIn }) {
|
|
354
|
+
const signedHeaders = {
|
|
355
|
+
"content-type": contentType,
|
|
356
|
+
"content-length": String(contentLength)
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
url: await presign("PUT", objectUrl(key), expiresIn, signedHeaders),
|
|
360
|
+
headers: { "content-type": contentType }
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
async createMultipart({ key, contentType }) {
|
|
364
|
+
const uploadId = readTag(await assertOk(await client.fetch(objectUrl(key, { uploads: "" }), {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: { "content-type": contentType }
|
|
367
|
+
}), "CreateMultipartUpload"), "UploadId");
|
|
368
|
+
if (!uploadId) throw new S3StorageError("CreateMultipartUpload returned no UploadId", 502);
|
|
369
|
+
return { uploadId };
|
|
370
|
+
},
|
|
371
|
+
presignPart({ key, uploadId, partNumber, expiresIn }) {
|
|
372
|
+
return presign("PUT", objectUrl(key, {
|
|
373
|
+
partNumber: String(partNumber),
|
|
374
|
+
uploadId
|
|
375
|
+
}), expiresIn);
|
|
376
|
+
},
|
|
377
|
+
async listParts({ key, uploadId }) {
|
|
378
|
+
const parts = [];
|
|
379
|
+
let marker;
|
|
380
|
+
for (;;) {
|
|
381
|
+
const query = { uploadId };
|
|
382
|
+
if (marker) query["part-number-marker"] = marker;
|
|
383
|
+
const body = await assertOk(await client.fetch(objectUrl(key, query), { method: "GET" }), "ListParts");
|
|
384
|
+
for (const part of readAll(body, "Part")) parts.push({
|
|
385
|
+
partNumber: Number(readTag(part, "PartNumber")),
|
|
386
|
+
etag: readTag(part, "ETag") ?? "",
|
|
387
|
+
size: Number(readTag(part, "Size"))
|
|
388
|
+
});
|
|
389
|
+
if (readTag(body, "IsTruncated") !== "true") break;
|
|
390
|
+
marker = readTag(body, "NextPartNumberMarker");
|
|
391
|
+
if (!marker) break;
|
|
392
|
+
}
|
|
393
|
+
return parts;
|
|
394
|
+
},
|
|
395
|
+
async completeMultipart({ key, uploadId, parts }) {
|
|
396
|
+
const xml = "<CompleteMultipartUpload>" + [...parts].sort((a, b) => a.partNumber - b.partNumber).map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("") + "</CompleteMultipartUpload>";
|
|
397
|
+
return { etag: readTag(await assertOk(await client.fetch(objectUrl(key, { uploadId }), {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: { "content-type": "application/xml" },
|
|
400
|
+
body: xml
|
|
401
|
+
}), "CompleteMultipartUpload"), "ETag") };
|
|
402
|
+
},
|
|
403
|
+
async abortMultipart({ key, uploadId }) {
|
|
404
|
+
const response = await client.fetch(objectUrl(key, { uploadId }), { method: "DELETE" });
|
|
405
|
+
if (response.status !== 404) await assertOk(response, "AbortMultipartUpload");
|
|
406
|
+
},
|
|
407
|
+
async headObject(key) {
|
|
408
|
+
const response = await client.fetch(objectUrl(key), { method: "HEAD" });
|
|
409
|
+
if (response.status === 404) return null;
|
|
410
|
+
if (!response.ok) await assertOk(response, "HeadObject");
|
|
411
|
+
return {
|
|
412
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
413
|
+
etag: response.headers.get("etag") ?? void 0,
|
|
414
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
async deleteObject(key) {
|
|
418
|
+
const response = await client.fetch(objectUrl(key), { method: "DELETE" });
|
|
419
|
+
if (response.status !== 404) await assertOk(response, "DeleteObject");
|
|
420
|
+
},
|
|
421
|
+
async getObject(key) {
|
|
422
|
+
const response = await client.fetch(objectUrl(key));
|
|
423
|
+
if (response.status === 404) return null;
|
|
424
|
+
if (!response.ok) await assertOk(response, "GetObject");
|
|
425
|
+
return response.body;
|
|
426
|
+
},
|
|
427
|
+
async getUrl(key) {
|
|
428
|
+
if (options.publicUrl) return `${options.publicUrl.replace(/\/+$/, "")}/${encodeKey(key)}`;
|
|
429
|
+
return presign("GET", objectUrl(key), readExpiry);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/** Cloudflare R2 through its S3-compatible API. */
|
|
434
|
+
function r2Storage({ accountId, jurisdiction, ...options }) {
|
|
435
|
+
const host = jurisdiction ? `${accountId}.${jurisdiction}.r2.cloudflarestorage.com` : `${accountId}.r2.cloudflarestorage.com`;
|
|
436
|
+
return {
|
|
437
|
+
...s3Storage({
|
|
438
|
+
...options,
|
|
439
|
+
region: "auto",
|
|
440
|
+
endpoint: `https://${host}`,
|
|
441
|
+
forcePathStyle: true
|
|
442
|
+
}),
|
|
443
|
+
name: "r2"
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/memory.ts
|
|
448
|
+
/**
|
|
449
|
+
* A development storage that accepts uploads in-process and keeps only
|
|
450
|
+
* metadata (size, ETag) — the bytes are discarded. It implements the full
|
|
451
|
+
* presigned + multipart flow with HMAC-signed URLs, so you can build and
|
|
452
|
+
* test upload UIs without cloud credentials. Not for production.
|
|
453
|
+
*/
|
|
454
|
+
function createMemoryStorage(options) {
|
|
455
|
+
const objects = /* @__PURE__ */ new Map();
|
|
456
|
+
const maxBodySize = options.maxBodySize ?? 5 * 1024 ** 3;
|
|
457
|
+
const { base, sign, verify } = createUrlSigner(options.secret, options.baseUrl);
|
|
458
|
+
const storage = {
|
|
459
|
+
name: "memory",
|
|
460
|
+
async presignPut({ key, contentType, contentLength, expiresIn }) {
|
|
461
|
+
return {
|
|
462
|
+
url: await sign({
|
|
463
|
+
key,
|
|
464
|
+
method: "PUT",
|
|
465
|
+
size: contentLength,
|
|
466
|
+
expires: Date.now() + expiresIn * 1e3
|
|
467
|
+
}),
|
|
468
|
+
headers: { "content-type": contentType }
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
async createMultipart() {
|
|
472
|
+
return { uploadId: crypto.randomUUID() };
|
|
473
|
+
},
|
|
474
|
+
presignPart({ key, uploadId, partNumber, expiresIn }) {
|
|
475
|
+
return sign({
|
|
476
|
+
key,
|
|
477
|
+
method: "PUT",
|
|
478
|
+
uploadId,
|
|
479
|
+
partNumber,
|
|
480
|
+
expires: Date.now() + expiresIn * 1e3
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
async listParts() {
|
|
484
|
+
return null;
|
|
485
|
+
},
|
|
486
|
+
async completeMultipart({ key, parts }) {
|
|
487
|
+
const size = parts.reduce((total, part) => total + Number(part.etag.replace(/"/g, "").split("-")[0]), 0);
|
|
488
|
+
const etag = `"${parts.length}-${size}"`;
|
|
489
|
+
objects.set(key, {
|
|
490
|
+
size,
|
|
491
|
+
etag
|
|
492
|
+
});
|
|
493
|
+
return { etag };
|
|
494
|
+
},
|
|
495
|
+
async abortMultipart() {},
|
|
496
|
+
async headObject(key) {
|
|
497
|
+
return objects.get(key);
|
|
498
|
+
},
|
|
499
|
+
async deleteObject(key) {
|
|
500
|
+
objects.delete(key);
|
|
501
|
+
},
|
|
502
|
+
async getUrl(key) {
|
|
503
|
+
return `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
/** Handles the signed PUT requests. Mount it at `baseUrl`. */
|
|
507
|
+
async function handler(request) {
|
|
508
|
+
if (request.method !== "PUT") return new Response("Method not allowed", {
|
|
509
|
+
status: 405,
|
|
510
|
+
headers: { allow: "PUT" }
|
|
511
|
+
});
|
|
512
|
+
const token = await verify(new URL(request.url).searchParams.get("token"));
|
|
513
|
+
if (!token || token.method !== "PUT") return new Response("Invalid or expired signature", { status: 403 });
|
|
514
|
+
if (Number(request.headers.get("content-length") ?? NaN) > maxBodySize) return new Response("Payload too large", { status: 413 });
|
|
515
|
+
const body = await request.arrayBuffer();
|
|
516
|
+
if (token.size != null && body.byteLength !== token.size) return new Response("Body size does not match the signed size", { status: 400 });
|
|
517
|
+
const etag = `"${body.byteLength}-${await sha256Hex(body)}"`;
|
|
518
|
+
if (token.uploadId == null) objects.set(token.key, {
|
|
519
|
+
size: body.byteLength,
|
|
520
|
+
etag,
|
|
521
|
+
contentType: request.headers.get("content-type") ?? void 0
|
|
522
|
+
});
|
|
523
|
+
return new Response(null, {
|
|
524
|
+
status: 200,
|
|
525
|
+
headers: {
|
|
526
|
+
etag,
|
|
527
|
+
"access-control-expose-headers": "ETag"
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
storage,
|
|
533
|
+
handler
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/cloudinary.ts
|
|
538
|
+
/**
|
|
539
|
+
* Signs Cloudinary upload parameters with your API secret, for
|
|
540
|
+
* `cloudinaryAdapter({ sign })`. Run it on the server only.
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* // app/api/cloudinary/route.ts
|
|
544
|
+
* export async function POST(request: Request) {
|
|
545
|
+
* const params = await request.json()
|
|
546
|
+
* if (params.folder !== "avatars") return new Response(null, { status: 403 })
|
|
547
|
+
* return Response.json(
|
|
548
|
+
* await signCloudinaryParams(params, {
|
|
549
|
+
* apiKey: process.env.CLOUDINARY_API_KEY!,
|
|
550
|
+
* apiSecret: process.env.CLOUDINARY_API_SECRET!,
|
|
551
|
+
* })
|
|
552
|
+
* )
|
|
553
|
+
* }
|
|
554
|
+
*/
|
|
555
|
+
async function signCloudinaryParams(params, { apiKey, apiSecret, timestamp = Math.floor(Date.now() / 1e3) }) {
|
|
556
|
+
if (!apiSecret) throw new Error("signCloudinaryParams needs an API secret");
|
|
557
|
+
const signed = {
|
|
558
|
+
...params,
|
|
559
|
+
timestamp
|
|
560
|
+
};
|
|
561
|
+
const excluded = /* @__PURE__ */ new Set([
|
|
562
|
+
"file",
|
|
563
|
+
"cloud_name",
|
|
564
|
+
"resource_type",
|
|
565
|
+
"api_key",
|
|
566
|
+
"signature"
|
|
567
|
+
]);
|
|
568
|
+
const payload = Object.keys(signed).filter((key) => !excluded.has(key) && signed[key] !== "").sort().map((key) => `${key}=${signed[key]}`).join("&");
|
|
569
|
+
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(payload + apiSecret));
|
|
570
|
+
return {
|
|
571
|
+
signature: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""),
|
|
572
|
+
timestamp,
|
|
573
|
+
apiKey
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
//#endregion
|
|
577
|
+
export { S3StorageError, UploadRouteError, createMemoryStorage, createUploadRoute, r2Storage, s3Storage, sanitizeFileName, signCloudinaryParams };
|
|
578
|
+
|
|
579
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/route.ts","../src/s3.ts","../src/memory.ts","../src/cloudinary.ts"],"sourcesContent":["import {\n type FileDescriptor,\n type StoredObject,\n type UploadRouteAction,\n type UploadRouteRequest,\n matchesAccept,\n} from \"@uploadcn/core\"\n\nimport type { ScanResult, Scanner } from \"./scan\"\nimport type { UploadStorage } from \"./storage\"\n\nexport class UploadRouteError extends Error {\n override readonly name = \"UploadRouteError\"\n constructor(\n message: string,\n readonly status = 400,\n /** `\"rejected\"` tells the client the file itself was refused (no retry). */\n readonly code?: \"rejected\"\n ) {\n super(message)\n }\n}\n\nexport interface UploadScanOptions<TAuth> {\n /** `clamavScanner()`, `virusTotalScanner()`, `httpScanner()` or your own. */\n scanner: Scanner\n /**\n * What to do when the scanner can't decide, or fails: `\"reject\"` (default,\n * fail closed) deletes the file; `\"allow\"` keeps it.\n */\n onUnknown?: \"reject\" | \"allow\"\n /** Files larger than this (bytes) are not scanned and count as unknown. */\n maxFileSize?: number\n /** Every verdict, for logging, alerts or an audit trail. */\n onResult?: (\n context: UploadRouteContext<TAuth> & {\n key: string\n file: FileDescriptor\n result: ScanResult\n }\n ) => void | Promise<void>\n}\n\nexport interface UploadRouteContext<TAuth> {\n request: Request\n action: UploadRouteAction\n /** Value returned by `authorize`. */\n auth: TAuth\n}\n\nexport interface UploadRouteOptions<TAuth = undefined> {\n storage: UploadStorage\n /**\n * Runs before every action. Return a value (user, tenant…) to pass along,\n * or throw `UploadRouteError` to deny. For multipart actions, `key` is\n * provided so you can verify the caller owns it.\n */\n authorize?: (context: {\n request: Request\n action: UploadRouteAction\n key?: string\n }) => TAuth | Promise<TAuth>\n /** Largest allowed file in bytes. Enforced by the signature for single PUTs. */\n maxFileSize?: number\n /** Allowed types, in `<input accept>` syntax (`image/*`, `.pdf`…). */\n allowedTypes?: string | readonly string[]\n /** Builds the object key. Default: `<uuid>/<sanitized file name>`. */\n getKey?: (\n context: UploadRouteContext<TAuth> & {\n file: FileDescriptor\n meta: Record<string, unknown>\n }\n ) => string | Promise<string>\n /** Signed URL lifetime in seconds. Default 3600. */\n expiresIn?: number\n /**\n * Called once an upload is stored, verified and (with `scan`) found clean:\n * save metadata, trigger processing. The return value is sent to the\n * client as `result.data`.\n */\n onUploadComplete?: (\n context: UploadRouteContext<TAuth> & {\n key: string\n url: string\n file: FileDescriptor\n meta: Record<string, unknown>\n /** The scan verdict, when `scan` is set. */\n scan?: ScanResult\n }\n ) => unknown\n /** Check that the object exists (HEAD) before completing. Default `true`. */\n verify?: boolean\n /**\n * Scan every upload for malware before it completes. Infected files are\n * deleted and the client sees the item as rejected. Requires a storage\n * that can read objects (`s3Storage`, `r2Storage`, `createFileSystemStorage`).\n */\n scan?: UploadScanOptions<TAuth>\n}\n\nconst MAX_PARTS = 10_000\n\nexport function sanitizeFileName(name: string) {\n const cleaned = name\n .normalize(\"NFKD\")\n .replace(/[̀-ͯ]/g, \"\")\n .replace(/[^\\w.\\- ]+/g, \"\")\n .replace(/\\s+/g, \"-\")\n .replace(/^[.-]+/, \"\")\n .slice(-120)\n return cleaned || \"file\"\n}\n\nfunction json(data: unknown, status = 200) {\n return new Response(JSON.stringify(data), {\n status,\n headers: {\n \"content-type\": \"application/json\",\n \"cache-control\": \"no-store\",\n },\n })\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n\nfunction requireString(body: Record<string, unknown>, field: string) {\n const value = body[field]\n if (typeof value !== \"string\" || value.length === 0 || value.length > 1024) {\n throw new UploadRouteError(`Invalid \"${field}\"`)\n }\n return value\n}\n\nfunction requireFile(body: Record<string, unknown>): FileDescriptor {\n const file = body.file\n if (!isRecord(file)) throw new UploadRouteError('Invalid \"file\"')\n const { name, type, size } = file\n if (\n typeof name !== \"string\" ||\n typeof type !== \"string\" ||\n typeof size !== \"number\"\n ) {\n throw new UploadRouteError('Invalid \"file\"')\n }\n if (!Number.isSafeInteger(size) || size < 0)\n throw new UploadRouteError(\"Invalid file size\")\n return {\n name: name.slice(0, 255),\n type: type || \"application/octet-stream\",\n size,\n }\n}\n\nfunction readMeta(body: Record<string, unknown>) {\n return isRecord(body.meta) ? body.meta : {}\n}\n\nfunction parseRequest(body: unknown): UploadRouteRequest {\n if (!isRecord(body)) throw new UploadRouteError(\"Expected a JSON object\")\n const action = body.action\n switch (action) {\n case \"presign\":\n case \"create-multipart\":\n return { action, file: requireFile(body), meta: readMeta(body) }\n case \"complete\":\n return {\n action,\n key: requireString(body, \"key\"),\n file: requireFile(body),\n meta: readMeta(body),\n }\n case \"sign-part\": {\n const partNumber = body.partNumber\n if (\n typeof partNumber !== \"number\" ||\n !Number.isInteger(partNumber) ||\n partNumber < 1 ||\n partNumber > MAX_PARTS\n ) {\n throw new UploadRouteError('Invalid \"partNumber\"')\n }\n return {\n action,\n key: requireString(body, \"key\"),\n uploadId: requireString(body, \"uploadId\"),\n partNumber,\n }\n }\n case \"list-parts\":\n case \"abort-multipart\":\n return {\n action,\n key: requireString(body, \"key\"),\n uploadId: requireString(body, \"uploadId\"),\n }\n case \"complete-multipart\": {\n const parts = body.parts\n if (\n !Array.isArray(parts) ||\n parts.length === 0 ||\n parts.length > MAX_PARTS\n ) {\n throw new UploadRouteError('Invalid \"parts\"')\n }\n return {\n action,\n key: requireString(body, \"key\"),\n uploadId: requireString(body, \"uploadId\"),\n file: requireFile(body),\n meta: readMeta(body),\n parts: parts.map((part) => {\n if (\n !isRecord(part) ||\n typeof part.partNumber !== \"number\" ||\n typeof part.etag !== \"string\"\n ) {\n throw new UploadRouteError('Invalid \"parts\"')\n }\n return { partNumber: part.partNumber, etag: part.etag }\n }),\n }\n }\n default:\n throw new UploadRouteError('Unknown \"action\"')\n }\n}\n\n/**\n * A single POST endpoint that signs uploads for the browser's `s3Adapter`.\n * It speaks Web standard `Request`/`Response`, so it mounts directly in\n * Next.js route handlers, TanStack Start server routes, Hono, Remix, Bun,\n * Deno and Cloudflare Workers.\n *\n * @example\n * // app/api/upload/route.ts\n * export const { POST } = createUploadRoute({ storage: s3Storage({...}) })\n */\nexport function createUploadRoute<TAuth = undefined>(\n options: UploadRouteOptions<TAuth>\n) {\n const { storage } = options\n const expiresIn = options.expiresIn ?? 3600\n if (options.scan && !storage.getObject) {\n throw new Error(\n `[uploadcn] scan needs a storage that can read objects; \"${storage.name}\" can't`\n )\n }\n\n async function scanObject(\n context: UploadRouteContext<TAuth>,\n key: string,\n file: FileDescriptor,\n size: number | undefined\n ): Promise<ScanResult> {\n const scan = options.scan!\n let result: ScanResult\n try {\n if (scan.maxFileSize != null && (size ?? file.size) > scan.maxFileSize) {\n result = {\n status: \"unknown\",\n threats: [],\n scanner: scan.scanner.name,\n details: { skipped: \"too large\" },\n }\n } else {\n const stream = await storage.getObject!(key)\n if (!stream) throw new UploadRouteError(\"Upload not found\", 409)\n result = await scan.scanner.scan({\n stream,\n file,\n key,\n signal: context.request.signal,\n })\n }\n } catch (error) {\n if (error instanceof UploadRouteError) throw error\n console.error(\"[uploadcn] virus scan failed\", error)\n result = {\n status: \"unknown\",\n threats: [],\n scanner: scan.scanner.name,\n details: {\n error: error instanceof Error ? error.message : String(error),\n },\n }\n }\n await scan.onResult?.({ ...context, key, file, result })\n const reject =\n result.status === \"infected\" ||\n (result.status === \"unknown\" && (scan.onUnknown ?? \"reject\") === \"reject\")\n if (reject) {\n await storage.deleteObject(key)\n throw new UploadRouteError(\n result.status === \"infected\"\n ? \"This file contains malware and was removed\"\n : \"This file couldn't be scanned and was removed\",\n 422,\n \"rejected\"\n )\n }\n return result\n }\n\n function checkFile(file: FileDescriptor) {\n if (options.maxFileSize != null && file.size > options.maxFileSize) {\n throw new UploadRouteError(\"File is too large\", 413)\n }\n if (options.allowedTypes && !matchesAccept(file, options.allowedTypes)) {\n throw new UploadRouteError(\"File type is not allowed\", 415)\n }\n }\n\n async function finalize(\n context: UploadRouteContext<TAuth>,\n key: string,\n file: FileDescriptor,\n meta: Record<string, unknown>,\n etag?: string\n ): Promise<StoredObject> {\n let size: number | undefined\n if (options.verify ?? true) {\n const info = await storage.headObject(key)\n size = info?.size\n if (info === null) throw new UploadRouteError(\"Upload not found\", 409)\n if (\n info &&\n options.maxFileSize != null &&\n info.size > options.maxFileSize\n ) {\n await storage.deleteObject(key)\n throw new UploadRouteError(\"File is too large\", 413)\n }\n etag ??= info?.etag\n }\n const scan = options.scan\n ? await scanObject(context, key, file, size)\n : undefined\n const url = await storage.getUrl(key)\n const data = await options.onUploadComplete?.({\n ...context,\n key,\n url,\n file,\n meta,\n scan,\n })\n return {\n key,\n url,\n etag,\n ...(data === undefined ? {} : { data }),\n ...(scan\n ? {\n scan: {\n status: scan.status === \"clean\" ? \"clean\" : \"unknown\",\n scanner: scan.scanner,\n },\n }\n : {}),\n }\n }\n\n async function handle(request: Request): Promise<Response> {\n try {\n if (request.method !== \"POST\") {\n return json({ error: \"Method not allowed\" }, 405)\n }\n let body: unknown\n try {\n body = await request.json()\n } catch {\n throw new UploadRouteError(\"Expected a JSON body\")\n }\n const input = parseRequest(body)\n const auth = (await options.authorize?.({\n request,\n action: input.action,\n key: \"key\" in input ? input.key : undefined,\n })) as TAuth\n const context: UploadRouteContext<TAuth> = {\n request,\n action: input.action,\n auth,\n }\n\n switch (input.action) {\n case \"presign\": {\n checkFile(input.file)\n const key = await resolveKey(context, input.file, input.meta ?? {})\n const signed = await storage.presignPut({\n key,\n contentType: input.file.type,\n contentLength: input.file.size,\n expiresIn,\n })\n return json({\n key,\n url: signed.url,\n method: \"PUT\",\n headers: signed.headers,\n })\n }\n case \"complete\":\n checkFile(input.file)\n return json(\n await finalize(context, input.key, input.file, input.meta ?? {})\n )\n case \"create-multipart\": {\n checkFile(input.file)\n const key = await resolveKey(context, input.file, input.meta ?? {})\n const { uploadId } = await storage.createMultipart({\n key,\n contentType: input.file.type,\n })\n return json({ key, uploadId })\n }\n case \"sign-part\":\n return json({\n url: await storage.presignPart({ ...input, expiresIn }),\n })\n case \"list-parts\":\n return json({ parts: await storage.listParts(input) })\n case \"complete-multipart\": {\n checkFile(input.file)\n const { etag } = await storage.completeMultipart(input)\n return json(\n await finalize(\n context,\n input.key,\n input.file,\n input.meta ?? {},\n etag\n )\n )\n }\n case \"abort-multipart\":\n await storage.abortMultipart(input)\n return json({ ok: true })\n }\n } catch (error) {\n if (error instanceof UploadRouteError) {\n return json(\n error.code\n ? { error: error.message, code: error.code }\n : { error: error.message },\n error.status\n )\n }\n console.error(\"[uploadcn] upload route error\", error)\n return json({ error: \"Upload failed\" }, 500)\n }\n }\n\n async function resolveKey(\n context: UploadRouteContext<TAuth>,\n file: FileDescriptor,\n meta: Record<string, unknown>\n ) {\n if (options.getKey) return options.getKey({ ...context, file, meta })\n return `${crypto.randomUUID()}/${sanitizeFileName(file.name)}`\n }\n\n return { POST: handle, handler: handle }\n}\n","import { AwsClient } from \"aws4fetch\"\n\nimport type { PresignedRequest, StoragePart, UploadStorage } from \"./storage\"\n\nexport interface S3StorageOptions {\n bucket: string\n /** AWS region, or `\"auto\"` for R2. */\n region: string\n accessKeyId: string\n secretAccessKey: string\n sessionToken?: string\n /**\n * Custom endpoint for S3-compatible services (R2, MinIO, B2, Spaces…).\n * Defaults to AWS.\n */\n endpoint?: string\n /** Use `endpoint/bucket/key` URLs. Defaults to `true` with a custom endpoint. */\n forcePathStyle?: boolean\n /**\n * Base URL for reading objects (a CDN or public bucket domain). Without\n * it, `getUrl` returns a signed GET URL valid for `readUrlExpiresIn`.\n */\n publicUrl?: string\n /** Seconds. Default 3600. */\n readUrlExpiresIn?: number\n}\n\nfunction encodeKey(key: string) {\n return key.split(\"/\").map(encodeURIComponent).join(\"/\")\n}\n\nfunction escapeXml(value: string) {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\")\n}\n\nfunction unescapeXml(value: string) {\n return value\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/&/g, \"&\")\n}\n\nfunction readTag(xml: string, tag: string) {\n const match = xml.match(new RegExp(`<${tag}>([\\\\s\\\\S]*?)</${tag}>`))\n return match ? unescapeXml(match[1]!) : undefined\n}\n\nfunction readAll(xml: string, tag: string) {\n return [\n ...xml.matchAll(new RegExp(`<${tag}>([\\\\s\\\\S]*?)</${tag}>`, \"g\")),\n ].map((match) => match[1]!)\n}\n\nexport class S3StorageError extends Error {\n override readonly name = \"S3StorageError\"\n constructor(\n message: string,\n readonly status: number,\n readonly code?: string\n ) {\n super(message)\n }\n}\n\nasync function assertOk(response: Response, operation: string) {\n const body = await response.text()\n // CompleteMultipartUpload can fail with a 200 and an <Error> body.\n if (response.ok && !body.includes(\"<Error>\")) return body\n const code = readTag(body, \"Code\")\n const message = readTag(body, \"Message\") ?? response.statusText\n throw new S3StorageError(\n `${operation} failed: ${code ?? response.status} ${message}`,\n response.status,\n code\n )\n}\n\n/**\n * Amazon S3 and any S3-compatible service, signed with SigV4 via aws4fetch.\n * Runs on Node, Bun, Deno, Cloudflare Workers and edge runtimes.\n */\nexport function s3Storage(options: S3StorageOptions): UploadStorage {\n const client = new AwsClient({\n accessKeyId: options.accessKeyId,\n secretAccessKey: options.secretAccessKey,\n sessionToken: options.sessionToken,\n service: \"s3\",\n region: options.region,\n })\n const pathStyle = options.forcePathStyle ?? !!options.endpoint\n const endpoint = (\n options.endpoint ?? `https://s3.${options.region}.amazonaws.com`\n ).replace(/\\/+$/, \"\")\n const readExpiry = options.readUrlExpiresIn ?? 3600\n\n function objectUrl(key: string, query: Record<string, string> = {}) {\n const base = pathStyle\n ? `${endpoint}/${options.bucket}/${encodeKey(key)}`\n : `${endpoint.replace(\"://\", `://${options.bucket}.`)}/${encodeKey(key)}`\n const url = new URL(base)\n for (const [name, value] of Object.entries(query))\n url.searchParams.set(name, value)\n return url\n }\n\n async function presign(\n method: string,\n url: URL,\n expiresIn: number,\n headers: Record<string, string> = {}\n ): Promise<string> {\n url.searchParams.set(\"X-Amz-Expires\", String(expiresIn))\n const signed = await client.sign(new Request(url, { method, headers }), {\n aws: { signQuery: true, allHeaders: true },\n })\n return signed.url\n }\n\n return {\n name: \"s3\",\n\n async presignPut({\n key,\n contentType,\n contentLength,\n expiresIn,\n }): Promise<PresignedRequest> {\n // Signing content-length makes S3 reject bodies of any other size.\n const signedHeaders = {\n \"content-type\": contentType,\n \"content-length\": String(contentLength),\n }\n const url = await presign(\"PUT\", objectUrl(key), expiresIn, signedHeaders)\n // Browsers set Content-Length themselves; only send Content-Type.\n return { url, headers: { \"content-type\": contentType } }\n },\n\n async createMultipart({ key, contentType }) {\n const response = await client.fetch(objectUrl(key, { uploads: \"\" }), {\n method: \"POST\",\n headers: { \"content-type\": contentType },\n })\n const body = await assertOk(response, \"CreateMultipartUpload\")\n const uploadId = readTag(body, \"UploadId\")\n if (!uploadId)\n throw new S3StorageError(\n \"CreateMultipartUpload returned no UploadId\",\n 502\n )\n return { uploadId }\n },\n\n presignPart({ key, uploadId, partNumber, expiresIn }) {\n return presign(\n \"PUT\",\n objectUrl(key, { partNumber: String(partNumber), uploadId }),\n expiresIn\n )\n },\n\n async listParts({ key, uploadId }) {\n const parts: StoragePart[] = []\n let marker: string | undefined\n for (;;) {\n const query: Record<string, string> = { uploadId }\n if (marker) query[\"part-number-marker\"] = marker\n const response = await client.fetch(objectUrl(key, query), {\n method: \"GET\",\n })\n const body = await assertOk(response, \"ListParts\")\n for (const part of readAll(body, \"Part\")) {\n parts.push({\n partNumber: Number(readTag(part, \"PartNumber\")),\n etag: readTag(part, \"ETag\") ?? \"\",\n size: Number(readTag(part, \"Size\")),\n })\n }\n if (readTag(body, \"IsTruncated\") !== \"true\") break\n marker = readTag(body, \"NextPartNumberMarker\")\n if (!marker) break\n }\n return parts\n },\n\n async completeMultipart({ key, uploadId, parts }) {\n const xml =\n \"<CompleteMultipartUpload>\" +\n [...parts]\n .sort((a, b) => a.partNumber - b.partNumber)\n .map(\n (part) =>\n `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`\n )\n .join(\"\") +\n \"</CompleteMultipartUpload>\"\n const response = await client.fetch(objectUrl(key, { uploadId }), {\n method: \"POST\",\n headers: { \"content-type\": \"application/xml\" },\n body: xml,\n })\n const body = await assertOk(response, \"CompleteMultipartUpload\")\n return { etag: readTag(body, \"ETag\") }\n },\n\n async abortMultipart({ key, uploadId }) {\n const response = await client.fetch(objectUrl(key, { uploadId }), {\n method: \"DELETE\",\n })\n // 404 means it is already gone, which is what we wanted.\n if (response.status !== 404)\n await assertOk(response, \"AbortMultipartUpload\")\n },\n\n async headObject(key) {\n const response = await client.fetch(objectUrl(key), { method: \"HEAD\" })\n if (response.status === 404) return null\n if (!response.ok) await assertOk(response, \"HeadObject\")\n return {\n size: Number(response.headers.get(\"content-length\") ?? 0),\n etag: response.headers.get(\"etag\") ?? undefined,\n contentType: response.headers.get(\"content-type\") ?? undefined,\n }\n },\n\n async deleteObject(key) {\n const response = await client.fetch(objectUrl(key), { method: \"DELETE\" })\n if (response.status !== 404) await assertOk(response, \"DeleteObject\")\n },\n\n async getObject(key) {\n const response = await client.fetch(objectUrl(key))\n if (response.status === 404) return null\n if (!response.ok) await assertOk(response, \"GetObject\")\n return response.body\n },\n\n async getUrl(key) {\n if (options.publicUrl) {\n return `${options.publicUrl.replace(/\\/+$/, \"\")}/${encodeKey(key)}`\n }\n return presign(\"GET\", objectUrl(key), readExpiry)\n },\n }\n}\n\nexport interface R2StorageOptions extends Omit<\n S3StorageOptions,\n \"region\" | \"endpoint\" | \"forcePathStyle\"\n> {\n /** Your Cloudflare account id. */\n accountId: string\n /** `\"eu\"` or `\"fedramp\"` for jurisdiction-restricted buckets. */\n jurisdiction?: \"eu\" | \"fedramp\"\n}\n\n/** Cloudflare R2 through its S3-compatible API. */\nexport function r2Storage({\n accountId,\n jurisdiction,\n ...options\n}: R2StorageOptions): UploadStorage {\n const host = jurisdiction\n ? `${accountId}.${jurisdiction}.r2.cloudflarestorage.com`\n : `${accountId}.r2.cloudflarestorage.com`\n return {\n ...s3Storage({\n ...options,\n region: \"auto\",\n endpoint: `https://${host}`,\n forcePathStyle: true,\n }),\n name: \"r2\",\n }\n}\n","import { createUrlSigner, sha256Hex } from \"./signing\"\nimport type { StoredObjectInfo, UploadStorage } from \"./storage\"\n\nexport interface MemoryStorageOptions {\n /**\n * Absolute or root-relative URL where `handler` is mounted, e.g.\n * `/api/storage`. Presigned URLs point here.\n */\n baseUrl: string\n /** HMAC secret used to sign URLs. Use a random value per deployment. */\n secret: string\n /** Largest accepted request body, in bytes. Default 5 GiB. */\n maxBodySize?: number\n}\n\n/**\n * A development storage that accepts uploads in-process and keeps only\n * metadata (size, ETag) — the bytes are discarded. It implements the full\n * presigned + multipart flow with HMAC-signed URLs, so you can build and\n * test upload UIs without cloud credentials. Not for production.\n */\nexport function createMemoryStorage(options: MemoryStorageOptions) {\n const objects = new Map<string, StoredObjectInfo>()\n const maxBodySize = options.maxBodySize ?? 5 * 1024 ** 3\n const { base, sign, verify } = createUrlSigner(\n options.secret,\n options.baseUrl\n )\n\n const storage: UploadStorage = {\n name: \"memory\",\n async presignPut({ key, contentType, contentLength, expiresIn }) {\n const url = await sign({\n key,\n method: \"PUT\",\n size: contentLength,\n expires: Date.now() + expiresIn * 1000,\n })\n return { url, headers: { \"content-type\": contentType } }\n },\n async createMultipart() {\n return { uploadId: crypto.randomUUID() }\n },\n presignPart({ key, uploadId, partNumber, expiresIn }) {\n return sign({\n key,\n method: \"PUT\",\n uploadId,\n partNumber,\n expires: Date.now() + expiresIn * 1000,\n })\n },\n // Parts are not tracked between requests; clients keep their own record.\n async listParts() {\n return null\n },\n async completeMultipart({ key, parts }) {\n // Part ETags encode their size, so completion needs no stored state.\n const size = parts.reduce(\n (total, part) =>\n total + Number(part.etag.replace(/\"/g, \"\").split(\"-\")[0]),\n 0\n )\n const etag = `\"${parts.length}-${size}\"`\n objects.set(key, { size, etag })\n return { etag }\n },\n async abortMultipart() {},\n async headObject(key) {\n return objects.get(key)\n },\n async deleteObject(key) {\n objects.delete(key)\n },\n async getUrl(key) {\n return `${base}/${key.split(\"/\").map(encodeURIComponent).join(\"/\")}`\n },\n }\n\n /** Handles the signed PUT requests. Mount it at `baseUrl`. */\n async function handler(request: Request): Promise<Response> {\n if (request.method !== \"PUT\") {\n return new Response(\"Method not allowed\", {\n status: 405,\n headers: { allow: \"PUT\" },\n })\n }\n const token = await verify(new URL(request.url).searchParams.get(\"token\"))\n if (!token || token.method !== \"PUT\")\n return new Response(\"Invalid or expired signature\", { status: 403 })\n const declared = Number(request.headers.get(\"content-length\") ?? NaN)\n if (declared > maxBodySize)\n return new Response(\"Payload too large\", { status: 413 })\n const body = await request.arrayBuffer()\n if (token.size != null && body.byteLength !== token.size) {\n return new Response(\"Body size does not match the signed size\", {\n status: 400,\n })\n }\n const etag = `\"${body.byteLength}-${await sha256Hex(body)}\"`\n if (token.uploadId == null) {\n objects.set(token.key, {\n size: body.byteLength,\n etag,\n contentType: request.headers.get(\"content-type\") ?? undefined,\n })\n }\n return new Response(null, {\n status: 200,\n headers: { etag, \"access-control-expose-headers\": \"ETag\" },\n })\n }\n\n return { storage, handler }\n}\n","/**\n * Signs Cloudinary upload parameters with your API secret, for\n * `cloudinaryAdapter({ sign })`. Run it on the server only.\n *\n * @example\n * // app/api/cloudinary/route.ts\n * export async function POST(request: Request) {\n * const params = await request.json()\n * if (params.folder !== \"avatars\") return new Response(null, { status: 403 })\n * return Response.json(\n * await signCloudinaryParams(params, {\n * apiKey: process.env.CLOUDINARY_API_KEY!,\n * apiSecret: process.env.CLOUDINARY_API_SECRET!,\n * })\n * )\n * }\n */\nexport async function signCloudinaryParams(\n params: Record<string, string | number>,\n {\n apiKey,\n apiSecret,\n timestamp = Math.floor(Date.now() / 1000),\n }: { apiKey: string; apiSecret: string; timestamp?: number }\n) {\n if (!apiSecret) throw new Error(\"signCloudinaryParams needs an API secret\")\n const signed: Record<string, string | number> = { ...params, timestamp }\n // Cloudinary signs every parameter except these, sorted by name.\n const excluded = new Set([\n \"file\",\n \"cloud_name\",\n \"resource_type\",\n \"api_key\",\n \"signature\",\n ])\n const payload = Object.keys(signed)\n .filter((key) => !excluded.has(key) && signed[key] !== \"\")\n .sort()\n .map((key) => `${key}=${signed[key]}`)\n .join(\"&\")\n const digest = await crypto.subtle.digest(\n \"SHA-1\",\n new TextEncoder().encode(payload + apiSecret)\n )\n const signature = [...new Uint8Array(digest)]\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\")\n return { signature, timestamp, apiKey }\n}\n"],"mappings":"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"}
|