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