@broberg/s3 0.0.0-stage → 0.1.1

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/dist/index.cjs ADDED
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ var aws4fetch = require('aws4fetch');
4
+
5
+ // src/server.ts
6
+ var MIN_PART_SIZE = 8 * 1024 * 1024;
7
+ var DEFAULT_PART_SIZE = 16 * 1024 * 1024;
8
+ var MAX_PARTS = 1e4;
9
+ var MAX_EXPIRES_SECONDS = 900;
10
+ var MiB = 1024 * 1024;
11
+ function choosePartSize(size, preferred = DEFAULT_PART_SIZE) {
12
+ const need = Math.ceil(size / MAX_PARTS);
13
+ return Math.ceil(Math.max(preferred, MIN_PART_SIZE, need) / MiB) * MiB;
14
+ }
15
+ var encodeKey = (key) => key.split("/").map(encodeURIComponent).join("/");
16
+ var xmlText = (xml, tag) => xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
17
+ var xmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
18
+ var xmlUnescape = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
19
+ var S3Error = class extends Error {
20
+ constructor(message, status, s3Code) {
21
+ super(message);
22
+ this.status = status;
23
+ this.s3Code = s3Code;
24
+ this.name = "S3Error";
25
+ }
26
+ status;
27
+ s3Code;
28
+ };
29
+ function originOf(cfg) {
30
+ if (cfg.endpoint) return cfg.endpoint.replace(/\/+$/, "");
31
+ if (!cfg.accountId) throw new Error("@broberg/s3: set accountId (R2) or endpoint (other S3-compatible storage)");
32
+ const j = cfg.jurisdiction ?? "eu";
33
+ return j === "eu" ? `https://${cfg.accountId}.eu.r2.cloudflarestorage.com` : `https://${cfg.accountId}.r2.cloudflarestorage.com`;
34
+ }
35
+ function createS3Signer(cfg) {
36
+ for (const f of ["bucket", "accessKeyId", "secretAccessKey"])
37
+ if (!cfg[f]) throw new Error(`@broberg/s3: ${f} is required`);
38
+ const origin = originOf(cfg);
39
+ const doFetch = cfg.fetch ?? ((r) => fetch(r));
40
+ const aws = new aws4fetch.AwsClient({
41
+ accessKeyId: cfg.accessKeyId,
42
+ secretAccessKey: cfg.secretAccessKey,
43
+ region: cfg.region ?? "auto",
44
+ service: "s3"
45
+ });
46
+ const objectUrl = (key) => new URL(`${origin}/${encodeURIComponent(cfg.bucket)}/${encodeKey(key)}`);
47
+ const clampExpiry = (s) => Math.max(1, Math.min(s ?? MAX_EXPIRES_SECONDS, MAX_EXPIRES_SECONDS));
48
+ async function call(method, url, body, headers) {
49
+ const signed = await aws.sign(url.toString(), { method, body, headers });
50
+ const res = await doFetch(signed);
51
+ const text = await res.text();
52
+ if (!res.ok || /<Error>/.test(text)) {
53
+ const code = xmlText(text, "Code");
54
+ throw new S3Error(`@broberg/s3: ${method} ${url.pathname} \u2192 ${res.status}${code ? ` ${code}` : ""}`, res.ok ? 502 : res.status, code);
55
+ }
56
+ return text;
57
+ }
58
+ async function presign(method, url, expiresIn) {
59
+ url.searchParams.set("X-Amz-Expires", String(clampExpiry(expiresIn)));
60
+ const signed = await aws.sign(url.toString(), { method, aws: { signQuery: true } });
61
+ return signed.url;
62
+ }
63
+ return {
64
+ async start({ key, contentType }) {
65
+ const url = objectUrl(key);
66
+ url.searchParams.set("uploads", "");
67
+ const xml = await call("POST", url, void 0, contentType ? { "content-type": contentType } : void 0);
68
+ const uploadId = xmlText(xml, "UploadId");
69
+ if (!uploadId) throw new S3Error("@broberg/s3: CreateMultipartUpload returned no UploadId", 502);
70
+ return { uploadId: xmlUnescape(uploadId) };
71
+ },
72
+ async signPart({ key, uploadId, partNumber, expiresIn }) {
73
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_PARTS)
74
+ throw new S3Error(`@broberg/s3: partNumber must be 1..${MAX_PARTS}`, 400);
75
+ const url = objectUrl(key);
76
+ url.searchParams.set("partNumber", String(partNumber));
77
+ url.searchParams.set("uploadId", uploadId);
78
+ return presign("PUT", url, expiresIn);
79
+ },
80
+ async listParts({ key, uploadId }) {
81
+ const parts = [];
82
+ let marker;
83
+ for (; ; ) {
84
+ const url = objectUrl(key);
85
+ url.searchParams.set("uploadId", uploadId);
86
+ if (marker) url.searchParams.set("part-number-marker", marker);
87
+ const xml = await call("GET", url);
88
+ for (const m of xml.matchAll(/<Part>([\s\S]*?)<\/Part>/g)) {
89
+ parts.push({
90
+ partNumber: Number(xmlText(m[1], "PartNumber")),
91
+ etag: xmlUnescape(xmlText(m[1], "ETag") ?? ""),
92
+ size: Number(xmlText(m[1], "Size"))
93
+ });
94
+ }
95
+ if (xmlText(xml, "IsTruncated") !== "true") break;
96
+ marker = xmlText(xml, "NextPartNumberMarker");
97
+ if (!marker) break;
98
+ }
99
+ return parts;
100
+ },
101
+ async complete({ key, uploadId, parts }) {
102
+ const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
103
+ const body = "<CompleteMultipartUpload>" + sorted.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${xmlEscape(p.etag)}</ETag></Part>`).join("") + "</CompleteMultipartUpload>";
104
+ const url = objectUrl(key);
105
+ url.searchParams.set("uploadId", uploadId);
106
+ await call("POST", url, body, { "content-type": "application/xml" });
107
+ },
108
+ async abort({ key, uploadId }) {
109
+ const url = objectUrl(key);
110
+ url.searchParams.set("uploadId", uploadId);
111
+ await call("DELETE", url);
112
+ },
113
+ async signDownload({ key, expiresIn, filename }) {
114
+ const url = objectUrl(key);
115
+ if (filename) {
116
+ const ascii = filename.replace(/[^\x20-\x7e]|["\\]/g, "_");
117
+ url.searchParams.set("response-content-disposition", `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
118
+ }
119
+ return presign("GET", url, expiresIn);
120
+ },
121
+ async head(key) {
122
+ const signed = await aws.sign(objectUrl(key).toString(), { method: "HEAD" });
123
+ const res = await doFetch(signed);
124
+ if (res.status === 404) return null;
125
+ if (!res.ok) throw new S3Error(`@broberg/s3: HEAD \u2192 ${res.status}`, res.status);
126
+ return Number(res.headers.get("content-length"));
127
+ }
128
+ };
129
+ }
130
+ function createS3SignerFromEnv(env = globalThis.process?.env ?? {}) {
131
+ const r2 = ["R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET"];
132
+ const s3 = ["S3_ENDPOINT", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_BUCKET"];
133
+ const pick = (names2) => names2.filter((n) => env[n]);
134
+ const r2Set = pick(r2), s3Set = pick(s3);
135
+ if (r2Set.length === 0 && s3Set.length === 0) return null;
136
+ const [names, set] = r2Set.length >= s3Set.length ? [r2, r2Set] : [s3, s3Set];
137
+ if (set.length !== names.length)
138
+ throw new Error(`@broberg/s3: incomplete storage config \u2014 missing ${names.filter((n) => !env[n]).join(", ")}`);
139
+ if (names === r2) {
140
+ const j = env.R2_JURISDICTION;
141
+ if (j && j !== "eu" && j !== "default") throw new Error(`@broberg/s3: R2_JURISDICTION must be "eu" or "default"`);
142
+ return createS3Signer({
143
+ accountId: env.R2_ACCOUNT_ID,
144
+ accessKeyId: env.R2_ACCESS_KEY_ID,
145
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
146
+ bucket: env.R2_BUCKET,
147
+ jurisdiction: j ?? "eu"
148
+ });
149
+ }
150
+ return createS3Signer({
151
+ endpoint: env.S3_ENDPOINT,
152
+ accessKeyId: env.S3_ACCESS_KEY_ID,
153
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
154
+ bucket: env.S3_BUCKET,
155
+ region: env.S3_REGION
156
+ });
157
+ }
158
+
159
+ // src/handler.ts
160
+ var GiB = 1024 ** 3;
161
+ var MAX_SIGN_BATCH = 100;
162
+ var HttpError = class extends Error {
163
+ constructor(status, code, message) {
164
+ super(message);
165
+ this.status = status;
166
+ this.code = code;
167
+ }
168
+ status;
169
+ code;
170
+ };
171
+ var json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
172
+ var safeFilename = (name) => name.normalize("NFKC").replace(/[\\/\u0000-\u001f\u007f]/g, "_").replace(/^\.+/, "").replace(/[^\p{L}\p{N}._ -]/gu, "_").trim().slice(0, 120) || "file";
173
+ function checkPrefix(prefix) {
174
+ if (typeof prefix !== "string") throw new HttpError(500, "bad_prefix", "authorize() returned no prefix");
175
+ const p = prefix.replace(/^\/+|\/+$/g, "");
176
+ if (!p || p.split("/").some((s) => !s || s === "." || s === "..") || /[%\\\u0000-\u001f]/.test(p))
177
+ throw new HttpError(500, "bad_prefix", "authorize() returned an unsafe prefix");
178
+ return p;
179
+ }
180
+ function checkKey(key, prefix) {
181
+ if (typeof key !== "string" || key.length > 1024) throw new HttpError(400, "bad_key", "key is required");
182
+ const segs = key.split("/");
183
+ if (/[%\\\u0000-\u001f\u007f]/.test(key) || segs.some((s) => !s || s === "." || s === "..") || !key.startsWith(prefix + "/"))
184
+ throw new HttpError(403, "forbidden_key", "that object is not yours");
185
+ return key;
186
+ }
187
+ var checkUploadId = (id) => {
188
+ if (typeof id !== "string" || !id || id.length > 1024 || /[\u0000-\u001f]/.test(id))
189
+ throw new HttpError(400, "bad_upload_id", "uploadId is required");
190
+ return id;
191
+ };
192
+ function typeAllowed(type, allowed) {
193
+ if (!allowed?.length) return true;
194
+ const t = type.toLowerCase();
195
+ return allowed.some((a) => {
196
+ const x = a.toLowerCase();
197
+ return x.endsWith("/*") ? t.startsWith(x.slice(0, -1)) : t === x;
198
+ });
199
+ }
200
+ var randomId = () => {
201
+ const b = new Uint8Array(9);
202
+ crypto.getRandomValues(b);
203
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
204
+ };
205
+ var defaultKeyFor = ({ prefix, filename }) => `${prefix}/${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}/${randomId()}-${safeFilename(filename)}`;
206
+ function createS3Handler(signer, opts) {
207
+ const maxBytes = opts.maxBytes ?? 20 * GiB;
208
+ const expiresIn = Math.min(opts.expiresIn ?? MAX_EXPIRES_SECONDS, MAX_EXPIRES_SECONDS);
209
+ return async (req) => {
210
+ try {
211
+ if (req.method !== "POST") throw new HttpError(405, "method_not_allowed", "POST only");
212
+ if (!signer) throw new HttpError(503, "not_configured", "file storage is not configured on this server");
213
+ const who = await opts.authorize(req);
214
+ if (!who) throw new HttpError(401, "unauthorized", "sign in first");
215
+ const prefix = checkPrefix(who.prefix);
216
+ if (!(req.headers.get("content-type") ?? "").toLowerCase().startsWith("application/json"))
217
+ throw new HttpError(415, "json_required", "content-type must be application/json");
218
+ let body;
219
+ try {
220
+ body = await req.json();
221
+ } catch {
222
+ throw new HttpError(400, "bad_json", "body must be JSON");
223
+ }
224
+ if (!body || typeof body !== "object") throw new HttpError(400, "bad_json", "body must be a JSON object");
225
+ switch (body.action) {
226
+ case "start": {
227
+ const filename = typeof body.filename === "string" ? body.filename : "";
228
+ const size = body.size;
229
+ const contentType = typeof body.type === "string" && body.type ? body.type : "application/octet-stream";
230
+ if (!filename) throw new HttpError(400, "bad_filename", "filename is required");
231
+ if (typeof size !== "number" || !Number.isFinite(size) || size < 1) throw new HttpError(400, "bad_size", "size must be a positive number");
232
+ if (size > maxBytes) throw new HttpError(413, "too_large", `file is larger than ${maxBytes} bytes`);
233
+ if (!typeAllowed(contentType, opts.allowedTypes)) throw new HttpError(415, "type_not_allowed", `${contentType} is not accepted here`);
234
+ const key = checkKey((opts.keyFor ?? defaultKeyFor)({ prefix, filename, contentType }), prefix);
235
+ const partSize = choosePartSize(size, opts.partSize);
236
+ const { uploadId } = await signer.start({ key, contentType });
237
+ return json(200, { key, uploadId, partSize, partCount: Math.ceil(size / partSize) });
238
+ }
239
+ case "sign": {
240
+ const key = checkKey(body.key, prefix);
241
+ const uploadId = checkUploadId(body.uploadId);
242
+ const nums = body.partNumbers;
243
+ if (!Array.isArray(nums) || nums.length < 1 || nums.length > MAX_SIGN_BATCH || !nums.every((n) => Number.isInteger(n) && n >= 1 && n <= MAX_PARTS))
244
+ throw new HttpError(400, "bad_parts", `partNumbers: 1..${MAX_SIGN_BATCH} integers in 1..${MAX_PARTS}`);
245
+ const urls = await Promise.all(nums.map(async (partNumber) => ({
246
+ partNumber,
247
+ url: await signer.signPart({ key, uploadId, partNumber, expiresIn })
248
+ })));
249
+ return json(200, { urls, expiresIn });
250
+ }
251
+ case "list": {
252
+ const key = checkKey(body.key, prefix);
253
+ const parts = await signer.listParts({ key, uploadId: checkUploadId(body.uploadId) });
254
+ return json(200, { parts });
255
+ }
256
+ case "complete": {
257
+ const key = checkKey(body.key, prefix);
258
+ const uploadId = checkUploadId(body.uploadId);
259
+ const sent = body.parts;
260
+ if (!Array.isArray(sent) || sent.length < 1 || sent.length > MAX_PARTS)
261
+ throw new HttpError(400, "bad_parts", "parts are required");
262
+ const stored = await signer.listParts({ key, uploadId });
263
+ const total = stored.reduce((s, p) => s + (p.size ?? 0), 0);
264
+ if (total > maxBytes) {
265
+ await signer.abort({ key, uploadId });
266
+ throw new HttpError(413, "too_large", `upload exceeded ${maxBytes} bytes and was discarded`);
267
+ }
268
+ const parts = sent.map((p) => {
269
+ if (!Number.isInteger(p?.partNumber) || typeof p?.etag !== "string") throw new HttpError(400, "bad_parts", "each part needs partNumber and etag");
270
+ return { partNumber: p.partNumber, etag: p.etag };
271
+ });
272
+ await signer.complete({ key, uploadId, parts });
273
+ return json(200, { key, size: total });
274
+ }
275
+ case "abort": {
276
+ const key = checkKey(body.key, prefix);
277
+ await signer.abort({ key, uploadId: checkUploadId(body.uploadId) });
278
+ return json(200, { aborted: true });
279
+ }
280
+ case "download": {
281
+ const key = checkKey(body.key, prefix);
282
+ const filename = typeof body.filename === "string" && body.filename ? safeFilename(body.filename) : void 0;
283
+ const url = await signer.signDownload({ key, expiresIn, filename });
284
+ return json(200, { url, expiresIn });
285
+ }
286
+ default:
287
+ throw new HttpError(400, "unknown_action", "action must be start, sign, list, complete, abort or download");
288
+ }
289
+ } catch (e) {
290
+ if (e instanceof HttpError) return json(e.status, { error: { code: e.code, message: e.message } });
291
+ if (e instanceof S3Error) {
292
+ const status = e.s3Code === "NoSuchUpload" ? 404 : e.s3Code === "NoSuchKey" ? 404 : 502;
293
+ return json(status, { error: { code: e.s3Code ?? "storage_error", message: status === 404 ? "upload or object not found" : "storage refused the request" } });
294
+ }
295
+ return json(500, { error: { code: "internal", message: "upload service error" } });
296
+ }
297
+ };
298
+ }
299
+
300
+ exports.DEFAULT_PART_SIZE = DEFAULT_PART_SIZE;
301
+ exports.MAX_EXPIRES_SECONDS = MAX_EXPIRES_SECONDS;
302
+ exports.MAX_PARTS = MAX_PARTS;
303
+ exports.MIN_PART_SIZE = MIN_PART_SIZE;
304
+ exports.S3Error = S3Error;
305
+ exports.choosePartSize = choosePartSize;
306
+ exports.createS3Handler = createS3Handler;
307
+ exports.createS3Signer = createS3Signer;
308
+ exports.createS3SignerFromEnv = createS3SignerFromEnv;
309
+ exports.safeFilename = safeFilename;
310
+ //# sourceMappingURL=index.cjs.map
311
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/server.ts","../src/handler.ts"],"names":["AwsClient","names"],"mappings":"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","file":"index.cjs","sourcesContent":["// F111.1 — the server half of @broberg/s3. It holds the storage key and only ever\n// hands the browser SHORT-LIVED, ONE-OBJECT signatures. Bytes never pass through\n// the site's own server: the browser talks to the bucket directly.\nimport { AwsClient } from \"aws4fetch\";\n\n/** Where a bucket lives and how to sign for it. R2 in the EU is the default. */\nexport interface S3Config {\n bucket: string;\n accessKeyId: string;\n secretAccessKey: string;\n /** Cloudflare R2 account id → https://<id>.eu.r2.cloudflarestorage.com */\n accountId?: string;\n /** R2 jurisdiction. Defaults to \"eu\"; a non-EU R2 bucket must say \"default\" explicitly. */\n jurisdiction?: \"eu\" | \"default\";\n /** Any other S3-compatible endpoint origin (Tigris, AWS, MinIO), e.g. \"https://fly.storage.tigris.dev\". */\n endpoint?: string;\n /** Signing region. \"auto\" for R2 and Tigris (default); the bucket's region for AWS. */\n region?: string;\n /** Injected for tests; defaults to the global fetch. */\n fetch?: typeof fetch;\n}\n\nexport interface UploadedPart { partNumber: number; etag: string; size?: number }\n\nexport const MIN_PART_SIZE = 8 * 1024 * 1024;\nexport const DEFAULT_PART_SIZE = 16 * 1024 * 1024;\nexport const MAX_PARTS = 10_000;\n/** The longest a signed URL may live. A leaked link is useless after this. */\nexport const MAX_EXPIRES_SECONDS = 900;\nconst MiB = 1024 * 1024;\n\n/** Part size for `size` bytes: at least `preferred` (and 8 MiB), and few enough parts for S3's 10,000 cap. */\nexport function choosePartSize(size: number, preferred = DEFAULT_PART_SIZE): number {\n const need = Math.ceil(size / MAX_PARTS);\n return Math.ceil(Math.max(preferred, MIN_PART_SIZE, need) / MiB) * MiB;\n}\n\nconst encodeKey = (key: string) => key.split(\"/\").map(encodeURIComponent).join(\"/\");\nconst xmlText = (xml: string, tag: string) => xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];\nconst xmlEscape = (s: string) => s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\nconst xmlUnescape = (s: string) => s.replace(/&quot;/g, '\"').replace(/&lt;/g, \"<\").replace(/&gt;/g, \">\").replace(/&amp;/g, \"&\");\n\nexport interface S3Signer {\n /** Begin a multipart upload. */\n start(input: { key: string; contentType?: string }): Promise<{ uploadId: string }>;\n /** A presigned PUT for one part of one upload. */\n signPart(input: { key: string; uploadId: string; partNumber: number; expiresIn?: number }): Promise<string>;\n /** Parts the bucket already holds for this upload (the truth a resume starts from). */\n listParts(input: { key: string; uploadId: string }): Promise<UploadedPart[]>;\n complete(input: { key: string; uploadId: string; parts: UploadedPart[] }): Promise<void>;\n abort(input: { key: string; uploadId: string }): Promise<void>;\n /** A presigned GET; `filename` makes the browser save it under that name. */\n signDownload(input: { key: string; expiresIn?: number; filename?: string }): Promise<string>;\n /** Size of a stored object, or null when it does not exist. */\n head(key: string): Promise<number | null>;\n}\n\nexport class S3Error extends Error {\n constructor(message: string, readonly status: number, readonly s3Code?: string) {\n super(message);\n this.name = \"S3Error\";\n }\n}\n\nfunction originOf(cfg: S3Config): string {\n if (cfg.endpoint) return cfg.endpoint.replace(/\\/+$/, \"\");\n if (!cfg.accountId) throw new Error(\"@broberg/s3: set accountId (R2) or endpoint (other S3-compatible storage)\");\n const j = cfg.jurisdiction ?? \"eu\";\n return j === \"eu\" ? `https://${cfg.accountId}.eu.r2.cloudflarestorage.com` : `https://${cfg.accountId}.r2.cloudflarestorage.com`;\n}\n\nexport function createS3Signer(cfg: S3Config): S3Signer {\n for (const f of [\"bucket\", \"accessKeyId\", \"secretAccessKey\"] as const)\n if (!cfg[f]) throw new Error(`@broberg/s3: ${f} is required`);\n const origin = originOf(cfg);\n const doFetch = cfg.fetch ?? ((r: Request) => fetch(r));\n const aws = new AwsClient({\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n region: cfg.region ?? \"auto\",\n service: \"s3\",\n });\n const objectUrl = (key: string) => new URL(`${origin}/${encodeURIComponent(cfg.bucket)}/${encodeKey(key)}`);\n const clampExpiry = (s?: number) => Math.max(1, Math.min(s ?? MAX_EXPIRES_SECONDS, MAX_EXPIRES_SECONDS));\n\n async function call(method: string, url: URL, body?: string, headers?: Record<string, string>): Promise<string> {\n const signed = await aws.sign(url.toString(), { method, body, headers });\n const res = await doFetch(signed);\n const text = await res.text();\n // S3 can answer 200 with an <Error> body (CompleteMultipartUpload does).\n if (!res.ok || /<Error>/.test(text)) {\n const code = xmlText(text, \"Code\");\n throw new S3Error(`@broberg/s3: ${method} ${url.pathname} → ${res.status}${code ? ` ${code}` : \"\"}`, res.ok ? 502 : res.status, code);\n }\n return text;\n }\n\n async function presign(method: \"PUT\" | \"GET\", url: URL, expiresIn?: number): Promise<string> {\n url.searchParams.set(\"X-Amz-Expires\", String(clampExpiry(expiresIn)));\n const signed = await aws.sign(url.toString(), { method, aws: { signQuery: true } });\n return signed.url;\n }\n\n return {\n async start({ key, contentType }) {\n const url = objectUrl(key);\n url.searchParams.set(\"uploads\", \"\");\n const xml = await call(\"POST\", url, undefined, contentType ? { \"content-type\": contentType } : undefined);\n const uploadId = xmlText(xml, \"UploadId\");\n if (!uploadId) throw new S3Error(\"@broberg/s3: CreateMultipartUpload returned no UploadId\", 502);\n return { uploadId: xmlUnescape(uploadId) };\n },\n async signPart({ key, uploadId, partNumber, expiresIn }) {\n if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_PARTS)\n throw new S3Error(`@broberg/s3: partNumber must be 1..${MAX_PARTS}`, 400);\n const url = objectUrl(key);\n url.searchParams.set(\"partNumber\", String(partNumber));\n url.searchParams.set(\"uploadId\", uploadId);\n return presign(\"PUT\", url, expiresIn);\n },\n async listParts({ key, uploadId }) {\n const parts: UploadedPart[] = [];\n let marker: string | undefined;\n for (;;) {\n const url = objectUrl(key);\n url.searchParams.set(\"uploadId\", uploadId);\n if (marker) url.searchParams.set(\"part-number-marker\", marker);\n const xml = await call(\"GET\", url);\n for (const m of xml.matchAll(/<Part>([\\s\\S]*?)<\\/Part>/g)) {\n parts.push({\n partNumber: Number(xmlText(m[1]!, \"PartNumber\")),\n etag: xmlUnescape(xmlText(m[1]!, \"ETag\") ?? \"\"),\n size: Number(xmlText(m[1]!, \"Size\")),\n });\n }\n if (xmlText(xml, \"IsTruncated\") !== \"true\") break;\n marker = xmlText(xml, \"NextPartNumberMarker\");\n if (!marker) break;\n }\n return parts;\n },\n async complete({ key, uploadId, parts }) {\n const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);\n const body =\n \"<CompleteMultipartUpload>\" +\n sorted.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${xmlEscape(p.etag)}</ETag></Part>`).join(\"\") +\n \"</CompleteMultipartUpload>\";\n const url = objectUrl(key);\n url.searchParams.set(\"uploadId\", uploadId);\n await call(\"POST\", url, body, { \"content-type\": \"application/xml\" });\n },\n async abort({ key, uploadId }) {\n const url = objectUrl(key);\n url.searchParams.set(\"uploadId\", uploadId);\n await call(\"DELETE\", url);\n },\n async signDownload({ key, expiresIn, filename }) {\n const url = objectUrl(key);\n if (filename) {\n const ascii = filename.replace(/[^\\x20-\\x7e]|[\"\\\\]/g, \"_\");\n url.searchParams.set(\"response-content-disposition\", `attachment; filename=\"${ascii}\"; filename*=UTF-8''${encodeURIComponent(filename)}`);\n }\n return presign(\"GET\", url, expiresIn);\n },\n async head(key) {\n const signed = await aws.sign(objectUrl(key).toString(), { method: \"HEAD\" });\n const res = await doFetch(signed);\n if (res.status === 404) return null;\n if (!res.ok) throw new S3Error(`@broberg/s3: HEAD → ${res.status}`, res.status);\n return Number(res.headers.get(\"content-length\"));\n },\n };\n}\n\ntype Env = Record<string, string | undefined>;\n\n/**\n * Builds a signer from the environment, or returns null when NOTHING is configured\n * (ship dark: the handler then answers 503). A HALF-configured environment throws\n * at boot instead — that is a mistake, not a choice.\n *\n * R2: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET [, R2_JURISDICTION]\n * S3: S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET [, S3_REGION]\n */\nexport function createS3SignerFromEnv(env: Env = (globalThis as { process?: { env: Env } }).process?.env ?? {}): S3Signer | null {\n const r2 = [\"R2_ACCOUNT_ID\", \"R2_ACCESS_KEY_ID\", \"R2_SECRET_ACCESS_KEY\", \"R2_BUCKET\"];\n const s3 = [\"S3_ENDPOINT\", \"S3_ACCESS_KEY_ID\", \"S3_SECRET_ACCESS_KEY\", \"S3_BUCKET\"];\n const pick = (names: string[]) => names.filter((n) => env[n]);\n const r2Set = pick(r2), s3Set = pick(s3);\n if (r2Set.length === 0 && s3Set.length === 0) return null;\n const [names, set] = r2Set.length >= s3Set.length ? [r2, r2Set] : [s3, s3Set];\n if (set.length !== names.length)\n throw new Error(`@broberg/s3: incomplete storage config — missing ${names.filter((n) => !env[n]).join(\", \")}`);\n if (names === r2) {\n const j = env.R2_JURISDICTION;\n if (j && j !== \"eu\" && j !== \"default\") throw new Error(`@broberg/s3: R2_JURISDICTION must be \"eu\" or \"default\"`);\n return createS3Signer({\n accountId: env.R2_ACCOUNT_ID, accessKeyId: env.R2_ACCESS_KEY_ID!, secretAccessKey: env.R2_SECRET_ACCESS_KEY!,\n bucket: env.R2_BUCKET!, jurisdiction: (j as \"eu\" | \"default\" | undefined) ?? \"eu\",\n });\n }\n return createS3Signer({\n endpoint: env.S3_ENDPOINT, accessKeyId: env.S3_ACCESS_KEY_ID!, secretAccessKey: env.S3_SECRET_ACCESS_KEY!,\n bucket: env.S3_BUCKET!, region: env.S3_REGION,\n });\n}\n","// F111.1 — ONE fetch handler (Request → Response) for Hono/Bun and Next.\n// Every action asks the SITE's authorize() first and keeps the user inside the\n// object prefix it returns. The storage key never appears in a response.\nimport {\n MAX_EXPIRES_SECONDS, MAX_PARTS, S3Error, choosePartSize,\n type S3Signer, type UploadedPart,\n} from \"./server\";\n\nexport interface Authorized {\n /** Every object this user may touch lives under `<prefix>/`. E.g. \"users/<bid-sub>\". */\n prefix: string;\n}\n\nexport interface S3HandlerOptions {\n /** The site's own check (BID session + allowlist). Return null to refuse. */\n authorize(req: Request): Promise<Authorized | null> | Authorized | null;\n /** Largest object accepted, in bytes. Default 20 GiB. Enforced at start (declared) AND at complete (actual). */\n maxBytes?: number;\n /** Allowed content types, exact (\"video/mp4\") or wildcard (\"video/*\"). Default: any. Declared type, not sniffed. */\n allowedTypes?: string[];\n /** Preferred part size in bytes (min 8 MiB). Default 16 MiB. */\n partSize?: number;\n /** Signed-URL lifetime in seconds, max 900. */\n expiresIn?: number;\n /** Object key for a new upload. Default `<prefix>/<yyyy-mm-dd>/<random>-<safe filename>`. */\n keyFor?(input: { prefix: string; filename: string; contentType: string }): string;\n}\n\nconst GiB = 1024 ** 3;\nconst MAX_SIGN_BATCH = 100;\n\nclass HttpError extends Error {\n constructor(readonly status: number, readonly code: string, message: string) { super(message); }\n}\n\nconst json = (status: number, body: unknown) =>\n new Response(JSON.stringify(body), { status, headers: { \"content-type\": \"application/json\", \"cache-control\": \"no-store\" } });\n\nexport const safeFilename = (name: string) =>\n (name.normalize(\"NFKC\").replace(/[\\\\/\\u0000-\\u001f\\u007f]/g, \"_\").replace(/^\\.+/, \"\").replace(/[^\\p{L}\\p{N}._ -]/gu, \"_\").trim().slice(0, 120)) || \"file\";\n\n/** A prefix must be plain path segments: no \"..\", no empty segments, no encoding tricks. */\nfunction checkPrefix(prefix: unknown): string {\n if (typeof prefix !== \"string\") throw new HttpError(500, \"bad_prefix\", \"authorize() returned no prefix\");\n const p = prefix.replace(/^\\/+|\\/+$/g, \"\");\n if (!p || p.split(\"/\").some((s) => !s || s === \".\" || s === \"..\") || /[%\\\\\\u0000-\\u001f]/.test(p))\n throw new HttpError(500, \"bad_prefix\", \"authorize() returned an unsafe prefix\");\n return p;\n}\n\n/** The key must sit under `<prefix>/` and be free of traversal, encoding and control characters. */\nfunction checkKey(key: unknown, prefix: string): string {\n if (typeof key !== \"string\" || key.length > 1024) throw new HttpError(400, \"bad_key\", \"key is required\");\n const segs = key.split(\"/\");\n if (/[%\\\\\\u0000-\\u001f\\u007f]/.test(key) || segs.some((s) => !s || s === \".\" || s === \"..\") || !key.startsWith(prefix + \"/\"))\n throw new HttpError(403, \"forbidden_key\", \"that object is not yours\");\n return key;\n}\n\nconst checkUploadId = (id: unknown): string => {\n if (typeof id !== \"string\" || !id || id.length > 1024 || /[\\u0000-\\u001f]/.test(id))\n throw new HttpError(400, \"bad_upload_id\", \"uploadId is required\");\n return id;\n};\n\nfunction typeAllowed(type: string, allowed?: string[]): boolean {\n if (!allowed?.length) return true;\n const t = type.toLowerCase();\n return allowed.some((a) => { const x = a.toLowerCase(); return x.endsWith(\"/*\") ? t.startsWith(x.slice(0, -1)) : t === x; });\n}\n\nconst randomId = () => {\n const b = new Uint8Array(9);\n crypto.getRandomValues(b);\n return Array.from(b, (x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n};\n\nconst defaultKeyFor = ({ prefix, filename }: { prefix: string; filename: string }) =>\n `${prefix}/${new Date().toISOString().slice(0, 10)}/${randomId()}-${safeFilename(filename)}`;\n\nexport function createS3Handler(signer: S3Signer | null, opts: S3HandlerOptions): (req: Request) => Promise<Response> {\n const maxBytes = opts.maxBytes ?? 20 * GiB;\n const expiresIn = Math.min(opts.expiresIn ?? MAX_EXPIRES_SECONDS, MAX_EXPIRES_SECONDS);\n\n return async (req) => {\n try {\n if (req.method !== \"POST\") throw new HttpError(405, \"method_not_allowed\", \"POST only\");\n if (!signer) throw new HttpError(503, \"not_configured\", \"file storage is not configured on this server\");\n const who = await opts.authorize(req);\n if (!who) throw new HttpError(401, \"unauthorized\", \"sign in first\");\n const prefix = checkPrefix(who.prefix);\n\n // JSON only: a cross-site <form> can POST text/plain with the user's cookies,\n // but cannot set application/json without a CORS preflight this route never grants.\n if (!(req.headers.get(\"content-type\") ?? \"\").toLowerCase().startsWith(\"application/json\"))\n throw new HttpError(415, \"json_required\", \"content-type must be application/json\");\n let body: Record<string, unknown>;\n try { body = (await req.json()) as Record<string, unknown>; } catch { throw new HttpError(400, \"bad_json\", \"body must be JSON\"); }\n if (!body || typeof body !== \"object\") throw new HttpError(400, \"bad_json\", \"body must be a JSON object\");\n\n switch (body.action) {\n case \"start\": {\n const filename = typeof body.filename === \"string\" ? body.filename : \"\";\n const size = body.size;\n const contentType = typeof body.type === \"string\" && body.type ? body.type : \"application/octet-stream\";\n if (!filename) throw new HttpError(400, \"bad_filename\", \"filename is required\");\n if (typeof size !== \"number\" || !Number.isFinite(size) || size < 1) throw new HttpError(400, \"bad_size\", \"size must be a positive number\");\n if (size > maxBytes) throw new HttpError(413, \"too_large\", `file is larger than ${maxBytes} bytes`);\n if (!typeAllowed(contentType, opts.allowedTypes)) throw new HttpError(415, \"type_not_allowed\", `${contentType} is not accepted here`);\n const key = checkKey((opts.keyFor ?? defaultKeyFor)({ prefix, filename, contentType }), prefix);\n const partSize = choosePartSize(size, opts.partSize);\n const { uploadId } = await signer.start({ key, contentType });\n return json(200, { key, uploadId, partSize, partCount: Math.ceil(size / partSize) });\n }\n case \"sign\": {\n const key = checkKey(body.key, prefix);\n const uploadId = checkUploadId(body.uploadId);\n const nums = body.partNumbers;\n if (!Array.isArray(nums) || nums.length < 1 || nums.length > MAX_SIGN_BATCH ||\n !nums.every((n) => Number.isInteger(n) && n >= 1 && n <= MAX_PARTS))\n throw new HttpError(400, \"bad_parts\", `partNumbers: 1..${MAX_SIGN_BATCH} integers in 1..${MAX_PARTS}`);\n const urls = await Promise.all((nums as number[]).map(async (partNumber) => ({\n partNumber, url: await signer.signPart({ key, uploadId, partNumber, expiresIn }),\n })));\n return json(200, { urls, expiresIn });\n }\n case \"list\": {\n const key = checkKey(body.key, prefix);\n const parts = await signer.listParts({ key, uploadId: checkUploadId(body.uploadId) });\n return json(200, { parts });\n }\n case \"complete\": {\n const key = checkKey(body.key, prefix);\n const uploadId = checkUploadId(body.uploadId);\n const sent = body.parts;\n if (!Array.isArray(sent) || sent.length < 1 || sent.length > MAX_PARTS)\n throw new HttpError(400, \"bad_parts\", \"parts are required\");\n // The bucket is the truth: sizes come from listParts, never from the browser.\n const stored = await signer.listParts({ key, uploadId });\n const total = stored.reduce((s, p) => s + (p.size ?? 0), 0);\n if (total > maxBytes) {\n await signer.abort({ key, uploadId });\n throw new HttpError(413, \"too_large\", `upload exceeded ${maxBytes} bytes and was discarded`);\n }\n const parts: UploadedPart[] = (sent as Array<{ partNumber?: unknown; etag?: unknown }>).map((p) => {\n if (!Number.isInteger(p?.partNumber) || typeof p?.etag !== \"string\") throw new HttpError(400, \"bad_parts\", \"each part needs partNumber and etag\");\n return { partNumber: p.partNumber as number, etag: p.etag };\n });\n await signer.complete({ key, uploadId, parts });\n return json(200, { key, size: total });\n }\n case \"abort\": {\n const key = checkKey(body.key, prefix);\n await signer.abort({ key, uploadId: checkUploadId(body.uploadId) });\n return json(200, { aborted: true });\n }\n case \"download\": {\n const key = checkKey(body.key, prefix);\n const filename = typeof body.filename === \"string\" && body.filename ? safeFilename(body.filename) : undefined;\n const url = await signer.signDownload({ key, expiresIn, filename });\n return json(200, { url, expiresIn });\n }\n default:\n throw new HttpError(400, \"unknown_action\", \"action must be start, sign, list, complete, abort or download\");\n }\n } catch (e) {\n if (e instanceof HttpError) return json(e.status, { error: { code: e.code, message: e.message } });\n if (e instanceof S3Error) {\n const status = e.s3Code === \"NoSuchUpload\" ? 404 : e.s3Code === \"NoSuchKey\" ? 404 : 502;\n return json(status, { error: { code: e.s3Code ?? \"storage_error\", message: status === 404 ? \"upload or object not found\" : \"storage refused the request\" } });\n }\n // Never echo an unknown error: it could carry configuration.\n return json(500, { error: { code: \"internal\", message: \"upload service error\" } });\n }\n };\n}\n"]}
@@ -0,0 +1,109 @@
1
+ /** Where a bucket lives and how to sign for it. R2 in the EU is the default. */
2
+ interface S3Config {
3
+ bucket: string;
4
+ accessKeyId: string;
5
+ secretAccessKey: string;
6
+ /** Cloudflare R2 account id → https://<id>.eu.r2.cloudflarestorage.com */
7
+ accountId?: string;
8
+ /** R2 jurisdiction. Defaults to "eu"; a non-EU R2 bucket must say "default" explicitly. */
9
+ jurisdiction?: "eu" | "default";
10
+ /** Any other S3-compatible endpoint origin (Tigris, AWS, MinIO), e.g. "https://fly.storage.tigris.dev". */
11
+ endpoint?: string;
12
+ /** Signing region. "auto" for R2 and Tigris (default); the bucket's region for AWS. */
13
+ region?: string;
14
+ /** Injected for tests; defaults to the global fetch. */
15
+ fetch?: typeof fetch;
16
+ }
17
+ interface UploadedPart {
18
+ partNumber: number;
19
+ etag: string;
20
+ size?: number;
21
+ }
22
+ declare const MIN_PART_SIZE: number;
23
+ declare const DEFAULT_PART_SIZE: number;
24
+ declare const MAX_PARTS = 10000;
25
+ /** The longest a signed URL may live. A leaked link is useless after this. */
26
+ declare const MAX_EXPIRES_SECONDS = 900;
27
+ /** Part size for `size` bytes: at least `preferred` (and 8 MiB), and few enough parts for S3's 10,000 cap. */
28
+ declare function choosePartSize(size: number, preferred?: number): number;
29
+ interface S3Signer {
30
+ /** Begin a multipart upload. */
31
+ start(input: {
32
+ key: string;
33
+ contentType?: string;
34
+ }): Promise<{
35
+ uploadId: string;
36
+ }>;
37
+ /** A presigned PUT for one part of one upload. */
38
+ signPart(input: {
39
+ key: string;
40
+ uploadId: string;
41
+ partNumber: number;
42
+ expiresIn?: number;
43
+ }): Promise<string>;
44
+ /** Parts the bucket already holds for this upload (the truth a resume starts from). */
45
+ listParts(input: {
46
+ key: string;
47
+ uploadId: string;
48
+ }): Promise<UploadedPart[]>;
49
+ complete(input: {
50
+ key: string;
51
+ uploadId: string;
52
+ parts: UploadedPart[];
53
+ }): Promise<void>;
54
+ abort(input: {
55
+ key: string;
56
+ uploadId: string;
57
+ }): Promise<void>;
58
+ /** A presigned GET; `filename` makes the browser save it under that name. */
59
+ signDownload(input: {
60
+ key: string;
61
+ expiresIn?: number;
62
+ filename?: string;
63
+ }): Promise<string>;
64
+ /** Size of a stored object, or null when it does not exist. */
65
+ head(key: string): Promise<number | null>;
66
+ }
67
+ declare class S3Error extends Error {
68
+ readonly status: number;
69
+ readonly s3Code?: string | undefined;
70
+ constructor(message: string, status: number, s3Code?: string | undefined);
71
+ }
72
+ declare function createS3Signer(cfg: S3Config): S3Signer;
73
+ type Env = Record<string, string | undefined>;
74
+ /**
75
+ * Builds a signer from the environment, or returns null when NOTHING is configured
76
+ * (ship dark: the handler then answers 503). A HALF-configured environment throws
77
+ * at boot instead — that is a mistake, not a choice.
78
+ *
79
+ * R2: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET [, R2_JURISDICTION]
80
+ * S3: S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET [, S3_REGION]
81
+ */
82
+ declare function createS3SignerFromEnv(env?: Env): S3Signer | null;
83
+
84
+ interface Authorized {
85
+ /** Every object this user may touch lives under `<prefix>/`. E.g. "users/<bid-sub>". */
86
+ prefix: string;
87
+ }
88
+ interface S3HandlerOptions {
89
+ /** The site's own check (BID session + allowlist). Return null to refuse. */
90
+ authorize(req: Request): Promise<Authorized | null> | Authorized | null;
91
+ /** Largest object accepted, in bytes. Default 20 GiB. Enforced at start (declared) AND at complete (actual). */
92
+ maxBytes?: number;
93
+ /** Allowed content types, exact ("video/mp4") or wildcard ("video/*"). Default: any. Declared type, not sniffed. */
94
+ allowedTypes?: string[];
95
+ /** Preferred part size in bytes (min 8 MiB). Default 16 MiB. */
96
+ partSize?: number;
97
+ /** Signed-URL lifetime in seconds, max 900. */
98
+ expiresIn?: number;
99
+ /** Object key for a new upload. Default `<prefix>/<yyyy-mm-dd>/<random>-<safe filename>`. */
100
+ keyFor?(input: {
101
+ prefix: string;
102
+ filename: string;
103
+ contentType: string;
104
+ }): string;
105
+ }
106
+ declare const safeFilename: (name: string) => string;
107
+ declare function createS3Handler(signer: S3Signer | null, opts: S3HandlerOptions): (req: Request) => Promise<Response>;
108
+
109
+ export { type Authorized, DEFAULT_PART_SIZE, MAX_EXPIRES_SECONDS, MAX_PARTS, MIN_PART_SIZE, type S3Config, S3Error, type S3HandlerOptions, type S3Signer, type UploadedPart, choosePartSize, createS3Handler, createS3Signer, createS3SignerFromEnv, safeFilename };
@@ -0,0 +1,109 @@
1
+ /** Where a bucket lives and how to sign for it. R2 in the EU is the default. */
2
+ interface S3Config {
3
+ bucket: string;
4
+ accessKeyId: string;
5
+ secretAccessKey: string;
6
+ /** Cloudflare R2 account id → https://<id>.eu.r2.cloudflarestorage.com */
7
+ accountId?: string;
8
+ /** R2 jurisdiction. Defaults to "eu"; a non-EU R2 bucket must say "default" explicitly. */
9
+ jurisdiction?: "eu" | "default";
10
+ /** Any other S3-compatible endpoint origin (Tigris, AWS, MinIO), e.g. "https://fly.storage.tigris.dev". */
11
+ endpoint?: string;
12
+ /** Signing region. "auto" for R2 and Tigris (default); the bucket's region for AWS. */
13
+ region?: string;
14
+ /** Injected for tests; defaults to the global fetch. */
15
+ fetch?: typeof fetch;
16
+ }
17
+ interface UploadedPart {
18
+ partNumber: number;
19
+ etag: string;
20
+ size?: number;
21
+ }
22
+ declare const MIN_PART_SIZE: number;
23
+ declare const DEFAULT_PART_SIZE: number;
24
+ declare const MAX_PARTS = 10000;
25
+ /** The longest a signed URL may live. A leaked link is useless after this. */
26
+ declare const MAX_EXPIRES_SECONDS = 900;
27
+ /** Part size for `size` bytes: at least `preferred` (and 8 MiB), and few enough parts for S3's 10,000 cap. */
28
+ declare function choosePartSize(size: number, preferred?: number): number;
29
+ interface S3Signer {
30
+ /** Begin a multipart upload. */
31
+ start(input: {
32
+ key: string;
33
+ contentType?: string;
34
+ }): Promise<{
35
+ uploadId: string;
36
+ }>;
37
+ /** A presigned PUT for one part of one upload. */
38
+ signPart(input: {
39
+ key: string;
40
+ uploadId: string;
41
+ partNumber: number;
42
+ expiresIn?: number;
43
+ }): Promise<string>;
44
+ /** Parts the bucket already holds for this upload (the truth a resume starts from). */
45
+ listParts(input: {
46
+ key: string;
47
+ uploadId: string;
48
+ }): Promise<UploadedPart[]>;
49
+ complete(input: {
50
+ key: string;
51
+ uploadId: string;
52
+ parts: UploadedPart[];
53
+ }): Promise<void>;
54
+ abort(input: {
55
+ key: string;
56
+ uploadId: string;
57
+ }): Promise<void>;
58
+ /** A presigned GET; `filename` makes the browser save it under that name. */
59
+ signDownload(input: {
60
+ key: string;
61
+ expiresIn?: number;
62
+ filename?: string;
63
+ }): Promise<string>;
64
+ /** Size of a stored object, or null when it does not exist. */
65
+ head(key: string): Promise<number | null>;
66
+ }
67
+ declare class S3Error extends Error {
68
+ readonly status: number;
69
+ readonly s3Code?: string | undefined;
70
+ constructor(message: string, status: number, s3Code?: string | undefined);
71
+ }
72
+ declare function createS3Signer(cfg: S3Config): S3Signer;
73
+ type Env = Record<string, string | undefined>;
74
+ /**
75
+ * Builds a signer from the environment, or returns null when NOTHING is configured
76
+ * (ship dark: the handler then answers 503). A HALF-configured environment throws
77
+ * at boot instead — that is a mistake, not a choice.
78
+ *
79
+ * R2: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET [, R2_JURISDICTION]
80
+ * S3: S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET [, S3_REGION]
81
+ */
82
+ declare function createS3SignerFromEnv(env?: Env): S3Signer | null;
83
+
84
+ interface Authorized {
85
+ /** Every object this user may touch lives under `<prefix>/`. E.g. "users/<bid-sub>". */
86
+ prefix: string;
87
+ }
88
+ interface S3HandlerOptions {
89
+ /** The site's own check (BID session + allowlist). Return null to refuse. */
90
+ authorize(req: Request): Promise<Authorized | null> | Authorized | null;
91
+ /** Largest object accepted, in bytes. Default 20 GiB. Enforced at start (declared) AND at complete (actual). */
92
+ maxBytes?: number;
93
+ /** Allowed content types, exact ("video/mp4") or wildcard ("video/*"). Default: any. Declared type, not sniffed. */
94
+ allowedTypes?: string[];
95
+ /** Preferred part size in bytes (min 8 MiB). Default 16 MiB. */
96
+ partSize?: number;
97
+ /** Signed-URL lifetime in seconds, max 900. */
98
+ expiresIn?: number;
99
+ /** Object key for a new upload. Default `<prefix>/<yyyy-mm-dd>/<random>-<safe filename>`. */
100
+ keyFor?(input: {
101
+ prefix: string;
102
+ filename: string;
103
+ contentType: string;
104
+ }): string;
105
+ }
106
+ declare const safeFilename: (name: string) => string;
107
+ declare function createS3Handler(signer: S3Signer | null, opts: S3HandlerOptions): (req: Request) => Promise<Response>;
108
+
109
+ export { type Authorized, DEFAULT_PART_SIZE, MAX_EXPIRES_SECONDS, MAX_PARTS, MIN_PART_SIZE, type S3Config, S3Error, type S3HandlerOptions, type S3Signer, type UploadedPart, choosePartSize, createS3Handler, createS3Signer, createS3SignerFromEnv, safeFilename };