@useupup/server 3.3.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.
@@ -0,0 +1,2358 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/providers/s3-client.ts
12
+ import { S3Client } from "@aws-sdk/client-s3";
13
+ function buildS3ClientConfig(storage) {
14
+ const config = { region: storage.region };
15
+ if (storage.accessKeyId && storage.secretAccessKey) {
16
+ config.credentials = {
17
+ accessKeyId: storage.accessKeyId,
18
+ secretAccessKey: storage.secretAccessKey
19
+ };
20
+ }
21
+ if (storage.endpoint) {
22
+ config.endpoint = storage.endpoint;
23
+ config.forcePathStyle = storage.forcePathStyle ?? true;
24
+ }
25
+ return config;
26
+ }
27
+ function cacheKey(storage) {
28
+ return [
29
+ storage.endpoint ?? "",
30
+ storage.region,
31
+ storage.bucket,
32
+ storage.accessKeyId ?? "",
33
+ String(storage.forcePathStyle ?? "")
34
+ ].join("\0");
35
+ }
36
+ function createS3Client(storage) {
37
+ const key = cacheKey(storage);
38
+ const cached = clients.get(key);
39
+ if (cached) return cached;
40
+ const client = new S3Client(buildS3ClientConfig(storage));
41
+ clients.set(key, client);
42
+ return client;
43
+ }
44
+ var clients;
45
+ var init_s3_client = __esm({
46
+ "src/providers/s3-client.ts"() {
47
+ "use strict";
48
+ clients = /* @__PURE__ */ new Map();
49
+ }
50
+ });
51
+
52
+ // src/providers/aws.ts
53
+ import {
54
+ PutObjectCommand,
55
+ GetObjectCommand,
56
+ CreateMultipartUploadCommand,
57
+ UploadPartCommand,
58
+ CompleteMultipartUploadCommand,
59
+ AbortMultipartUploadCommand,
60
+ ListPartsCommand,
61
+ HeadBucketCommand
62
+ } from "@aws-sdk/client-s3";
63
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
64
+ import { UpupStorageError } from "@useupup/core";
65
+ function computePartSize(fileSize, chunkSizeBytes) {
66
+ let partSize = chunkSizeBytes ?? MIN_PART_SIZE;
67
+ if (partSize < MIN_PART_SIZE) partSize = MIN_PART_SIZE;
68
+ const minPartSizeForFile = Math.ceil(fileSize / MAX_PARTS);
69
+ if (partSize < minPartSizeForFile) partSize = minPartSizeForFile;
70
+ return partSize;
71
+ }
72
+ async function generateSignedPublicUrl(storage, key, expiresIn = DEFAULT_DOWNLOAD_URL_EXPIRES_IN) {
73
+ const client = createS3Client(storage);
74
+ return getSignedUrl(
75
+ client,
76
+ new GetObjectCommand({ Bucket: storage.bucket, Key: key }),
77
+ { expiresIn }
78
+ );
79
+ }
80
+ async function generatePresignedUrl(storage, key, contentType, contentLength, expiresIn = DEFAULT_EXPIRES_IN, downloadUrlExpiresIn) {
81
+ const client = createS3Client(storage);
82
+ const command = new PutObjectCommand({
83
+ Bucket: storage.bucket,
84
+ Key: key,
85
+ ContentType: contentType,
86
+ ContentLength: contentLength
87
+ });
88
+ const uploadUrl = await getSignedUrl(client, command, {
89
+ expiresIn,
90
+ // Bind content-length into the signature so the PUT body cannot exceed the
91
+ // approved size (S1). Browsers/Node set Content-Length from the body; a
92
+ // larger body changes it and S3 rejects the signature.
93
+ signableHeaders: /* @__PURE__ */ new Set(["content-type", "content-length"])
94
+ });
95
+ const downloadUrl = await generateSignedPublicUrl(
96
+ storage,
97
+ key,
98
+ downloadUrlExpiresIn ?? DEFAULT_DOWNLOAD_URL_EXPIRES_IN
99
+ );
100
+ return {
101
+ key,
102
+ downloadUrl,
103
+ uploadUrl,
104
+ uploadHeaders: {
105
+ "Content-Type": contentType || "application/octet-stream"
106
+ },
107
+ expiresIn
108
+ };
109
+ }
110
+ async function initiateMultipartUpload(storage, key, contentType, fileSize, expiresIn = DEFAULT_EXPIRES_IN, chunkSizeBytes) {
111
+ const client = createS3Client(storage);
112
+ const command = new CreateMultipartUploadCommand({
113
+ Bucket: storage.bucket,
114
+ Key: key,
115
+ ContentType: contentType
116
+ });
117
+ const response = await client.send(command);
118
+ if (!response.UploadId) {
119
+ throw new UpupStorageError(
120
+ "Failed to initiate multipart upload: no UploadId",
121
+ storage.type,
122
+ "multipart-init"
123
+ );
124
+ }
125
+ const partSize = computePartSize(fileSize, chunkSizeBytes);
126
+ return { key, uploadId: response.UploadId, partSize, expiresIn };
127
+ }
128
+ async function generatePresignedPartUrl(storage, key, uploadId, partNumber, expiresIn = DEFAULT_EXPIRES_IN) {
129
+ const client = createS3Client(storage);
130
+ const command = new UploadPartCommand({
131
+ Bucket: storage.bucket,
132
+ Key: key,
133
+ UploadId: uploadId,
134
+ PartNumber: partNumber
135
+ });
136
+ const uploadUrl = await getSignedUrl(client, command, { expiresIn });
137
+ return { uploadUrl, expiresIn };
138
+ }
139
+ async function completeMultipartUpload(storage, key, uploadId, parts, downloadUrlExpiresIn) {
140
+ const client = createS3Client(storage);
141
+ const command = new CompleteMultipartUploadCommand({
142
+ Bucket: storage.bucket,
143
+ Key: key,
144
+ UploadId: uploadId,
145
+ MultipartUpload: {
146
+ Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((p) => ({ PartNumber: p.partNumber, ETag: p.eTag }))
147
+ }
148
+ });
149
+ const result = await client.send(command);
150
+ const downloadUrl = await generateSignedPublicUrl(
151
+ storage,
152
+ key,
153
+ downloadUrlExpiresIn ?? DEFAULT_DOWNLOAD_URL_EXPIRES_IN
154
+ );
155
+ return {
156
+ key,
157
+ downloadUrl,
158
+ ...result.ETag !== void 0 ? { etag: result.ETag } : {}
159
+ };
160
+ }
161
+ async function abortMultipartUpload(storage, key, uploadId) {
162
+ const client = createS3Client(storage);
163
+ const command = new AbortMultipartUploadCommand({
164
+ Bucket: storage.bucket,
165
+ Key: key,
166
+ UploadId: uploadId
167
+ });
168
+ await client.send(command);
169
+ return { ok: true };
170
+ }
171
+ async function listMultipartParts(storage, key, uploadId) {
172
+ const client = createS3Client(storage);
173
+ const parts = [];
174
+ let partNumberMarker;
175
+ for (; ; ) {
176
+ const command = new ListPartsCommand({
177
+ Bucket: storage.bucket,
178
+ Key: key,
179
+ UploadId: uploadId,
180
+ PartNumberMarker: partNumberMarker
181
+ });
182
+ const response = await client.send(command);
183
+ if (response.Parts) {
184
+ for (const part of response.Parts) {
185
+ if (part.PartNumber != null && part.ETag) {
186
+ parts.push({
187
+ partNumber: part.PartNumber,
188
+ eTag: part.ETag,
189
+ ...part.Size !== void 0 ? { size: part.Size } : {}
190
+ });
191
+ }
192
+ }
193
+ }
194
+ if (!response.IsTruncated) break;
195
+ partNumberMarker = String(
196
+ response.Parts?.[response.Parts.length - 1]?.PartNumber
197
+ );
198
+ }
199
+ return { parts };
200
+ }
201
+ async function getMultipartUploadedSize(storage, key, uploadId) {
202
+ const client = createS3Client(storage);
203
+ let total = 0;
204
+ let partNumberMarker;
205
+ for (; ; ) {
206
+ const command = new ListPartsCommand({
207
+ Bucket: storage.bucket,
208
+ Key: key,
209
+ UploadId: uploadId,
210
+ PartNumberMarker: partNumberMarker
211
+ });
212
+ const response = await client.send(command);
213
+ if (response.Parts) {
214
+ for (const part of response.Parts) {
215
+ total += part.Size ?? 0;
216
+ }
217
+ }
218
+ if (!response.IsTruncated) break;
219
+ partNumberMarker = String(
220
+ response.Parts?.[response.Parts.length - 1]?.PartNumber
221
+ );
222
+ }
223
+ return total;
224
+ }
225
+ async function checkStorageReachable(storage) {
226
+ try {
227
+ const client = createS3Client(storage);
228
+ await client.send(new HeadBucketCommand({ Bucket: storage.bucket }));
229
+ return { ok: true };
230
+ } catch (error) {
231
+ return { ok: false, error };
232
+ }
233
+ }
234
+ var DEFAULT_EXPIRES_IN, DEFAULT_DOWNLOAD_URL_EXPIRES_IN, MIN_PART_SIZE, MAX_PARTS;
235
+ var init_aws = __esm({
236
+ "src/providers/aws.ts"() {
237
+ "use strict";
238
+ init_s3_client();
239
+ DEFAULT_EXPIRES_IN = 3600;
240
+ DEFAULT_DOWNLOAD_URL_EXPIRES_IN = 3600 * 24 * 3;
241
+ MIN_PART_SIZE = 5 * 1024 * 1024;
242
+ MAX_PARTS = 1e4;
243
+ }
244
+ });
245
+
246
+ // src/observability.ts
247
+ function toSafeError(e) {
248
+ return e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { name: "NonError", message: String(e) };
249
+ }
250
+ function scrubSensitive(text) {
251
+ return SCRUBBERS.reduce((acc, [re, repl]) => acc.replace(re, repl), text);
252
+ }
253
+ function reportServerError(logger, event) {
254
+ const scrubbed = {
255
+ ...event,
256
+ message: scrubSensitive(event.message)
257
+ };
258
+ if (event.error) {
259
+ scrubbed.error = {
260
+ name: event.error.name,
261
+ message: scrubSensitive(event.error.message),
262
+ stack: event.error.stack ? scrubSensitive(event.error.stack) : void 0
263
+ };
264
+ }
265
+ ;
266
+ (logger ?? defaultLogger)(scrubbed);
267
+ }
268
+ var SCRUBBERS, defaultLogger;
269
+ var init_observability = __esm({
270
+ "src/observability.ts"() {
271
+ "use strict";
272
+ SCRUBBERS = [
273
+ // `Authorization: Bearer xxx` / `authorization=xxx` header dumps.
274
+ [/(authorization)(\s*[:=]\s*)(?:bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]"],
275
+ // Standalone bearer tokens not preceded by the header name.
276
+ [/\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [REDACTED]"],
277
+ // SigV4 signature / credential / security-token query params or headers.
278
+ [
279
+ /(x-amz-(?:signature|credential|security-token))(\s*[:=]\s*)[^\s&,;"']+/gi,
280
+ "$1$2[REDACTED]"
281
+ ],
282
+ // AWS access-key ids.
283
+ [/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED_AWS_KEY]"]
284
+ ];
285
+ defaultLogger = (event) => {
286
+ console.error("[upup:server]", JSON.stringify(event));
287
+ };
288
+ }
289
+ });
290
+
291
+ // src/transfer.ts
292
+ var transfer_exports = {};
293
+ __export(transfer_exports, {
294
+ transferDriveFileToS3: () => transferDriveFileToS3
295
+ });
296
+ import {
297
+ PutObjectCommand as PutObjectCommand2,
298
+ CreateMultipartUploadCommand as CreateMultipartUploadCommand2,
299
+ UploadPartCommand as UploadPartCommand2,
300
+ CompleteMultipartUploadCommand as CompleteMultipartUploadCommand2,
301
+ AbortMultipartUploadCommand as AbortMultipartUploadCommand2
302
+ } from "@aws-sdk/client-s3";
303
+ import { UpupStorageError as UpupStorageError2, UpupErrorCode as UpupErrorCode4 } from "@useupup/core";
304
+ async function transferDriveFileToS3(opts) {
305
+ const key = `${crypto.randomUUID()}-${opts.fileName}`;
306
+ if (opts.size > 0 && opts.size <= SINGLE_PUT_MAX_BYTES) {
307
+ return singlePut({ ...opts, key });
308
+ }
309
+ return streamingMultipart({ ...opts, key });
310
+ }
311
+ async function singlePut(opts) {
312
+ const buffer = await streamToUint8Array(opts.stream);
313
+ if (opts.maxBytes !== void 0 && buffer.byteLength > opts.maxBytes) {
314
+ throw new UpupStorageError2(
315
+ `Drive file exceeds the configured maxFileSize (${buffer.byteLength} > ${opts.maxBytes} bytes)`,
316
+ opts.storage.type,
317
+ "upload"
318
+ );
319
+ }
320
+ const client = createS3Client(opts.storage);
321
+ await client.send(
322
+ new PutObjectCommand2({
323
+ Bucket: opts.storage.bucket,
324
+ Key: opts.key,
325
+ ContentType: opts.mimeType,
326
+ Body: buffer
327
+ })
328
+ );
329
+ const url = await generateSignedPublicUrl(
330
+ opts.storage,
331
+ opts.key,
332
+ opts.downloadUrlExpiresIn ?? DEFAULT_DOWNLOAD_URL_EXPIRES_IN
333
+ );
334
+ return {
335
+ key: opts.key,
336
+ name: opts.fileName,
337
+ size: buffer.byteLength,
338
+ type: opts.mimeType,
339
+ url
340
+ };
341
+ }
342
+ async function streamingMultipart(opts) {
343
+ const client = createS3Client(opts.storage);
344
+ const init = await client.send(
345
+ new CreateMultipartUploadCommand2({
346
+ Bucket: opts.storage.bucket,
347
+ Key: opts.key,
348
+ ContentType: opts.mimeType
349
+ })
350
+ );
351
+ const uploadId = init.UploadId;
352
+ if (!uploadId)
353
+ throw new UpupStorageError2(
354
+ "Missing UploadId on multipart init",
355
+ opts.storage.type,
356
+ "multipart-init"
357
+ );
358
+ const parts = [];
359
+ let totalBytes = 0;
360
+ try {
361
+ let partNumber = 1;
362
+ for await (const chunk of chunkedStream(opts.stream, MIN_PART_SIZE)) {
363
+ const res = await client.send(
364
+ new UploadPartCommand2({
365
+ Bucket: opts.storage.bucket,
366
+ Key: opts.key,
367
+ UploadId: uploadId,
368
+ PartNumber: partNumber,
369
+ ContentLength: chunk.byteLength,
370
+ Body: chunk
371
+ })
372
+ );
373
+ if (!res.ETag) {
374
+ throw new UpupStorageError2(
375
+ `Missing ETag for part ${partNumber}`,
376
+ opts.storage.type,
377
+ "multipart-sign-part"
378
+ );
379
+ }
380
+ parts.push({ PartNumber: partNumber, ETag: res.ETag });
381
+ totalBytes += chunk.byteLength;
382
+ if (opts.maxBytes !== void 0 && totalBytes > opts.maxBytes) {
383
+ throw new UpupStorageError2(
384
+ `Drive file exceeds the configured maxFileSize (${totalBytes} > ${opts.maxBytes} bytes)`,
385
+ opts.storage.type,
386
+ "upload"
387
+ );
388
+ }
389
+ partNumber++;
390
+ }
391
+ if (parts.length === 0) {
392
+ throw new UpupStorageError2(
393
+ "Drive download produced no bytes",
394
+ opts.storage.type,
395
+ "upload"
396
+ );
397
+ }
398
+ await client.send(
399
+ new CompleteMultipartUploadCommand2({
400
+ Bucket: opts.storage.bucket,
401
+ Key: opts.key,
402
+ UploadId: uploadId,
403
+ MultipartUpload: { Parts: parts }
404
+ })
405
+ );
406
+ } catch (err) {
407
+ try {
408
+ await client.send(
409
+ new AbortMultipartUploadCommand2({
410
+ Bucket: opts.storage.bucket,
411
+ Key: opts.key,
412
+ UploadId: uploadId
413
+ })
414
+ );
415
+ } catch (abortErr) {
416
+ reportServerError(opts.onError, {
417
+ route: "files/transfer",
418
+ method: "POST",
419
+ status: 500,
420
+ code: UpupErrorCode4.STORAGE_ERROR,
421
+ message: `Failed to abort multipart upload after a transfer error (uploadId=${uploadId}, key=${opts.key})`,
422
+ requestId: opts.requestId,
423
+ error: toSafeError(abortErr)
424
+ });
425
+ }
426
+ throw err;
427
+ }
428
+ const url = await generateSignedPublicUrl(
429
+ opts.storage,
430
+ opts.key,
431
+ opts.downloadUrlExpiresIn ?? DEFAULT_DOWNLOAD_URL_EXPIRES_IN
432
+ );
433
+ return {
434
+ key: opts.key,
435
+ name: opts.fileName,
436
+ size: totalBytes,
437
+ type: opts.mimeType,
438
+ url
439
+ };
440
+ }
441
+ async function streamToUint8Array(stream) {
442
+ const reader = stream.getReader();
443
+ const chunks = [];
444
+ let total = 0;
445
+ for (; ; ) {
446
+ const { value, done } = await reader.read();
447
+ if (done) break;
448
+ chunks.push(value);
449
+ total += value.byteLength;
450
+ }
451
+ const out = new Uint8Array(total);
452
+ let offset = 0;
453
+ for (const c of chunks) {
454
+ out.set(c, offset);
455
+ offset += c.byteLength;
456
+ }
457
+ return out;
458
+ }
459
+ async function* chunkedStream(stream, chunkSize) {
460
+ const reader = stream.getReader();
461
+ let buffered = [];
462
+ let bufferedSize = 0;
463
+ const flush = () => {
464
+ const out = new Uint8Array(bufferedSize);
465
+ let offset = 0;
466
+ for (const c of buffered) {
467
+ out.set(c, offset);
468
+ offset += c.byteLength;
469
+ }
470
+ buffered = [];
471
+ bufferedSize = 0;
472
+ return out;
473
+ };
474
+ for (; ; ) {
475
+ const { value, done } = await reader.read();
476
+ if (done) break;
477
+ buffered.push(value);
478
+ bufferedSize += value.byteLength;
479
+ while (bufferedSize >= chunkSize) {
480
+ const out = new Uint8Array(chunkSize);
481
+ let remaining = chunkSize;
482
+ let offset = 0;
483
+ while (remaining > 0 && buffered.length > 0) {
484
+ const head = buffered[0];
485
+ if (head === void 0) break;
486
+ const take = Math.min(head.byteLength, remaining);
487
+ out.set(head.subarray(0, take), offset);
488
+ offset += take;
489
+ remaining -= take;
490
+ if (take < head.byteLength) {
491
+ buffered[0] = head.subarray(take);
492
+ } else {
493
+ buffered.shift();
494
+ }
495
+ }
496
+ bufferedSize -= chunkSize;
497
+ yield out;
498
+ }
499
+ }
500
+ if (bufferedSize > 0) yield flush();
501
+ }
502
+ var SINGLE_PUT_MAX_BYTES;
503
+ var init_transfer = __esm({
504
+ "src/transfer.ts"() {
505
+ "use strict";
506
+ init_s3_client();
507
+ init_aws();
508
+ init_observability();
509
+ SINGLE_PUT_MAX_BYTES = MIN_PART_SIZE;
510
+ }
511
+ });
512
+
513
+ // src/handler.ts
514
+ import { UpupErrorCode as UpupErrorCode6, UpupConfigError as UpupConfigError5 } from "@useupup/core";
515
+
516
+ // src/uploadToken.ts
517
+ import { UpupConfigError } from "@useupup/core";
518
+ var UploadTokenError = class extends Error {
519
+ constructor(code, message) {
520
+ super(message);
521
+ this.name = "UploadTokenError";
522
+ this.code = code;
523
+ }
524
+ };
525
+ var DEFAULT_UPLOAD_TOKEN_TTL_SECONDS = 3600;
526
+ var MIN_SECRET_LENGTH = 16;
527
+ function assertUploadTokenSecret(secret) {
528
+ if (!secret || secret.length < MIN_SECRET_LENGTH) {
529
+ throw new UpupConfigError(
530
+ `[@useupup/server] config.uploadTokenSecret is required and must be at least ${MIN_SECRET_LENGTH} characters. Generate a stable, high-entropy secret (e.g. \`openssl rand -hex 32\`) and share the SAME value across every server instance/worker.`
531
+ );
532
+ }
533
+ }
534
+ var encoder = new TextEncoder();
535
+ function bytesToBase64Url(bytes) {
536
+ let bin = "";
537
+ for (const byte of bytes) bin += String.fromCharCode(byte);
538
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
539
+ }
540
+ function base64UrlToBytes(b64url) {
541
+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
542
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
543
+ const bin = atob(b64 + pad);
544
+ const out = new Uint8Array(bin.length);
545
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
546
+ return out;
547
+ }
548
+ async function hmacSha256(secret, data) {
549
+ const key = await crypto.subtle.importKey(
550
+ "raw",
551
+ encoder.encode(secret),
552
+ { name: "HMAC", hash: "SHA-256" },
553
+ false,
554
+ ["sign"]
555
+ );
556
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
557
+ return new Uint8Array(sig);
558
+ }
559
+ function timingSafeEqual(a, b) {
560
+ if (a.length !== b.length) return false;
561
+ let diff = 0;
562
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
563
+ return diff === 0;
564
+ }
565
+ async function signUploadToken(secret, payload) {
566
+ const body = bytesToBase64Url(encoder.encode(JSON.stringify(payload)));
567
+ const sig = bytesToBase64Url(await hmacSha256(secret, body));
568
+ return `${body}.${sig}`;
569
+ }
570
+ async function verifyUploadToken(secret, token, nowMs, options = {}) {
571
+ if (typeof token !== "string" || !token.includes(".")) {
572
+ throw new UploadTokenError(
573
+ "malformed",
574
+ "Upload token is missing or malformed"
575
+ );
576
+ }
577
+ const [body, sig] = token.split(".");
578
+ if (!body || !sig) {
579
+ throw new UploadTokenError("malformed", "Upload token is malformed");
580
+ }
581
+ const expected = bytesToBase64Url(await hmacSha256(secret, body));
582
+ if (!timingSafeEqual(sig, expected)) {
583
+ throw new UploadTokenError(
584
+ "bad_signature",
585
+ "Upload token signature is invalid"
586
+ );
587
+ }
588
+ let payload;
589
+ try {
590
+ payload = JSON.parse(
591
+ new TextDecoder().decode(base64UrlToBytes(body))
592
+ );
593
+ } catch {
594
+ throw new UploadTokenError(
595
+ "malformed",
596
+ "Upload token payload is not valid JSON"
597
+ );
598
+ }
599
+ if (typeof payload.k !== "string" || typeof payload.u !== "string" || typeof payload.exp !== "number" || typeof payload.smin !== "number" || typeof payload.smax !== "number" || payload.sid !== void 0 && typeof payload.sid !== "string") {
600
+ throw new UploadTokenError(
601
+ "malformed",
602
+ "Upload token payload is missing required fields"
603
+ );
604
+ }
605
+ if (!options.allowExpired && payload.exp <= Math.floor(nowMs / 1e3)) {
606
+ throw new UploadTokenError("expired", "Upload token has expired");
607
+ }
608
+ return payload;
609
+ }
610
+
611
+ // src/validate-config.ts
612
+ import { UpupConfigError as UpupConfigError2 } from "@useupup/core";
613
+ function isNonEmpty(v) {
614
+ return typeof v === "string" && v.trim().length > 0;
615
+ }
616
+ function validateServerConfig(config) {
617
+ const missing = [];
618
+ const invalid = [];
619
+ if (!config.storage) {
620
+ missing.push("storage");
621
+ } else if (typeof config.storage === "function") {
622
+ } else {
623
+ if (!isNonEmpty(config.storage.bucket)) missing.push("storage.bucket");
624
+ if (!isNonEmpty(config.storage.region)) missing.push("storage.region");
625
+ const hasId = config.storage.accessKeyId !== void 0;
626
+ const hasSecret = config.storage.secretAccessKey !== void 0;
627
+ if (hasId || hasSecret) {
628
+ if (!isNonEmpty(config.storage.accessKeyId))
629
+ missing.push("storage.accessKeyId");
630
+ if (!isNonEmpty(config.storage.secretAccessKey))
631
+ missing.push("storage.secretAccessKey");
632
+ }
633
+ }
634
+ const p = config.providers;
635
+ if (p?.googleDrive) {
636
+ if (!isNonEmpty(p.googleDrive.clientId))
637
+ missing.push("providers.googleDrive.clientId");
638
+ if (!isNonEmpty(p.googleDrive.clientSecret))
639
+ missing.push("providers.googleDrive.clientSecret");
640
+ }
641
+ if (p?.dropbox) {
642
+ if (!isNonEmpty(p.dropbox.appKey))
643
+ missing.push("providers.dropbox.appKey");
644
+ if (!isNonEmpty(p.dropbox.appSecret))
645
+ missing.push("providers.dropbox.appSecret");
646
+ }
647
+ if (p?.oneDrive) {
648
+ if (!isNonEmpty(p.oneDrive.clientId))
649
+ missing.push("providers.oneDrive.clientId");
650
+ if (!isNonEmpty(p.oneDrive.clientSecret))
651
+ missing.push("providers.oneDrive.clientSecret");
652
+ }
653
+ if (p?.box) {
654
+ if (!isNonEmpty(p.box.clientId)) missing.push("providers.box.clientId");
655
+ if (!isNonEmpty(p.box.clientSecret))
656
+ missing.push("providers.box.clientSecret");
657
+ }
658
+ const resumeWindow = config.multipartResumeWindowSeconds;
659
+ if (resumeWindow !== void 0 && (!Number.isInteger(resumeWindow) || resumeWindow < 0)) {
660
+ invalid.push(
661
+ `multipartResumeWindowSeconds must be a non-negative integer (got ${String(resumeWindow)}); use 0 to disable /multipart/resume`
662
+ );
663
+ }
664
+ if (missing.length > 0 || invalid.length > 0) {
665
+ const sections = [
666
+ missing.length > 0 ? "missing/empty required field(s):\n" + missing.map((m) => ` - ${m}`).join("\n") : "",
667
+ invalid.length > 0 ? "invalid field(s):\n" + invalid.map((m) => ` - ${m}`).join("\n") : ""
668
+ ].filter(Boolean);
669
+ throw new UpupConfigError2(
670
+ `[@useupup/server] Invalid config \u2014 ${sections.join("\n")}`
671
+ );
672
+ }
673
+ }
674
+
675
+ // src/storage.ts
676
+ import { UpupConfigError as UpupConfigError3, NON_S3_STORAGE_PROVIDERS } from "@useupup/core";
677
+ function assertS3Storage(storage) {
678
+ const storageType = storage.type;
679
+ if (typeof storageType === "string" && NON_S3_STORAGE_PROVIDERS.has(storageType)) {
680
+ throw new UpupConfigError3(
681
+ `[@useupup/server] storage.type "${storageType}" has no S3-compatible API and cannot be served. upup uploads via the S3 API \u2014 use an S3-compatible provider (aws, minio, r2, wasabi, \u2026) and set storage.endpoint for non-AWS backends.`
682
+ );
683
+ }
684
+ }
685
+
686
+ // src/resolve-storage.ts
687
+ import { UpupConfigError as UpupConfigError4 } from "@useupup/core";
688
+ function isStorageResolver(storage) {
689
+ return typeof storage === "function";
690
+ }
691
+ async function storageIdentity(storage) {
692
+ const material = [
693
+ storage.bucket,
694
+ storage.endpoint ?? "",
695
+ storage.region
696
+ ].join("\n");
697
+ const digest = await crypto.subtle.digest(
698
+ "SHA-256",
699
+ new TextEncoder().encode(material)
700
+ );
701
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
702
+ }
703
+ function assertResolvedStorage(storage) {
704
+ const s = storage;
705
+ const missing = [];
706
+ if (!s || typeof s !== "object") {
707
+ throw new UpupConfigError4(
708
+ "[@useupup/server] the storage resolver did not return a storage config object."
709
+ );
710
+ }
711
+ if (typeof s.bucket !== "string" || s.bucket.trim() === "")
712
+ missing.push("bucket");
713
+ if (typeof s.region !== "string" || s.region.trim() === "")
714
+ missing.push("region");
715
+ if (missing.length > 0) {
716
+ throw new UpupConfigError4(
717
+ "[@useupup/server] the storage resolver returned a config missing required field(s): " + missing.map((m) => `storage.${m}`).join(", ")
718
+ );
719
+ }
720
+ assertS3Storage(s);
721
+ }
722
+ async function resolveStorage(config, ctx) {
723
+ if (!isStorageResolver(config.storage)) return config.storage;
724
+ const resolved = await config.storage(ctx);
725
+ assertResolvedStorage(resolved);
726
+ return resolved;
727
+ }
728
+ async function resolveBoundStorage(config, ctx, boundId) {
729
+ if (!isStorageResolver(config.storage)) return config.storage;
730
+ if (!boundId) {
731
+ throw new StorageBindingError(
732
+ "Upload token carries no storage binding; restart the upload from /multipart/init"
733
+ );
734
+ }
735
+ const resolved = await resolveStorage(config, {
736
+ ...ctx,
737
+ storageId: boundId
738
+ });
739
+ if (await storageIdentity(resolved) !== boundId) {
740
+ throw new StorageBindingError(
741
+ "Upload token is bound to different storage than the resolver returned"
742
+ );
743
+ }
744
+ return resolved;
745
+ }
746
+ var StorageBindingError = class extends Error {
747
+ constructor(message) {
748
+ super(message);
749
+ this.name = "StorageBindingError";
750
+ }
751
+ };
752
+
753
+ // src/health.ts
754
+ init_aws();
755
+ init_observability();
756
+ var cachedStorageCheck;
757
+ var cachedAt = 0;
758
+ var STORAGE_CHECK_TTL_MS = 3e4;
759
+ function isConfigComplete(config) {
760
+ const secretOk = Boolean(
761
+ config.uploadTokenSecret && config.uploadTokenSecret.length >= 16
762
+ );
763
+ if (isStorageResolver(config.storage)) return secretOk;
764
+ return Boolean(config.storage.bucket && config.storage.region && secretOk);
765
+ }
766
+ async function sha256Hex(input) {
767
+ const bytes = new TextEncoder().encode(input);
768
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
769
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
770
+ }
771
+ async function handleHealth(config, res) {
772
+ const configOk = isConfigComplete(config);
773
+ const staticStorage = isStorageResolver(config.storage) ? null : config.storage;
774
+ const now = Date.now();
775
+ if (staticStorage && (!cachedStorageCheck || now - cachedAt > STORAGE_CHECK_TTL_MS)) {
776
+ const result = await checkStorageReachable(staticStorage);
777
+ if (!result.ok) {
778
+ reportServerError(config.onError, {
779
+ route: "health",
780
+ method: "GET",
781
+ status: 200,
782
+ code: "STORAGE_ERROR",
783
+ message: "Health check: storage unreachable",
784
+ requestId: res.requestId,
785
+ error: toSafeError(result.error)
786
+ });
787
+ }
788
+ cachedStorageCheck = result.ok ? { ok: true } : { ok: false };
789
+ cachedAt = now;
790
+ }
791
+ const storageOk = staticStorage ? Boolean(cachedStorageCheck?.ok) : true;
792
+ const body = {
793
+ status: configOk && storageOk ? "ok" : "degraded",
794
+ checks: {
795
+ config: configOk ? "ok" : "incomplete",
796
+ storage: !staticStorage ? "skipped" : storageOk ? "ok" : "error"
797
+ },
798
+ // Non-secret operational summary — labels/flags/counts only, never any
799
+ // secret VALUE. Lets an operator eyeball how an instance is configured
800
+ // (which storage backend, whether anonymous access is open, how many
801
+ // drive providers are wired, the upload-token lifetime) from the same
802
+ // unauthenticated probe.
803
+ summary: {
804
+ storageType: staticStorage ? staticStorage.type : "dynamic",
805
+ anonymousUploads: Boolean(config.allowAnonymousUploads),
806
+ anonymousDrives: Boolean(config.allowAnonymous),
807
+ driveProviders: config.providers ? Object.keys(config.providers).length : 0,
808
+ uploadTokenTtlSeconds: DEFAULT_UPLOAD_TOKEN_TTL_SECONDS
809
+ }
810
+ };
811
+ if (config.health?.exposeSecretFingerprint && config.uploadTokenSecret) {
812
+ body.uploadTokenFingerprint = (await sha256Hex(config.uploadTokenSecret)).slice(0, 8);
813
+ }
814
+ return res.json(body);
815
+ }
816
+
817
+ // src/respond.ts
818
+ init_observability();
819
+ import { UpupErrorCode } from "@useupup/core";
820
+ function corsHeaders(req, config) {
821
+ const cors = config.cors;
822
+ if (!cors) return {};
823
+ const origin = req.headers.get("origin") ?? "";
824
+ const allowsWildcard = cors.allowedOrigins.includes("*");
825
+ const allowsOrigin = origin && cors.allowedOrigins.includes(origin);
826
+ if (!allowsWildcard && !allowsOrigin) return {};
827
+ const allowOrigin = origin ? origin : "*";
828
+ const headers = {
829
+ "Access-Control-Allow-Origin": allowOrigin,
830
+ "Access-Control-Allow-Methods": (cors.allowedMethods ?? ["GET", "POST", "OPTIONS"]).join(", "),
831
+ "Access-Control-Allow-Headers": (cors.allowedHeaders ?? ["Content-Type", "Authorization"]).join(", "),
832
+ "Access-Control-Max-Age": String(cors.maxAgeSeconds ?? 600),
833
+ Vary: "Origin"
834
+ };
835
+ if (allowsOrigin && allowOrigin !== "*") {
836
+ headers["Access-Control-Allow-Credentials"] = "true";
837
+ }
838
+ return headers;
839
+ }
840
+ function createResponder(req, config) {
841
+ const requestId = crypto.randomUUID();
842
+ const headers = {
843
+ ...corsHeaders(req, config),
844
+ "x-upup-request-id": requestId
845
+ };
846
+ const json = (data, status = 200) => new Response(JSON.stringify(data), {
847
+ status,
848
+ headers: { "Content-Type": "application/json", ...headers }
849
+ });
850
+ return {
851
+ requestId,
852
+ json,
853
+ html: (body, status = 200) => new Response(body, {
854
+ status,
855
+ headers: {
856
+ "Content-Type": "text/html; charset=utf-8",
857
+ ...headers
858
+ }
859
+ }),
860
+ redirect: (location, status = 302) => new Response(null, {
861
+ status,
862
+ headers: { ...headers, Location: location }
863
+ }),
864
+ noContent: (status = 204) => new Response(null, { status, headers }),
865
+ fail: (route, method, status, code, message, error) => {
866
+ reportServerError(config.onError, {
867
+ route,
868
+ method,
869
+ status,
870
+ code,
871
+ message,
872
+ requestId,
873
+ error: toSafeError(error)
874
+ });
875
+ return json({ error: message, code }, status);
876
+ }
877
+ };
878
+ }
879
+ async function parseJsonBody(req, res) {
880
+ try {
881
+ const value = await req.json();
882
+ return { ok: true, value };
883
+ } catch {
884
+ return {
885
+ ok: false,
886
+ response: res.json(
887
+ { error: "Invalid JSON body", code: UpupErrorCode.BAD_REQUEST },
888
+ 400
889
+ )
890
+ };
891
+ }
892
+ }
893
+
894
+ // src/upload-routes.ts
895
+ init_aws();
896
+ import {
897
+ UpupErrorCode as UpupErrorCode2,
898
+ UpupError
899
+ } from "@useupup/core";
900
+
901
+ // src/tokenStore.ts
902
+ var OAUTH_STATE_TTL_SECONDS = 600;
903
+ var tokensKey = (userId, provider) => `upup:tokens:${userId}:${provider}`;
904
+ var oauthStateKey = (state) => `upup:oauth-state:${state}`;
905
+ async function getTokens(store, userId, provider) {
906
+ const raw = await store.get(tokensKey(userId, provider));
907
+ if (!raw) return null;
908
+ try {
909
+ return JSON.parse(raw);
910
+ } catch {
911
+ return null;
912
+ }
913
+ }
914
+ async function setTokens(store, userId, provider, tokens) {
915
+ let ttlSeconds;
916
+ if (!tokens.refreshToken && tokens.expiresAt) {
917
+ ttlSeconds = Math.max(
918
+ 0,
919
+ Math.ceil((tokens.expiresAt - Date.now()) / 1e3)
920
+ );
921
+ }
922
+ await store.set(
923
+ tokensKey(userId, provider),
924
+ JSON.stringify(tokens),
925
+ ttlSeconds
926
+ );
927
+ }
928
+ async function deleteTokens(store, userId, provider) {
929
+ await store.delete(tokensKey(userId, provider));
930
+ }
931
+ async function saveOAuthState(store, state, payload) {
932
+ await store.set(
933
+ oauthStateKey(state),
934
+ JSON.stringify(payload),
935
+ OAUTH_STATE_TTL_SECONDS
936
+ );
937
+ }
938
+ async function consumeOAuthState(store, state) {
939
+ const raw = await store.get(oauthStateKey(state));
940
+ if (!raw) return null;
941
+ await store.delete(oauthStateKey(state));
942
+ try {
943
+ return JSON.parse(raw);
944
+ } catch {
945
+ return null;
946
+ }
947
+ }
948
+ function generateOAuthState() {
949
+ const bytes = new Uint8Array(32);
950
+ crypto.getRandomValues(bytes);
951
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
952
+ }
953
+ var DEFAULT_USER_ID = "default";
954
+ async function resolveUserId(config, req) {
955
+ if (config.getUserId) return config.getUserId(req);
956
+ return DEFAULT_USER_ID;
957
+ }
958
+
959
+ // src/key.ts
960
+ function sanitizeFilename(name) {
961
+ const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._]+/, "").slice(0, 128);
962
+ return cleaned || "file";
963
+ }
964
+ function defaultKeyStrategy(ctx) {
965
+ const owner = ctx.userId ?? "anon";
966
+ return `${owner}/${crypto.randomUUID()}/${sanitizeFilename(ctx.fileName)}`;
967
+ }
968
+
969
+ // src/upload-routes.ts
970
+ init_observability();
971
+ function requireUploadAuthorization(config, res, route, method) {
972
+ if (!config.auth && !config.getUserId && !config.allowAnonymousUploads) {
973
+ return res.fail(
974
+ route,
975
+ method,
976
+ 403,
977
+ UpupErrorCode2.AUTH_REQUIRED,
978
+ "Anonymous uploads are disabled. Set allowAnonymousUploads:true, or configure auth/getUserId.",
979
+ new Error("anonymous upload rejected")
980
+ );
981
+ }
982
+ return null;
983
+ }
984
+ var DEFAULT_MULTIPART_RESUME_WINDOW_SECONDS = 86400;
985
+ async function verifyTokenOrRespond(config, token, res, route, method, allowExpired = false) {
986
+ assertUploadTokenSecret(config.uploadTokenSecret);
987
+ try {
988
+ return await verifyUploadToken(
989
+ config.uploadTokenSecret,
990
+ token,
991
+ Date.now(),
992
+ { allowExpired }
993
+ );
994
+ } catch (e) {
995
+ if (e instanceof UploadTokenError) {
996
+ reportServerError(config.onError, {
997
+ route,
998
+ method,
999
+ status: 403,
1000
+ code: e.code,
1001
+ message: "Invalid upload token",
1002
+ requestId: res.requestId,
1003
+ error: toSafeError(e)
1004
+ });
1005
+ return res.json(
1006
+ { error: "Invalid upload token", code: e.code },
1007
+ 403
1008
+ );
1009
+ }
1010
+ throw e;
1011
+ }
1012
+ }
1013
+ async function enforceTokenOwner(config, req, payload, res, route, method) {
1014
+ if (!config.getUserId) return null;
1015
+ const currentUserId = await resolveUserId(config, req);
1016
+ const currentOwner = currentUserId === DEFAULT_USER_ID ? null : currentUserId;
1017
+ if (currentOwner !== payload.uid) {
1018
+ return res.fail(
1019
+ route,
1020
+ method,
1021
+ 403,
1022
+ UpupErrorCode2.AUTH_DENIED,
1023
+ "Upload token does not belong to the current user",
1024
+ new Error("upload-token uid mismatch")
1025
+ );
1026
+ }
1027
+ return null;
1028
+ }
1029
+ function matchesAllowedType(type, allowedTypes) {
1030
+ if (!allowedTypes?.length) return true;
1031
+ return allowedTypes.some((allowed) => {
1032
+ if (allowed === type) return true;
1033
+ if (allowed.endsWith("/*")) {
1034
+ return type.startsWith(`${allowed.slice(0, -2)}/`);
1035
+ }
1036
+ return false;
1037
+ });
1038
+ }
1039
+ async function validateUploadMetadata(req, config, body, res) {
1040
+ if (typeof body.name !== "string" || body.name.length === 0 || typeof body.type !== "string" || typeof body.size !== "number" || !Number.isFinite(body.size) || body.size < 0) {
1041
+ return res.json(
1042
+ { error: "Invalid file metadata", code: UpupErrorCode2.BAD_REQUEST },
1043
+ 400
1044
+ );
1045
+ }
1046
+ if (config.maxFileSize && body.size > config.maxFileSize) {
1047
+ return res.json({ error: "File too large" }, 413);
1048
+ }
1049
+ if (!matchesAllowedType(body.type, config.allowedTypes)) {
1050
+ return res.json({ error: "File type not allowed" }, 415);
1051
+ }
1052
+ if (config.hooks?.onBeforeUpload) {
1053
+ let allowed;
1054
+ try {
1055
+ allowed = await config.hooks.onBeforeUpload(body, req);
1056
+ } catch (error) {
1057
+ if (error instanceof UpupError) {
1058
+ return res.json({ error: error.message, code: error.code }, 403);
1059
+ }
1060
+ throw error;
1061
+ }
1062
+ if (!allowed) {
1063
+ return res.json({ error: "Upload rejected" }, 403);
1064
+ }
1065
+ }
1066
+ return null;
1067
+ }
1068
+ async function resolveStorageOrFail(config, res, route, method, ctx) {
1069
+ try {
1070
+ return await resolveStorage(config, ctx);
1071
+ } catch (error) {
1072
+ return res.fail(
1073
+ route,
1074
+ method,
1075
+ 500,
1076
+ UpupErrorCode2.STORAGE_ERROR,
1077
+ "Storage configuration error",
1078
+ error
1079
+ );
1080
+ }
1081
+ }
1082
+ async function resolveBoundStorageOrFail(config, res, route, method, ctx, boundId) {
1083
+ try {
1084
+ return await resolveBoundStorage(config, ctx, boundId);
1085
+ } catch (error) {
1086
+ if (error instanceof StorageBindingError) {
1087
+ return res.fail(
1088
+ route,
1089
+ method,
1090
+ 403,
1091
+ UpupErrorCode2.AUTH_DENIED,
1092
+ "Upload token is not valid for the resolved storage",
1093
+ error
1094
+ );
1095
+ }
1096
+ return res.fail(
1097
+ route,
1098
+ method,
1099
+ 500,
1100
+ UpupErrorCode2.STORAGE_ERROR,
1101
+ "Storage configuration error",
1102
+ error
1103
+ );
1104
+ }
1105
+ }
1106
+ async function applyPresignResponseHook(config, response, ctx) {
1107
+ const hook = config.hooks?.onPresignResponse;
1108
+ if (!hook) return response;
1109
+ return await hook(response, ctx) ?? response;
1110
+ }
1111
+ async function runPostCompletionHooks(config, res, route, method, run) {
1112
+ if (!config.hooks) return;
1113
+ try {
1114
+ await run();
1115
+ } catch (error) {
1116
+ reportServerError(config.onError, {
1117
+ route,
1118
+ method,
1119
+ status: 200,
1120
+ // No dedicated HOOK_ERROR code exists in @useupup/core; the message
1121
+ // carries the real meaning — the upload itself succeeded.
1122
+ code: UpupErrorCode2.STORAGE_ERROR,
1123
+ message: "Post-completion hook threw after a durably-completed upload; the upload succeeded and the client was returned 200 (F-745).",
1124
+ requestId: res.requestId,
1125
+ error: toSafeError(error)
1126
+ });
1127
+ }
1128
+ }
1129
+ async function handlePresign(req, config, res) {
1130
+ const gate = requireUploadAuthorization(config, res, "presign", req.method);
1131
+ if (gate) return gate;
1132
+ const parsed = await parseJsonBody(req, res);
1133
+ if (!parsed.ok) return parsed.response;
1134
+ const body = parsed.value;
1135
+ const validationError = await validateUploadMetadata(req, config, body, res);
1136
+ if (validationError) return validationError;
1137
+ const userId = await resolveUserId(config, req);
1138
+ if (userId === null) return res.json({ error: "Unauthenticated" }, 401);
1139
+ const owner = userId === DEFAULT_USER_ID ? null : userId;
1140
+ const key = (config.keyStrategy ?? defaultKeyStrategy)({
1141
+ userId: owner,
1142
+ fileName: body.name,
1143
+ contentType: body.type,
1144
+ size: body.size,
1145
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
1146
+ req
1147
+ });
1148
+ const storage = await resolveStorageOrFail(
1149
+ config,
1150
+ res,
1151
+ "presign",
1152
+ req.method,
1153
+ {
1154
+ req,
1155
+ phase: "presign",
1156
+ userId: owner,
1157
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
1158
+ fileName: body.name,
1159
+ contentType: body.type,
1160
+ size: body.size
1161
+ }
1162
+ );
1163
+ if (storage instanceof Response) return storage;
1164
+ try {
1165
+ const result = await generatePresignedUrl(
1166
+ storage,
1167
+ key,
1168
+ body.type,
1169
+ body.size,
1170
+ void 0,
1171
+ config.downloadUrlExpiresIn
1172
+ );
1173
+ const payload = await applyPresignResponseHook(config, result, {
1174
+ req,
1175
+ phase: "presign",
1176
+ // Always the key that is IN the payload, on every phase.
1177
+ key: result.key,
1178
+ file: body,
1179
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
1180
+ userId: owner
1181
+ });
1182
+ return res.json(payload, 200);
1183
+ } catch (error) {
1184
+ return res.fail(
1185
+ "presign",
1186
+ req.method,
1187
+ 500,
1188
+ UpupErrorCode2.PRESIGN_FAILED,
1189
+ "Presign failed",
1190
+ error
1191
+ );
1192
+ }
1193
+ }
1194
+ async function handleMultipartInit(req, config, res) {
1195
+ const gate = requireUploadAuthorization(
1196
+ config,
1197
+ res,
1198
+ "multipart/init",
1199
+ req.method
1200
+ );
1201
+ if (gate) return gate;
1202
+ const parsed = await parseJsonBody(req, res);
1203
+ if (!parsed.ok) return parsed.response;
1204
+ const body = parsed.value;
1205
+ try {
1206
+ const validationError = await validateUploadMetadata(
1207
+ req,
1208
+ config,
1209
+ body,
1210
+ res
1211
+ );
1212
+ if (validationError) return validationError;
1213
+ const userId = await resolveUserId(config, req);
1214
+ if (userId === null) return res.json({ error: "Unauthenticated" }, 401);
1215
+ const owner = userId === DEFAULT_USER_ID ? null : userId;
1216
+ const key = (config.keyStrategy ?? defaultKeyStrategy)({
1217
+ userId: owner,
1218
+ fileName: body.name,
1219
+ contentType: body.type,
1220
+ size: body.size,
1221
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
1222
+ req
1223
+ });
1224
+ const storage = await resolveStorageOrFail(
1225
+ config,
1226
+ res,
1227
+ "multipart/init",
1228
+ req.method,
1229
+ {
1230
+ req,
1231
+ phase: "multipart-init",
1232
+ userId: owner,
1233
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
1234
+ fileName: body.name,
1235
+ contentType: body.type,
1236
+ size: body.size
1237
+ }
1238
+ );
1239
+ if (storage instanceof Response) return storage;
1240
+ const result = await initiateMultipartUpload(
1241
+ storage,
1242
+ key,
1243
+ body.type,
1244
+ body.size,
1245
+ void 0,
1246
+ body.chunkSizeBytes
1247
+ );
1248
+ assertUploadTokenSecret(config.uploadTokenSecret);
1249
+ const sid = isStorageResolver(config.storage) ? await storageIdentity(storage) : void 0;
1250
+ const issuedAt = Math.floor(Date.now() / 1e3);
1251
+ const token = await signUploadToken(config.uploadTokenSecret, {
1252
+ k: result.key,
1253
+ u: result.uploadId,
1254
+ uid: owner,
1255
+ smin: 0,
1256
+ smax: body.size,
1257
+ ...sid !== void 0 ? { sid } : {},
1258
+ exp: issuedAt + DEFAULT_UPLOAD_TOKEN_TTL_SECONDS,
1259
+ iat: issuedAt
1260
+ });
1261
+ const payload = await applyPresignResponseHook(
1262
+ config,
1263
+ { ...result, token },
1264
+ {
1265
+ req,
1266
+ phase: "multipart-init",
1267
+ key: result.key,
1268
+ file: body,
1269
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
1270
+ userId: owner
1271
+ }
1272
+ );
1273
+ return res.json(payload, 200);
1274
+ } catch (error) {
1275
+ return res.fail(
1276
+ "multipart/init",
1277
+ req.method,
1278
+ 500,
1279
+ UpupErrorCode2.STORAGE_ERROR,
1280
+ "Multipart init failed",
1281
+ error
1282
+ );
1283
+ }
1284
+ }
1285
+ function isNoSuchUpload(error) {
1286
+ if (typeof error !== "object" || error === null) return false;
1287
+ const shape = error;
1288
+ return shape.name === "NoSuchUpload" || shape.Code === "NoSuchUpload";
1289
+ }
1290
+ async function handleMultipartResume(req, config, res) {
1291
+ const parsed = await parseJsonBody(req, res);
1292
+ if (!parsed.ok) return parsed.response;
1293
+ const body = parsed.value;
1294
+ try {
1295
+ const payload = await verifyTokenOrRespond(
1296
+ config,
1297
+ body.token,
1298
+ res,
1299
+ "multipart/resume",
1300
+ req.method,
1301
+ true
1302
+ // allowExpired — the resume window below is the real bound
1303
+ );
1304
+ if (payload instanceof Response) return payload;
1305
+ const nowSeconds = Math.floor(Date.now() / 1e3);
1306
+ const issuedAt = payload.iat ?? payload.exp - DEFAULT_UPLOAD_TOKEN_TTL_SECONDS;
1307
+ const windowSeconds = config.multipartResumeWindowSeconds ?? DEFAULT_MULTIPART_RESUME_WINDOW_SECONDS;
1308
+ if (nowSeconds > issuedAt + windowSeconds) {
1309
+ return res.fail(
1310
+ "multipart/resume",
1311
+ req.method,
1312
+ 403,
1313
+ "expired",
1314
+ "Upload resume window has expired",
1315
+ new Error("resume window elapsed")
1316
+ );
1317
+ }
1318
+ const owned = await enforceTokenOwner(
1319
+ config,
1320
+ req,
1321
+ payload,
1322
+ res,
1323
+ "multipart/resume",
1324
+ req.method
1325
+ );
1326
+ if (owned) return owned;
1327
+ const storage = await resolveBoundStorageOrFail(
1328
+ config,
1329
+ res,
1330
+ "multipart/resume",
1331
+ req.method,
1332
+ { req, phase: "multipart-resume", userId: payload.uid },
1333
+ payload.sid
1334
+ );
1335
+ if (storage instanceof Response) return storage;
1336
+ let listed;
1337
+ try {
1338
+ listed = await listMultipartParts(storage, payload.k, payload.u);
1339
+ } catch (error) {
1340
+ if (isNoSuchUpload(error)) {
1341
+ return res.fail(
1342
+ "multipart/resume",
1343
+ req.method,
1344
+ 404,
1345
+ UpupErrorCode2.NOT_FOUND,
1346
+ "Multipart upload no longer exists",
1347
+ error
1348
+ );
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ assertUploadTokenSecret(config.uploadTokenSecret);
1353
+ const token = await signUploadToken(config.uploadTokenSecret, {
1354
+ // Every binding is copied from the VERIFIED payload, unchanged: same
1355
+ // key, same uploadId, same owner, same size envelope, same storage
1356
+ // identity. Only `exp` moves — `iat` is the original, so the window
1357
+ // does not roll.
1358
+ k: payload.k,
1359
+ u: payload.u,
1360
+ uid: payload.uid,
1361
+ smin: payload.smin,
1362
+ smax: payload.smax,
1363
+ ...payload.sid !== void 0 ? { sid: payload.sid } : {},
1364
+ // Clamp to the resume window, not a fresh full TTL. The window is
1365
+ // the operator's stated cap on how long a leaked token stays
1366
+ // usable (multipartResumeWindowSeconds); a re-issued token that
1367
+ // outlived it by up to a full TTL would make that cap — and the
1368
+ // documented leak mitigation — a lie.
1369
+ exp: Math.min(
1370
+ nowSeconds + DEFAULT_UPLOAD_TOKEN_TTL_SECONDS,
1371
+ issuedAt + windowSeconds
1372
+ ),
1373
+ iat: issuedAt
1374
+ });
1375
+ const response = {
1376
+ key: payload.k,
1377
+ token,
1378
+ parts: listed.parts
1379
+ };
1380
+ return res.json(response, 200);
1381
+ } catch (error) {
1382
+ return res.fail(
1383
+ "multipart/resume",
1384
+ req.method,
1385
+ 500,
1386
+ UpupErrorCode2.STORAGE_ERROR,
1387
+ "Multipart resume failed",
1388
+ error
1389
+ );
1390
+ }
1391
+ }
1392
+ async function handleMultipartSignPart(req, config, res) {
1393
+ const parsed = await parseJsonBody(req, res);
1394
+ if (!parsed.ok) return parsed.response;
1395
+ const body = parsed.value;
1396
+ try {
1397
+ const payload = await verifyTokenOrRespond(
1398
+ config,
1399
+ body.token,
1400
+ res,
1401
+ "multipart/sign-part",
1402
+ req.method
1403
+ );
1404
+ if (payload instanceof Response) return payload;
1405
+ const owned = await enforceTokenOwner(
1406
+ config,
1407
+ req,
1408
+ payload,
1409
+ res,
1410
+ "multipart/sign-part",
1411
+ req.method
1412
+ );
1413
+ if (owned) return owned;
1414
+ const storage = await resolveBoundStorageOrFail(
1415
+ config,
1416
+ res,
1417
+ "multipart/sign-part",
1418
+ req.method,
1419
+ {
1420
+ req,
1421
+ phase: "multipart-sign-part",
1422
+ userId: payload.uid
1423
+ },
1424
+ payload.sid
1425
+ );
1426
+ if (storage instanceof Response) return storage;
1427
+ const result = await generatePresignedPartUrl(
1428
+ storage,
1429
+ payload.k,
1430
+ payload.u,
1431
+ body.partNumber
1432
+ );
1433
+ const rewritten = await applyPresignResponseHook(config, result, {
1434
+ req,
1435
+ phase: "multipart-sign-part",
1436
+ // From the VERIFIED token — sign-part never sees a client-asserted
1437
+ // key, and has no file metadata to report.
1438
+ key: payload.k,
1439
+ userId: payload.uid
1440
+ });
1441
+ return res.json(rewritten, 200);
1442
+ } catch (error) {
1443
+ return res.fail(
1444
+ "multipart/sign-part",
1445
+ req.method,
1446
+ 500,
1447
+ UpupErrorCode2.STORAGE_ERROR,
1448
+ "Multipart sign failed",
1449
+ error
1450
+ );
1451
+ }
1452
+ }
1453
+ async function handleMultipartComplete(req, config, res) {
1454
+ const parsed = await parseJsonBody(req, res);
1455
+ if (!parsed.ok) return parsed.response;
1456
+ const body = parsed.value;
1457
+ try {
1458
+ const payload = await verifyTokenOrRespond(
1459
+ config,
1460
+ body.token,
1461
+ res,
1462
+ "multipart/complete",
1463
+ req.method
1464
+ );
1465
+ if (payload instanceof Response) return payload;
1466
+ const owned = await enforceTokenOwner(
1467
+ config,
1468
+ req,
1469
+ payload,
1470
+ res,
1471
+ "multipart/complete",
1472
+ req.method
1473
+ );
1474
+ if (owned) return owned;
1475
+ const storage = await resolveBoundStorageOrFail(
1476
+ config,
1477
+ res,
1478
+ "multipart/complete",
1479
+ req.method,
1480
+ { req, phase: "multipart-complete", userId: payload.uid },
1481
+ payload.sid
1482
+ );
1483
+ if (storage instanceof Response) return storage;
1484
+ const uploadedSize = await getMultipartUploadedSize(
1485
+ storage,
1486
+ payload.k,
1487
+ payload.u
1488
+ );
1489
+ if (uploadedSize < payload.smin || uploadedSize > payload.smax) {
1490
+ await abortMultipartUpload(storage, payload.k, payload.u);
1491
+ return res.json(
1492
+ { error: "Upload size outside signed envelope" },
1493
+ 403
1494
+ );
1495
+ }
1496
+ const result = await completeMultipartUpload(
1497
+ storage,
1498
+ payload.k,
1499
+ payload.u,
1500
+ body.parts,
1501
+ config.downloadUrlExpiresIn
1502
+ );
1503
+ const uploaded = {
1504
+ key: result.key,
1505
+ name: result.key.split("/").pop() ?? result.key,
1506
+ size: uploadedSize,
1507
+ // already computed above for the envelope check
1508
+ type: "",
1509
+ // not retained server-side on the multipart path
1510
+ url: result.downloadUrl ?? ""
1511
+ };
1512
+ await runPostCompletionHooks(
1513
+ config,
1514
+ res,
1515
+ "multipart/complete",
1516
+ req.method,
1517
+ async () => {
1518
+ if (config.hooks?.onFileUploaded)
1519
+ await config.hooks.onFileUploaded(uploaded, req);
1520
+ if (config.hooks?.onUploadComplete)
1521
+ await config.hooks.onUploadComplete([uploaded], req);
1522
+ }
1523
+ );
1524
+ return res.json(result, 200);
1525
+ } catch (error) {
1526
+ return res.fail(
1527
+ "multipart/complete",
1528
+ req.method,
1529
+ 500,
1530
+ UpupErrorCode2.STORAGE_ERROR,
1531
+ "Multipart complete failed",
1532
+ error
1533
+ );
1534
+ }
1535
+ }
1536
+ async function handleMultipartAbort(req, config, res) {
1537
+ const parsed = await parseJsonBody(req, res);
1538
+ if (!parsed.ok) return parsed.response;
1539
+ const body = parsed.value;
1540
+ try {
1541
+ const payload = await verifyTokenOrRespond(
1542
+ config,
1543
+ body.token,
1544
+ res,
1545
+ "multipart/abort",
1546
+ req.method
1547
+ );
1548
+ if (payload instanceof Response) return payload;
1549
+ const owned = await enforceTokenOwner(
1550
+ config,
1551
+ req,
1552
+ payload,
1553
+ res,
1554
+ "multipart/abort",
1555
+ req.method
1556
+ );
1557
+ if (owned) return owned;
1558
+ const storage = await resolveBoundStorageOrFail(
1559
+ config,
1560
+ res,
1561
+ "multipart/abort",
1562
+ req.method,
1563
+ { req, phase: "multipart-abort", userId: payload.uid },
1564
+ payload.sid
1565
+ );
1566
+ if (storage instanceof Response) return storage;
1567
+ const result = await abortMultipartUpload(storage, payload.k, payload.u);
1568
+ return res.json(result, 200);
1569
+ } catch (error) {
1570
+ return res.fail(
1571
+ "multipart/abort",
1572
+ req.method,
1573
+ 500,
1574
+ UpupErrorCode2.STORAGE_ERROR,
1575
+ "Multipart abort failed",
1576
+ error
1577
+ );
1578
+ }
1579
+ }
1580
+
1581
+ // src/oauth.ts
1582
+ import { UpupErrorCode as UpupErrorCode3 } from "@useupup/core";
1583
+ init_observability();
1584
+ var VALID_PROVIDERS = [
1585
+ "google-drive",
1586
+ "one-drive",
1587
+ "dropbox",
1588
+ "box"
1589
+ ];
1590
+ function isValidProvider(p) {
1591
+ return VALID_PROVIDERS.includes(p);
1592
+ }
1593
+ function getProviderMeta(config, provider) {
1594
+ const providers = config.providers;
1595
+ if (!providers)
1596
+ return { error: "No OAuth providers configured", status: 500 };
1597
+ switch (provider) {
1598
+ case "google-drive": {
1599
+ const gc = providers.googleDrive;
1600
+ if (!gc)
1601
+ return { error: "Google Drive not configured", status: 400 };
1602
+ return {
1603
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
1604
+ tokenUrl: "https://oauth2.googleapis.com/token",
1605
+ scope: "https://www.googleapis.com/auth/drive.readonly",
1606
+ clientId: gc.clientId,
1607
+ clientSecret: gc.clientSecret,
1608
+ extra: { access_type: "offline", prompt: "consent" }
1609
+ };
1610
+ }
1611
+ case "one-drive": {
1612
+ const oc = providers.oneDrive;
1613
+ if (!oc) return { error: "OneDrive not configured", status: 400 };
1614
+ const tenant = oc.tenantId ?? "common";
1615
+ return {
1616
+ authUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
1617
+ tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
1618
+ scope: "Files.Read.All offline_access",
1619
+ clientId: oc.clientId,
1620
+ clientSecret: oc.clientSecret
1621
+ };
1622
+ }
1623
+ case "dropbox": {
1624
+ const dc = providers.dropbox;
1625
+ if (!dc) return { error: "Dropbox not configured", status: 400 };
1626
+ return {
1627
+ authUrl: "https://www.dropbox.com/oauth2/authorize",
1628
+ tokenUrl: "https://api.dropboxapi.com/oauth2/token",
1629
+ scope: "files.content.read files.metadata.read",
1630
+ clientId: dc.appKey,
1631
+ clientSecret: dc.appSecret,
1632
+ extra: { token_access_type: "online" }
1633
+ };
1634
+ }
1635
+ case "box": {
1636
+ const bc = providers.box;
1637
+ if (!bc) return { error: "Box not configured", status: 400 };
1638
+ return {
1639
+ authUrl: "https://account.box.com/api/oauth2/authorize",
1640
+ tokenUrl: "https://api.box.com/oauth2/token",
1641
+ scope: "root_readonly",
1642
+ clientId: bc.clientId,
1643
+ clientSecret: bc.clientSecret
1644
+ };
1645
+ }
1646
+ }
1647
+ }
1648
+ function callbackUrlFor(req, provider) {
1649
+ const url = new URL(req.url);
1650
+ const base = `${url.origin}${url.pathname.replace(/\/auth\/[\w-]+(?:\/cb)?\/?$/, "")}`;
1651
+ return `${base}/auth/${provider}/cb`;
1652
+ }
1653
+ async function handleOAuthRedirect(req, config, provider, res) {
1654
+ if (!isValidProvider(provider)) {
1655
+ return res.json({ error: `Unknown provider: ${provider}` }, 400);
1656
+ }
1657
+ if (!config.tokenStore) {
1658
+ return res.json(
1659
+ { error: "tokenStore is required for OAuth flows" },
1660
+ 500
1661
+ );
1662
+ }
1663
+ const userId = await resolveUserId(config, req);
1664
+ if (!userId) return res.json({ error: "Unauthenticated" }, 401);
1665
+ const meta = getProviderMeta(config, provider);
1666
+ if ("error" in meta) return res.json({ error: meta.error }, meta.status);
1667
+ const state = generateOAuthState();
1668
+ const returnTo = new URL(req.url).searchParams.get("returnTo") ?? void 0;
1669
+ await saveOAuthState(config.tokenStore, state, {
1670
+ userId,
1671
+ provider,
1672
+ returnTo
1673
+ });
1674
+ const params = new URLSearchParams({
1675
+ client_id: meta.clientId,
1676
+ redirect_uri: callbackUrlFor(req, provider),
1677
+ response_type: "code",
1678
+ scope: meta.scope,
1679
+ state,
1680
+ ...meta.extra ?? {}
1681
+ });
1682
+ return res.redirect(`${meta.authUrl}?${params.toString()}`);
1683
+ }
1684
+ async function handleOAuthCallback(req, config, provider, res) {
1685
+ if (!isValidProvider(provider)) {
1686
+ return res.json({ error: `Unknown provider: ${provider}` }, 400);
1687
+ }
1688
+ if (!config.tokenStore) {
1689
+ return res.json({ error: "tokenStore is required" }, 500);
1690
+ }
1691
+ const url = new URL(req.url);
1692
+ const code = url.searchParams.get("code");
1693
+ const state = url.searchParams.get("state");
1694
+ const error = url.searchParams.get("error");
1695
+ if (error) return res.json({ error: `OAuth error: ${error}` }, 400);
1696
+ if (!code || !state)
1697
+ return res.json({ error: "Missing code or state" }, 400);
1698
+ const stateData = await consumeOAuthState(config.tokenStore, state);
1699
+ if (!stateData || stateData.provider !== provider) {
1700
+ return res.json({ error: "Invalid or expired state" }, 400);
1701
+ }
1702
+ const meta = getProviderMeta(config, provider);
1703
+ if ("error" in meta) return res.json({ error: meta.error }, meta.status);
1704
+ const tokenRes = await fetch(meta.tokenUrl, {
1705
+ method: "POST",
1706
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1707
+ body: new URLSearchParams({
1708
+ code,
1709
+ client_id: meta.clientId,
1710
+ client_secret: meta.clientSecret,
1711
+ redirect_uri: callbackUrlFor(req, provider),
1712
+ grant_type: "authorization_code"
1713
+ })
1714
+ });
1715
+ if (!tokenRes.ok) {
1716
+ const body = await tokenRes.text();
1717
+ reportServerError(config.onError, {
1718
+ route: `auth/${provider}/cb`,
1719
+ method: req.method,
1720
+ status: 502,
1721
+ code: UpupErrorCode3.AUTH_PROVIDER_ERROR,
1722
+ message: "Token exchange failed",
1723
+ error: toSafeError(new Error(body.slice(0, 500)))
1724
+ });
1725
+ return res.json(
1726
+ {
1727
+ error: "Token exchange failed",
1728
+ code: UpupErrorCode3.AUTH_PROVIDER_ERROR
1729
+ },
1730
+ 502
1731
+ );
1732
+ }
1733
+ const payload = await tokenRes.json();
1734
+ const tokens = {
1735
+ accessToken: payload.access_token,
1736
+ expiresAt: payload.expires_in ? Date.now() + payload.expires_in * 1e3 : void 0,
1737
+ scope: payload.scope,
1738
+ tokenType: payload.token_type,
1739
+ refreshToken: payload.refresh_token
1740
+ };
1741
+ await setTokens(config.tokenStore, stateData.userId, provider, tokens);
1742
+ const validatedReturn = validateReturnTo(
1743
+ stateData.returnTo,
1744
+ req,
1745
+ config.cors
1746
+ );
1747
+ const targetOrigins = concreteAllowedOrigins(config.cors);
1748
+ return res.html(
1749
+ buildOAuthSuccessPage(provider, {
1750
+ targetOrigins,
1751
+ ...validatedReturn !== void 0 ? { returnTo: validatedReturn } : {}
1752
+ })
1753
+ );
1754
+ }
1755
+ function validateReturnTo(returnTo, req, cors) {
1756
+ if (!returnTo) return void 0;
1757
+ const serverOrigin = new URL(req.url).origin;
1758
+ let resolved;
1759
+ try {
1760
+ resolved = new URL(returnTo, serverOrigin);
1761
+ } catch {
1762
+ return void 0;
1763
+ }
1764
+ if (resolved.origin === serverOrigin) return resolved.toString();
1765
+ const concrete = (cors?.allowedOrigins ?? []).filter((o) => o !== "*");
1766
+ if (concrete.includes(resolved.origin)) return resolved.toString();
1767
+ return void 0;
1768
+ }
1769
+ function concreteAllowedOrigins(cors) {
1770
+ return (cors?.allowedOrigins ?? []).filter((o) => o !== "*");
1771
+ }
1772
+ function buildOAuthSuccessPage(provider, opts) {
1773
+ const safeProvider = provider.replace(/[^a-z0-9-]/gi, "");
1774
+ const { returnTo, targetOrigins } = opts;
1775
+ let postMessageScript;
1776
+ if (targetOrigins.length > 0) {
1777
+ postMessageScript = targetOrigins.map(
1778
+ (origin) => `window.opener.postMessage({ type: 'upup:oauth-success', provider: ${JSON.stringify(safeProvider)} }, ${JSON.stringify(origin)});`
1779
+ ).join("\n ");
1780
+ } else {
1781
+ postMessageScript = `window.opener.postMessage({ type: 'upup:oauth-success', provider: ${JSON.stringify(safeProvider)} }, '*' /* token-free payload */);`;
1782
+ }
1783
+ const elseBody = returnTo ? `window.location.replace(${JSON.stringify(returnTo)});` : `document.body.textContent = 'Connected to ' + ${JSON.stringify(safeProvider)} + '. You may close this window.';`;
1784
+ return `<!doctype html>
1785
+ <html><head><title>Connected</title></head><body>
1786
+ <script>
1787
+ try {
1788
+ if (window.opener) {
1789
+ ${postMessageScript}
1790
+ window.close();
1791
+ } else if (${JSON.stringify(returnTo ?? "")}) {
1792
+ ${elseBody}
1793
+ } else {
1794
+ document.body.textContent = 'Connected to ' + ${JSON.stringify(safeProvider)} + '. You may close this window.';
1795
+ }
1796
+ } catch (e) {
1797
+ document.body.textContent = 'Connected. You may close this window.';
1798
+ }
1799
+ </script>
1800
+ </body></html>`;
1801
+ }
1802
+ async function refreshAccessToken(config, provider, userId, tokens) {
1803
+ if (!tokens.refreshToken || !config.tokenStore) return null;
1804
+ const meta = getProviderMeta(config, provider);
1805
+ if ("error" in meta) return null;
1806
+ const res = await fetch(meta.tokenUrl, {
1807
+ method: "POST",
1808
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1809
+ body: new URLSearchParams({
1810
+ grant_type: "refresh_token",
1811
+ refresh_token: tokens.refreshToken,
1812
+ client_id: meta.clientId,
1813
+ client_secret: meta.clientSecret
1814
+ })
1815
+ });
1816
+ if (!res.ok) {
1817
+ const body = await res.text().catch(() => "");
1818
+ reportServerError(config.onError, {
1819
+ route: `auth/${provider}/refresh`,
1820
+ method: "POST",
1821
+ status: res.status,
1822
+ code: UpupErrorCode3.AUTH_EXPIRED,
1823
+ message: "Drive token refresh failed",
1824
+ error: toSafeError(new Error(body.slice(0, 500) || res.statusText))
1825
+ });
1826
+ await deleteTokens(config.tokenStore, userId, provider);
1827
+ return null;
1828
+ }
1829
+ const payload = await res.json();
1830
+ const next = {
1831
+ accessToken: payload.access_token,
1832
+ expiresAt: payload.expires_in ? Date.now() + payload.expires_in * 1e3 : void 0,
1833
+ scope: payload.scope ?? tokens.scope,
1834
+ tokenType: payload.token_type ?? tokens.tokenType,
1835
+ // Some providers omit refresh_token on refresh -> keep the existing one.
1836
+ refreshToken: payload.refresh_token ?? tokens.refreshToken
1837
+ };
1838
+ await setTokens(config.tokenStore, userId, provider, next);
1839
+ return next;
1840
+ }
1841
+
1842
+ // src/drive-routes.ts
1843
+ import { UpupErrorCode as UpupErrorCode5, UpupNetworkError as UpupNetworkError2 } from "@useupup/core";
1844
+
1845
+ // src/drive-clients.ts
1846
+ import { UpupNetworkError } from "@useupup/core";
1847
+ var DRIVE_CLIENTS = {
1848
+ "google-drive": {
1849
+ listFiles: listGoogleDriveFiles,
1850
+ fetchFile: fetchGoogleDriveFile
1851
+ },
1852
+ "one-drive": { listFiles: listOneDriveFiles, fetchFile: fetchOneDriveFile },
1853
+ dropbox: { listFiles: listDropboxFiles, fetchFile: fetchDropboxFile },
1854
+ box: { listFiles: listBoxFiles, fetchFile: fetchBoxFile }
1855
+ };
1856
+ function getDriveClient(provider) {
1857
+ return DRIVE_CLIENTS[provider];
1858
+ }
1859
+ async function driveFetch(url, init = {}) {
1860
+ const res = await fetch(url, init);
1861
+ if (res.status === 401) {
1862
+ throw new UpupNetworkError("Drive API 401", 401);
1863
+ }
1864
+ if (!res.ok) {
1865
+ const text = await res.text().catch(() => "");
1866
+ throw new UpupNetworkError(
1867
+ `Drive API ${res.status}: ${text.slice(0, 200) || res.statusText}`,
1868
+ res.status
1869
+ );
1870
+ }
1871
+ return res;
1872
+ }
1873
+ function escapeDriveQueryValue(value) {
1874
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1875
+ }
1876
+ function escapeODataSearchValue(value) {
1877
+ return value.replace(/'/g, "''");
1878
+ }
1879
+ function httpHeaderSafeJson(value) {
1880
+ return JSON.stringify(value).replace(
1881
+ /[\u007f-\uffff]/g,
1882
+ (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")
1883
+ );
1884
+ }
1885
+ async function listGoogleDriveFiles(accessToken, opts) {
1886
+ const parent = opts.folderId ?? "root";
1887
+ const q = opts.search ? `name contains '${escapeDriveQueryValue(opts.search)}' and trashed = false` : `'${parent}' in parents and trashed = false`;
1888
+ const params = new URLSearchParams({
1889
+ q,
1890
+ fields: "files(id,name,size,mimeType,thumbnailLink,modifiedTime,iconLink)",
1891
+ pageSize: "200"
1892
+ });
1893
+ const res = await driveFetch(
1894
+ `https://www.googleapis.com/drive/v3/files?${params.toString()}`,
1895
+ { headers: { Authorization: `Bearer ${accessToken}` } }
1896
+ );
1897
+ const data = await res.json();
1898
+ return data.files.map((f) => ({
1899
+ id: f.id,
1900
+ name: f.name,
1901
+ size: f.size ? Number(f.size) : void 0,
1902
+ mimeType: f.mimeType,
1903
+ thumbnailUrl: f.thumbnailLink,
1904
+ isFolder: f.mimeType === "application/vnd.google-apps.folder",
1905
+ modifiedAt: f.modifiedTime
1906
+ }));
1907
+ }
1908
+ async function fetchGoogleDriveFile(accessToken, body) {
1909
+ const metaRes = await driveFetch(
1910
+ `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(
1911
+ body.fileId
1912
+ )}?fields=name,size,mimeType`,
1913
+ { headers: { Authorization: `Bearer ${accessToken}` } }
1914
+ );
1915
+ const meta = await metaRes.json();
1916
+ const dlRes = await driveFetch(
1917
+ `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(
1918
+ body.fileId
1919
+ )}?alt=media`,
1920
+ { headers: { Authorization: `Bearer ${accessToken}` } }
1921
+ );
1922
+ if (!dlRes.body) throw new UpupNetworkError("Empty download body");
1923
+ return {
1924
+ stream: dlRes.body,
1925
+ size: Number(meta.size ?? body.size ?? 0),
1926
+ fileName: body.fileName ?? meta.name,
1927
+ mimeType: body.mimeType ?? meta.mimeType ?? "application/octet-stream"
1928
+ };
1929
+ }
1930
+ async function listOneDriveFiles(accessToken, opts) {
1931
+ const path = opts.search ? `/me/drive/root/search(q='${encodeURIComponent(escapeODataSearchValue(opts.search))}')` : opts.folderId ? `/me/drive/items/${encodeURIComponent(opts.folderId)}/children` : "/me/drive/root/children";
1932
+ const res = await driveFetch(`https://graph.microsoft.com/v1.0${path}`, {
1933
+ headers: { Authorization: `Bearer ${accessToken}` }
1934
+ });
1935
+ const data = await res.json();
1936
+ return data.value.map((f) => ({
1937
+ id: f.id,
1938
+ name: f.name,
1939
+ size: f.size,
1940
+ mimeType: f.file?.mimeType,
1941
+ isFolder: !!f.folder,
1942
+ modifiedAt: f.lastModifiedDateTime
1943
+ }));
1944
+ }
1945
+ async function fetchOneDriveFile(accessToken, body) {
1946
+ const metaRes = await driveFetch(
1947
+ `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(
1948
+ body.fileId
1949
+ )}`,
1950
+ { headers: { Authorization: `Bearer ${accessToken}` } }
1951
+ );
1952
+ const meta = await metaRes.json();
1953
+ const dlRes = await driveFetch(
1954
+ `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(
1955
+ body.fileId
1956
+ )}/content`,
1957
+ { headers: { Authorization: `Bearer ${accessToken}` } }
1958
+ );
1959
+ if (!dlRes.body) throw new UpupNetworkError("Empty download body");
1960
+ return {
1961
+ stream: dlRes.body,
1962
+ size: meta.size ?? body.size ?? 0,
1963
+ fileName: body.fileName ?? meta.name,
1964
+ mimeType: body.mimeType ?? meta.file?.mimeType ?? "application/octet-stream"
1965
+ };
1966
+ }
1967
+ async function listDropboxFiles(accessToken, opts) {
1968
+ const endpoint = opts.search ? "https://api.dropboxapi.com/2/files/search_v2" : "https://api.dropboxapi.com/2/files/list_folder";
1969
+ const body = opts.search ? { query: opts.search } : { path: opts.folderId ?? "", recursive: false };
1970
+ const res = await driveFetch(endpoint, {
1971
+ method: "POST",
1972
+ headers: {
1973
+ Authorization: `Bearer ${accessToken}`,
1974
+ "Content-Type": "application/json"
1975
+ },
1976
+ body: JSON.stringify(body)
1977
+ });
1978
+ const data = await res.json();
1979
+ const entries = "entries" in data ? data.entries : data.matches.map((m) => m.metadata.metadata);
1980
+ return entries.filter((e) => e[".tag"] !== "deleted").map((e) => ({
1981
+ id: e.path_lower ?? e.id,
1982
+ name: e.name,
1983
+ size: "size" in e ? e.size : void 0,
1984
+ isFolder: e[".tag"] === "folder",
1985
+ modifiedAt: "server_modified" in e ? e.server_modified : void 0
1986
+ }));
1987
+ }
1988
+ async function fetchDropboxFile(accessToken, body) {
1989
+ const dlRes = await driveFetch(
1990
+ "https://content.dropboxapi.com/2/files/download",
1991
+ {
1992
+ method: "POST",
1993
+ headers: {
1994
+ Authorization: `Bearer ${accessToken}`,
1995
+ "Dropbox-API-Arg": httpHeaderSafeJson({ path: body.fileId })
1996
+ }
1997
+ }
1998
+ );
1999
+ if (!dlRes.body) throw new UpupNetworkError("Empty download body");
2000
+ const apiResult = dlRes.headers.get("Dropbox-API-Result");
2001
+ let name = body.fileName ?? "download";
2002
+ let size = body.size ?? 0;
2003
+ if (apiResult) {
2004
+ try {
2005
+ const parsed = JSON.parse(apiResult);
2006
+ name = body.fileName ?? parsed.name ?? name;
2007
+ size = parsed.size ?? size;
2008
+ } catch {
2009
+ }
2010
+ }
2011
+ return {
2012
+ stream: dlRes.body,
2013
+ size,
2014
+ fileName: name,
2015
+ mimeType: body.mimeType ?? dlRes.headers.get("Content-Type") ?? "application/octet-stream"
2016
+ };
2017
+ }
2018
+ async function listBoxFiles(accessToken, opts) {
2019
+ if (opts.search) {
2020
+ const params = new URLSearchParams({
2021
+ query: opts.search,
2022
+ limit: "200"
2023
+ });
2024
+ const res2 = await driveFetch(
2025
+ `https://api.box.com/2.0/search?${params.toString()}`,
2026
+ { headers: { Authorization: `Bearer ${accessToken}` } }
2027
+ );
2028
+ const data2 = await res2.json();
2029
+ return data2.entries.map((e) => ({
2030
+ id: e.id,
2031
+ name: e.name,
2032
+ size: e.size,
2033
+ isFolder: e.type === "folder",
2034
+ modifiedAt: e.modified_at
2035
+ }));
2036
+ }
2037
+ const folderId = opts.folderId ?? "0";
2038
+ const res = await driveFetch(
2039
+ `https://api.box.com/2.0/folders/${encodeURIComponent(folderId)}/items?limit=200&fields=id,name,size,type,modified_at`,
2040
+ { headers: { Authorization: `Bearer ${accessToken}` } }
2041
+ );
2042
+ const data = await res.json();
2043
+ return data.entries.map((e) => ({
2044
+ id: e.id,
2045
+ name: e.name,
2046
+ size: e.size,
2047
+ isFolder: e.type === "folder",
2048
+ modifiedAt: e.modified_at
2049
+ }));
2050
+ }
2051
+ async function fetchBoxFile(accessToken, body) {
2052
+ const metaRes = await driveFetch(
2053
+ `https://api.box.com/2.0/files/${encodeURIComponent(body.fileId)}`,
2054
+ { headers: { Authorization: `Bearer ${accessToken}` } }
2055
+ );
2056
+ const meta = await metaRes.json();
2057
+ const dlRes = await driveFetch(
2058
+ `https://api.box.com/2.0/files/${encodeURIComponent(body.fileId)}/content`,
2059
+ {
2060
+ headers: { Authorization: `Bearer ${accessToken}` },
2061
+ redirect: "follow"
2062
+ }
2063
+ );
2064
+ if (!dlRes.body) throw new UpupNetworkError("Empty download body");
2065
+ return {
2066
+ stream: dlRes.body,
2067
+ size: meta.size ?? body.size ?? 0,
2068
+ fileName: body.fileName ?? meta.name,
2069
+ mimeType: body.mimeType ?? dlRes.headers.get("Content-Type") ?? "application/octet-stream"
2070
+ };
2071
+ }
2072
+
2073
+ // src/drive-routes.ts
2074
+ async function handleListFiles(req, config, provider, res) {
2075
+ if (!isValidProvider(provider)) {
2076
+ return res.json({ error: `Unknown provider: ${provider}` }, 400);
2077
+ }
2078
+ if (!config.tokenStore)
2079
+ return res.json({ error: "tokenStore is required" }, 500);
2080
+ const userId = await resolveUserId(config, req);
2081
+ if (!userId) return res.json({ error: "Unauthenticated" }, 401);
2082
+ let tokens = await getTokens(config.tokenStore, userId, provider);
2083
+ if (!tokens) {
2084
+ return res.json({ reauth: true, provider }, 401);
2085
+ }
2086
+ if (tokens.refreshToken && tokens.expiresAt && Date.now() > tokens.expiresAt - 3e4) {
2087
+ const refreshed = await refreshAccessToken(
2088
+ config,
2089
+ provider,
2090
+ userId,
2091
+ tokens
2092
+ );
2093
+ if (!refreshed) {
2094
+ return res.json({ reauth: true, provider }, 401);
2095
+ }
2096
+ tokens = refreshed;
2097
+ }
2098
+ const url = new URL(req.url);
2099
+ const folderId = url.searchParams.get("folderId") ?? void 0;
2100
+ const search = url.searchParams.get("search") ?? void 0;
2101
+ try {
2102
+ const files = await getDriveClient(provider).listFiles(
2103
+ tokens.accessToken,
2104
+ { folderId, search }
2105
+ );
2106
+ return res.json({ provider, files }, 200);
2107
+ } catch (err) {
2108
+ if (err instanceof UpupNetworkError2 && err.status === 401) {
2109
+ await deleteTokens(config.tokenStore, userId, provider);
2110
+ return res.json({ reauth: true, provider }, 401);
2111
+ }
2112
+ return res.fail(
2113
+ `files/${provider}`,
2114
+ req.method,
2115
+ 500,
2116
+ UpupErrorCode5.STORAGE_ERROR,
2117
+ "Drive request failed",
2118
+ err
2119
+ );
2120
+ }
2121
+ }
2122
+ async function handleFileTransfer(req, config, provider, res) {
2123
+ if (!isValidProvider(provider)) {
2124
+ return res.json({ error: `Unknown provider: ${provider}` }, 400);
2125
+ }
2126
+ if (!config.tokenStore)
2127
+ return res.json({ error: "tokenStore is required" }, 500);
2128
+ const userId = await resolveUserId(config, req);
2129
+ if (!userId) return res.json({ error: "Unauthenticated" }, 401);
2130
+ let tokens = await getTokens(config.tokenStore, userId, provider);
2131
+ if (!tokens) return res.json({ reauth: true, provider }, 401);
2132
+ if (tokens.refreshToken && tokens.expiresAt && Date.now() > tokens.expiresAt - 3e4) {
2133
+ const refreshed = await refreshAccessToken(
2134
+ config,
2135
+ provider,
2136
+ userId,
2137
+ tokens
2138
+ );
2139
+ if (!refreshed) {
2140
+ return res.json({ reauth: true, provider }, 401);
2141
+ }
2142
+ tokens = refreshed;
2143
+ }
2144
+ let body;
2145
+ try {
2146
+ body = await req.json();
2147
+ } catch {
2148
+ return res.json({ error: "Invalid JSON body" }, 400);
2149
+ }
2150
+ if (!body.fileId) return res.json({ error: "Missing fileId" }, 400);
2151
+ if (config.maxFileSize && typeof body.size === "number" && body.size > config.maxFileSize) {
2152
+ return res.json({ error: "File too large" }, 413);
2153
+ }
2154
+ if (!matchesAllowedType(body.mimeType ?? "", config.allowedTypes)) {
2155
+ return res.json({ error: "File type not allowed" }, 415);
2156
+ }
2157
+ try {
2158
+ const { stream, size, fileName, mimeType } = await getDriveClient(
2159
+ provider
2160
+ ).fetchFile(tokens.accessToken, body);
2161
+ const storage = await resolveStorage(config, {
2162
+ req,
2163
+ phase: "drive-transfer",
2164
+ userId,
2165
+ ...body.metadata !== void 0 ? { metadata: body.metadata } : {},
2166
+ fileName,
2167
+ contentType: mimeType,
2168
+ size
2169
+ });
2170
+ const { transferDriveFileToS3: transferDriveFileToS32 } = await Promise.resolve().then(() => (init_transfer(), transfer_exports));
2171
+ const result = await transferDriveFileToS32({
2172
+ stream,
2173
+ size,
2174
+ fileName,
2175
+ mimeType,
2176
+ storage,
2177
+ // Enforce maxFileSize against the ACTUAL streamed bytes, and route
2178
+ // an abort-cleanup failure through onError instead of swallowing it
2179
+ // (F-743 / F-744).
2180
+ maxBytes: config.maxFileSize,
2181
+ onError: config.onError,
2182
+ requestId: res.requestId,
2183
+ downloadUrlExpiresIn: config.downloadUrlExpiresIn
2184
+ });
2185
+ await runPostCompletionHooks(
2186
+ config,
2187
+ res,
2188
+ `files/${provider}/transfer`,
2189
+ req.method,
2190
+ async () => {
2191
+ if (config.hooks?.onFileUploaded)
2192
+ await config.hooks.onFileUploaded(result, req);
2193
+ }
2194
+ );
2195
+ return res.json({ provider, ...result }, 200);
2196
+ } catch (err) {
2197
+ if (err instanceof UpupNetworkError2 && err.status === 401) {
2198
+ await deleteTokens(config.tokenStore, userId, provider);
2199
+ return res.json({ reauth: true, provider }, 401);
2200
+ }
2201
+ return res.fail(
2202
+ `files/${provider}/transfer`,
2203
+ req.method,
2204
+ 500,
2205
+ UpupErrorCode5.STORAGE_ERROR,
2206
+ "Drive request failed",
2207
+ err
2208
+ );
2209
+ }
2210
+ }
2211
+
2212
+ // src/handler.ts
2213
+ function createUpupHandler(config) {
2214
+ assertUploadTokenSecret(config.uploadTokenSecret);
2215
+ validateServerConfig(config);
2216
+ if (!isStorageResolver(config.storage)) assertS3Storage(config.storage);
2217
+ if ((config.providers || config.tokenStore) && !config.getUserId && !config.allowAnonymous) {
2218
+ throw new UpupConfigError5(
2219
+ "[@useupup/server] drive providers / tokenStore require config.getUserId to scope tokens per user. Set getUserId, or set allowAnonymous:true to intentionally share ONE anonymous namespace (demos only)."
2220
+ );
2221
+ }
2222
+ if (config.allowAnonymousUploads) {
2223
+ console.warn(
2224
+ "[@useupup/server] allowAnonymousUploads:true \u2014 /presign and /multipart/init accept UNAUTHENTICATED uploads under a shared anonymous namespace. Demos / upstream-auth deployments only; never enable in multi-tenant production."
2225
+ );
2226
+ }
2227
+ return async (req) => {
2228
+ const url = new URL(req.url);
2229
+ const path = url.pathname.replace(/\/+$/, "") || "/";
2230
+ const res = createResponder(req, config);
2231
+ try {
2232
+ if (req.method === "OPTIONS") {
2233
+ return res.noContent();
2234
+ }
2235
+ if (req.method === "GET" && path.endsWith("/health")) {
2236
+ return await handleHealth(config, res);
2237
+ }
2238
+ if (config.auth) {
2239
+ const authorized = await config.auth(req);
2240
+ if (!authorized) {
2241
+ return res.json({ error: "Unauthorized" }, 401);
2242
+ }
2243
+ }
2244
+ if (req.method === "POST" && path.endsWith("/presign")) {
2245
+ return await handlePresign(req, config, res);
2246
+ }
2247
+ if (req.method === "POST" && path.endsWith("/multipart/init")) {
2248
+ return await handleMultipartInit(req, config, res);
2249
+ }
2250
+ if (req.method === "POST" && path.endsWith("/multipart/sign-part")) {
2251
+ return await handleMultipartSignPart(req, config, res);
2252
+ }
2253
+ if (req.method === "POST" && path.endsWith("/multipart/complete")) {
2254
+ return await handleMultipartComplete(req, config, res);
2255
+ }
2256
+ if (req.method === "POST" && path.endsWith("/multipart/abort")) {
2257
+ return await handleMultipartAbort(req, config, res);
2258
+ }
2259
+ if (req.method === "POST" && path.endsWith("/multipart/resume") && (config.multipartResumeWindowSeconds ?? DEFAULT_MULTIPART_RESUME_WINDOW_SECONDS) > 0) {
2260
+ return await handleMultipartResume(req, config, res);
2261
+ }
2262
+ const authMatch = path.match(/\/auth\/([\w-]+?)(?:\/(cb))?$/);
2263
+ if (req.method === "GET" && authMatch) {
2264
+ const provider = authMatch[1];
2265
+ if (provider === void 0) {
2266
+ return res.json({ error: "Not found" }, 404);
2267
+ }
2268
+ const isCallback = authMatch[2] === "cb";
2269
+ if (isCallback) {
2270
+ return await handleOAuthCallback(req, config, provider, res);
2271
+ }
2272
+ return await handleOAuthRedirect(req, config, provider, res);
2273
+ }
2274
+ const filesMatch = path.match(
2275
+ /\/files\/([\w-]+?)(?:\/(transfer))?$/
2276
+ );
2277
+ if (filesMatch) {
2278
+ const provider = filesMatch[1];
2279
+ if (provider === void 0) {
2280
+ return res.json({ error: "Not found" }, 404);
2281
+ }
2282
+ const isTransfer = filesMatch[2] === "transfer";
2283
+ if (req.method === "POST" && isTransfer) {
2284
+ return await handleFileTransfer(req, config, provider, res);
2285
+ }
2286
+ if (req.method === "GET" && !isTransfer) {
2287
+ return await handleListFiles(req, config, provider, res);
2288
+ }
2289
+ }
2290
+ return res.json({ error: "Not found" }, 404);
2291
+ } catch (error) {
2292
+ return res.fail(
2293
+ "router",
2294
+ req.method,
2295
+ 500,
2296
+ UpupErrorCode6.STORAGE_ERROR,
2297
+ "Internal error",
2298
+ error
2299
+ );
2300
+ }
2301
+ };
2302
+ }
2303
+
2304
+ // src/node-http-bridge.ts
2305
+ function nodeHeadersToWeb(h) {
2306
+ const headers = new Headers();
2307
+ for (const [k, v] of Object.entries(h)) {
2308
+ if (v === void 0) continue;
2309
+ if (Array.isArray(v)) for (const one of v) headers.append(k, one);
2310
+ else headers.set(k, v);
2311
+ }
2312
+ return headers;
2313
+ }
2314
+ function toWebRequest(input) {
2315
+ const hasBody = input.method !== "GET" && input.method !== "HEAD";
2316
+ return new Request(input.url, {
2317
+ method: input.method,
2318
+ headers: nodeHeadersToWeb(input.headers),
2319
+ ...hasBody && input.body != null ? { body: input.body } : {}
2320
+ });
2321
+ }
2322
+ async function writeWebResponse(sink, webRes) {
2323
+ sink.status(webRes.status);
2324
+ webRes.headers.forEach((value, key) => {
2325
+ if (key.toLowerCase() === "content-length") return;
2326
+ sink.setHeader(key, value);
2327
+ });
2328
+ sink.send(Buffer.from(await webRes.arrayBuffer()));
2329
+ }
2330
+
2331
+ // src/express.ts
2332
+ function createUpupMiddleware(config) {
2333
+ const handler = createUpupHandler(config);
2334
+ return async (req, res, _next) => {
2335
+ const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
2336
+ const webReq = toWebRequest({
2337
+ url,
2338
+ method: req.method,
2339
+ headers: req.headers,
2340
+ body: req.method !== "GET" && req.method !== "HEAD" ? JSON.stringify(req.body) : void 0
2341
+ });
2342
+ const webRes = await handler(webReq);
2343
+ await writeWebResponse(
2344
+ {
2345
+ status: (c) => res.status(c),
2346
+ setHeader: (k, v) => res.setHeader(k, v),
2347
+ send: (b) => {
2348
+ res.send(b);
2349
+ }
2350
+ },
2351
+ webRes
2352
+ );
2353
+ };
2354
+ }
2355
+ export {
2356
+ createUpupMiddleware
2357
+ };
2358
+ //# sourceMappingURL=express.js.map