@velajs/storage 0.2.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/base64.d.ts +8 -0
- package/dist/base64.js +25 -0
- package/dist/client/index.d.ts +50 -0
- package/dist/client/index.js +243 -0
- package/dist/decorators/inject-storage.decorator.d.ts +17 -0
- package/dist/decorators/inject-storage.decorator.js +21 -0
- package/dist/drivers/memory/index.d.ts +16 -0
- package/dist/drivers/memory/index.js +202 -0
- package/dist/drivers/r2/index.d.ts +11 -0
- package/dist/drivers/r2/index.js +166 -0
- package/dist/drivers/r2/r2.types.d.ts +67 -0
- package/dist/drivers/r2/r2.types.js +5 -0
- package/dist/drivers/r2-http/index.d.ts +33 -0
- package/dist/drivers/r2-http/index.js +52 -0
- package/dist/drivers/s3/index.d.ts +5 -0
- package/dist/drivers/s3/index.js +3 -0
- package/dist/drivers/s3/s3-client.d.ts +33 -0
- package/dist/drivers/s3/s3-client.js +154 -0
- package/dist/drivers/s3/s3.driver.d.ts +4 -0
- package/dist/drivers/s3/s3.driver.js +404 -0
- package/dist/drivers/s3/s3.types.d.ts +28 -0
- package/dist/drivers/s3/s3.types.js +1 -0
- package/dist/drivers/s3/xml.d.ts +6 -0
- package/dist/drivers/s3/xml.js +52 -0
- package/dist/http/authorizer.types.d.ts +59 -0
- package/dist/http/authorizer.types.js +1 -0
- package/dist/http/http-helpers.d.ts +22 -0
- package/dist/http/http-helpers.js +80 -0
- package/dist/http/protocol.types.d.ts +76 -0
- package/dist/http/protocol.types.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +16 -0
- package/dist/internal/body.d.ts +18 -0
- package/dist/internal/body.js +90 -0
- package/dist/internal/retry.d.ts +41 -0
- package/dist/internal/retry.js +127 -0
- package/dist/internal/stored-file.d.ts +27 -0
- package/dist/internal/stored-file.js +114 -0
- package/dist/middleware/cache.d.ts +50 -0
- package/dist/middleware/cache.js +101 -0
- package/dist/middleware/compose.d.ts +12 -0
- package/dist/middleware/compose.js +11 -0
- package/dist/middleware/compression.d.ts +16 -0
- package/dist/middleware/compression.js +101 -0
- package/dist/middleware/encryption.d.ts +14 -0
- package/dist/middleware/encryption.js +134 -0
- package/dist/middleware/failover.d.ts +18 -0
- package/dist/middleware/failover.js +52 -0
- package/dist/middleware/index.d.ts +15 -0
- package/dist/middleware/index.js +8 -0
- package/dist/middleware/retry.d.ts +14 -0
- package/dist/middleware/retry.js +47 -0
- package/dist/middleware/versioning.d.ts +36 -0
- package/dist/middleware/versioning.js +89 -0
- package/dist/middleware/wrap.d.ts +11 -0
- package/dist/middleware/wrap.js +46 -0
- package/dist/object-key.d.ts +17 -0
- package/dist/object-key.js +42 -0
- package/dist/storage.controller.d.ts +21 -0
- package/dist/storage.controller.js +380 -0
- package/dist/storage.error.d.ts +28 -0
- package/dist/storage.error.js +46 -0
- package/dist/storage.facade.d.ts +47 -0
- package/dist/storage.facade.js +443 -0
- package/dist/storage.module.d.ts +46 -0
- package/dist/storage.module.js +113 -0
- package/dist/storage.service.d.ts +42 -0
- package/dist/storage.service.js +86 -0
- package/dist/storage.tokens.d.ts +13 -0
- package/dist/storage.tokens.js +26 -0
- package/dist/storage.types.d.ts +245 -0
- package/dist/storage.types.js +1 -0
- package/dist/storagesdk/index.d.ts +85 -0
- package/dist/storagesdk/index.js +136 -0
- package/package.json +116 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { byteLengthOf } from "../../internal/body.js";
|
|
2
|
+
import { createStoredFile } from "../../internal/stored-file.js";
|
|
3
|
+
import { StorageError } from "../../storage.error.js";
|
|
4
|
+
import { S3Client } from "./s3-client.js";
|
|
5
|
+
import { escapeXml, tagBlocks, tagText } from "./xml.js";
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const DEFAULT_EXPIRES = 3600;
|
|
8
|
+
const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
|
|
9
|
+
function stripQuotes(s) {
|
|
10
|
+
return s ? s.replace(/^"|"$/g, '') : undefined;
|
|
11
|
+
}
|
|
12
|
+
function parseDate(s) {
|
|
13
|
+
if (!s) return undefined;
|
|
14
|
+
const t = Date.parse(s);
|
|
15
|
+
return Number.isFinite(t) ? t : undefined;
|
|
16
|
+
}
|
|
17
|
+
function formatRange(range) {
|
|
18
|
+
return `bytes=${range.start}-${range.end ?? ''}`;
|
|
19
|
+
}
|
|
20
|
+
function metaFromHeaders(headers) {
|
|
21
|
+
const meta = {};
|
|
22
|
+
headers.forEach((value, key)=>{
|
|
23
|
+
if (key.startsWith('x-amz-meta-')) meta[key.slice('x-amz-meta-'.length)] = value;
|
|
24
|
+
});
|
|
25
|
+
return Object.keys(meta).length ? meta : undefined;
|
|
26
|
+
}
|
|
27
|
+
function joinPublic(base, key) {
|
|
28
|
+
const enc = key.split('/').map(encodeURIComponent).join('/');
|
|
29
|
+
return `${base.replace(/\/$/, '')}/${enc}`;
|
|
30
|
+
}
|
|
31
|
+
/** Map an S3 `<Code>` (and/or HTTP status) to our error taxonomy. */ function mapS3Code(code, status) {
|
|
32
|
+
switch(code){
|
|
33
|
+
case 'NoSuchKey':
|
|
34
|
+
case 'NoSuchUpload':
|
|
35
|
+
case 'NoSuchBucket':
|
|
36
|
+
return 'NotFound';
|
|
37
|
+
case 'AccessDenied':
|
|
38
|
+
case 'InvalidAccessKeyId':
|
|
39
|
+
case 'SignatureDoesNotMatch':
|
|
40
|
+
case 'AllAccessDisabled':
|
|
41
|
+
return 'AccessDenied';
|
|
42
|
+
case 'SlowDown':
|
|
43
|
+
case 'RequestTimeout':
|
|
44
|
+
return 'RateLimited';
|
|
45
|
+
default:
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (status === 404) return 'NotFound';
|
|
49
|
+
if (status === 403 || status === 401) return 'AccessDenied';
|
|
50
|
+
if (status === 409) return 'Conflict';
|
|
51
|
+
if (status === 429) return 'RateLimited';
|
|
52
|
+
if (status >= 500) return 'Provider';
|
|
53
|
+
if (status >= 400) return 'InvalidRequest';
|
|
54
|
+
return 'Provider'; // e.g. a 200 response carrying an <Error> body
|
|
55
|
+
}
|
|
56
|
+
function toError(status, code, message, key) {
|
|
57
|
+
const mapped = mapS3Code(code, status);
|
|
58
|
+
const retryable = mapped === 'Provider' || mapped === 'RateLimited' || mapped === 'Timeout' || mapped === 'Network';
|
|
59
|
+
return new StorageError(mapped, message ?? code ?? `S3 error${key ? ` (${key})` : ''}`, {
|
|
60
|
+
status: status >= 400 ? status : undefined,
|
|
61
|
+
retryable
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function s3Error(res, key) {
|
|
65
|
+
let code;
|
|
66
|
+
let message;
|
|
67
|
+
try {
|
|
68
|
+
const body = await res.text();
|
|
69
|
+
code = tagText(body, 'Code');
|
|
70
|
+
message = tagText(body, 'Message');
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore body read errors */ }
|
|
73
|
+
return toError(res.status, code, message, key);
|
|
74
|
+
}
|
|
75
|
+
function parseErrorBody(status, body, key) {
|
|
76
|
+
return toError(status, tagText(body, 'Code'), tagText(body, 'Message'), key);
|
|
77
|
+
}
|
|
78
|
+
/** Add `duplex: 'half'` when streaming a request body (required by fetch). */ function withDuplex(init, body) {
|
|
79
|
+
if (body instanceof ReadableStream) {
|
|
80
|
+
return {
|
|
81
|
+
...init,
|
|
82
|
+
duplex: 'half'
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return init;
|
|
86
|
+
}
|
|
87
|
+
/** S3 / S3-compatible driver signed with aws4fetch (edge-safe). */ export function s3Driver(options) {
|
|
88
|
+
const client = new S3Client(options);
|
|
89
|
+
const expires = options.defaultUrlExpiresIn ?? DEFAULT_EXPIRES;
|
|
90
|
+
function uploadHeaders(opts, unsignedPayload) {
|
|
91
|
+
const h = new Headers();
|
|
92
|
+
if (unsignedPayload) h.set('x-amz-content-sha256', 'UNSIGNED-PAYLOAD');
|
|
93
|
+
if (opts?.contentType) h.set('content-type', opts.contentType);
|
|
94
|
+
if (opts?.cacheControl) h.set('cache-control', opts.cacheControl);
|
|
95
|
+
if (opts?.metadata) for (const [k, v] of Object.entries(opts.metadata))h.set(`x-amz-meta-${k}`, v);
|
|
96
|
+
return h;
|
|
97
|
+
}
|
|
98
|
+
function multipartHandle(key, uploadId, createOpts) {
|
|
99
|
+
return {
|
|
100
|
+
key,
|
|
101
|
+
uploadId,
|
|
102
|
+
async uploadPart (partNumber, part, o) {
|
|
103
|
+
const url = client.objectUrl(key, {
|
|
104
|
+
partNumber: String(partNumber),
|
|
105
|
+
uploadId
|
|
106
|
+
});
|
|
107
|
+
const res = await client.send(url, withDuplex({
|
|
108
|
+
method: 'PUT',
|
|
109
|
+
headers: new Headers({
|
|
110
|
+
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'
|
|
111
|
+
}),
|
|
112
|
+
body: part,
|
|
113
|
+
signal: o?.signal
|
|
114
|
+
}, part));
|
|
115
|
+
if (!res.ok) throw await s3Error(res, key);
|
|
116
|
+
const etag = res.headers.get('etag');
|
|
117
|
+
if (!etag) throw new StorageError('Provider', `missing ETag for part ${partNumber}`);
|
|
118
|
+
return {
|
|
119
|
+
partNumber,
|
|
120
|
+
etag
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
async complete (parts, o) {
|
|
124
|
+
const xml = '<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>' + [
|
|
125
|
+
...parts
|
|
126
|
+
].sort((a, b)=>a.partNumber - b.partNumber).map((p)=>`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`).join('') + '</CompleteMultipartUpload>';
|
|
127
|
+
const res = await client.send(client.objectUrl(key, {
|
|
128
|
+
uploadId
|
|
129
|
+
}), {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: new Headers({
|
|
132
|
+
'content-type': 'application/xml'
|
|
133
|
+
}),
|
|
134
|
+
body: encoder.encode(xml),
|
|
135
|
+
signal: o?.signal
|
|
136
|
+
});
|
|
137
|
+
const body = await res.text();
|
|
138
|
+
if (!res.ok || body.includes('<Error')) throw parseErrorBody(res.status, body, key);
|
|
139
|
+
return {
|
|
140
|
+
key,
|
|
141
|
+
size: 0,
|
|
142
|
+
contentType: createOpts?.contentType ?? 'application/octet-stream',
|
|
143
|
+
etag: stripQuotes(tagText(body, 'ETag'))
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
async abort (o) {
|
|
147
|
+
const res = await client.send(client.objectUrl(key, {
|
|
148
|
+
uploadId
|
|
149
|
+
}), {
|
|
150
|
+
method: 'DELETE',
|
|
151
|
+
signal: o?.signal
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok && res.status !== 404) throw await s3Error(res, key);
|
|
154
|
+
},
|
|
155
|
+
async listParts (o) {
|
|
156
|
+
const res = await client.send(client.objectUrl(key, {
|
|
157
|
+
uploadId
|
|
158
|
+
}), {
|
|
159
|
+
method: 'GET',
|
|
160
|
+
signal: o?.signal
|
|
161
|
+
});
|
|
162
|
+
const xml = await res.text();
|
|
163
|
+
if (!res.ok) throw parseErrorBody(res.status, xml, key);
|
|
164
|
+
return tagBlocks(xml, 'Part').map((b)=>({
|
|
165
|
+
partNumber: Number(tagText(b, 'PartNumber') ?? 0),
|
|
166
|
+
etag: stripQuotes(tagText(b, 'ETag')) ?? '',
|
|
167
|
+
size: Number(tagText(b, 'Size') ?? 0)
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const signedMultipart = {
|
|
173
|
+
async create (key, o) {
|
|
174
|
+
const res = await client.send(client.objectUrl(key, {
|
|
175
|
+
uploads: ''
|
|
176
|
+
}), {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: uploadHeaders({
|
|
179
|
+
contentType: o.contentType,
|
|
180
|
+
metadata: o.metadata
|
|
181
|
+
}, false)
|
|
182
|
+
});
|
|
183
|
+
const xml = await res.text();
|
|
184
|
+
if (!res.ok) throw parseErrorBody(res.status, xml, key);
|
|
185
|
+
const uploadId = tagText(xml, 'UploadId');
|
|
186
|
+
if (!uploadId) throw new StorageError('Parse', 'missing UploadId');
|
|
187
|
+
return {
|
|
188
|
+
uploadId,
|
|
189
|
+
partSize: o.partSize ?? DEFAULT_PART_SIZE
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
async signPart (key, uploadId, partNumber, o) {
|
|
193
|
+
const url = await client.presign(key, 'PUT', o?.expiresIn ?? expires, {
|
|
194
|
+
partNumber: String(partNumber),
|
|
195
|
+
uploadId
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
url
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
complete (key, uploadId, parts) {
|
|
202
|
+
return multipartHandle(key, uploadId).complete(parts);
|
|
203
|
+
},
|
|
204
|
+
abort (key, uploadId) {
|
|
205
|
+
return multipartHandle(key, uploadId).abort();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
name: options.name ?? 's3',
|
|
210
|
+
raw: client,
|
|
211
|
+
supportsRange: true,
|
|
212
|
+
supportsDelimiter: true,
|
|
213
|
+
supportsMetadata: true,
|
|
214
|
+
supportsCacheControl: true,
|
|
215
|
+
supportsServerSideCopy: true,
|
|
216
|
+
reportsUploadProgress: false,
|
|
217
|
+
signedUrl: {
|
|
218
|
+
supported: true,
|
|
219
|
+
upload: true
|
|
220
|
+
},
|
|
221
|
+
async upload (key, body, opts) {
|
|
222
|
+
const size = byteLengthOf(body) ?? 0;
|
|
223
|
+
const res = await client.send(client.objectUrl(key), withDuplex({
|
|
224
|
+
method: 'PUT',
|
|
225
|
+
headers: uploadHeaders(opts, true),
|
|
226
|
+
body: body,
|
|
227
|
+
signal: opts?.signal
|
|
228
|
+
}, body));
|
|
229
|
+
if (!res.ok) throw await s3Error(res, key);
|
|
230
|
+
return {
|
|
231
|
+
key,
|
|
232
|
+
size,
|
|
233
|
+
contentType: opts?.contentType ?? 'application/octet-stream',
|
|
234
|
+
etag: stripQuotes(res.headers.get('etag')),
|
|
235
|
+
lastModified: parseDate(res.headers.get('last-modified'))
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
async download (key, opts) {
|
|
239
|
+
const headers = new Headers();
|
|
240
|
+
if (opts?.range) headers.set('Range', formatRange(opts.range));
|
|
241
|
+
const res = await client.send(client.objectUrl(key), {
|
|
242
|
+
method: 'GET',
|
|
243
|
+
headers,
|
|
244
|
+
signal: opts?.signal
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok || !res.body) {
|
|
247
|
+
if (res.status === 404) throw new StorageError('NotFound', `not found: ${key}`, {
|
|
248
|
+
status: 404
|
|
249
|
+
});
|
|
250
|
+
throw await s3Error(res, key);
|
|
251
|
+
}
|
|
252
|
+
return createStoredFile({
|
|
253
|
+
key,
|
|
254
|
+
size: Number(res.headers.get('content-length') ?? 0),
|
|
255
|
+
type: res.headers.get('content-type') ?? 'application/octet-stream',
|
|
256
|
+
etag: stripQuotes(res.headers.get('etag')),
|
|
257
|
+
lastModified: parseDate(res.headers.get('last-modified')),
|
|
258
|
+
metadata: metaFromHeaders(res.headers)
|
|
259
|
+
}, {
|
|
260
|
+
kind: 'stream',
|
|
261
|
+
stream: res.body
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
async head (key, opts) {
|
|
265
|
+
const res = await client.send(client.objectUrl(key), {
|
|
266
|
+
method: 'HEAD',
|
|
267
|
+
signal: opts?.signal
|
|
268
|
+
});
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
if (res.status === 404) throw new StorageError('NotFound', `not found: ${key}`, {
|
|
271
|
+
status: 404
|
|
272
|
+
});
|
|
273
|
+
throw await s3Error(res, key);
|
|
274
|
+
}
|
|
275
|
+
return createStoredFile({
|
|
276
|
+
key,
|
|
277
|
+
size: Number(res.headers.get('content-length') ?? 0),
|
|
278
|
+
type: res.headers.get('content-type') ?? 'application/octet-stream',
|
|
279
|
+
etag: stripQuotes(res.headers.get('etag')),
|
|
280
|
+
lastModified: parseDate(res.headers.get('last-modified')),
|
|
281
|
+
metadata: metaFromHeaders(res.headers)
|
|
282
|
+
}, {
|
|
283
|
+
kind: 'lazy',
|
|
284
|
+
fetch: ()=>client.send(client.objectUrl(key), {
|
|
285
|
+
method: 'GET'
|
|
286
|
+
})
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
async exists (key, opts) {
|
|
290
|
+
const res = await client.send(client.objectUrl(key), {
|
|
291
|
+
method: 'HEAD',
|
|
292
|
+
signal: opts?.signal
|
|
293
|
+
});
|
|
294
|
+
if (res.status === 404) return false;
|
|
295
|
+
if (!res.ok) throw await s3Error(res, key);
|
|
296
|
+
return true;
|
|
297
|
+
},
|
|
298
|
+
async delete (key, opts) {
|
|
299
|
+
const res = await client.send(client.objectUrl(key), {
|
|
300
|
+
method: 'DELETE',
|
|
301
|
+
signal: opts?.signal
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok && res.status !== 404) throw await s3Error(res, key);
|
|
304
|
+
},
|
|
305
|
+
async copy (from, to, opts) {
|
|
306
|
+
const source = `/${client.bucket}/${from.split('/').map(encodeURIComponent).join('/')}`;
|
|
307
|
+
const res = await client.send(client.objectUrl(to), {
|
|
308
|
+
method: 'PUT',
|
|
309
|
+
headers: new Headers({
|
|
310
|
+
'x-amz-copy-source': source
|
|
311
|
+
}),
|
|
312
|
+
signal: opts?.signal
|
|
313
|
+
});
|
|
314
|
+
const body = await res.text();
|
|
315
|
+
if (!res.ok || body.includes('<Error')) throw parseErrorBody(res.status, body, to);
|
|
316
|
+
},
|
|
317
|
+
async list (opts) {
|
|
318
|
+
const query = {
|
|
319
|
+
'list-type': '2'
|
|
320
|
+
};
|
|
321
|
+
if (opts?.prefix) query.prefix = opts.prefix;
|
|
322
|
+
if (opts?.delimiter) query.delimiter = opts.delimiter;
|
|
323
|
+
if (opts?.cursor) query['continuation-token'] = opts.cursor;
|
|
324
|
+
if (opts?.limit) query['max-keys'] = String(opts.limit);
|
|
325
|
+
const res = await client.send(client.objectUrl('', query), {
|
|
326
|
+
method: 'GET',
|
|
327
|
+
signal: opts?.signal
|
|
328
|
+
});
|
|
329
|
+
const xml = await res.text();
|
|
330
|
+
if (!res.ok) throw parseErrorBody(res.status, xml);
|
|
331
|
+
const items = tagBlocks(xml, 'Contents').map((b)=>{
|
|
332
|
+
const k = tagText(b, 'Key') ?? '';
|
|
333
|
+
return createStoredFile({
|
|
334
|
+
key: k,
|
|
335
|
+
size: Number(tagText(b, 'Size') ?? 0),
|
|
336
|
+
type: 'application/octet-stream',
|
|
337
|
+
etag: stripQuotes(tagText(b, 'ETag')),
|
|
338
|
+
lastModified: parseDate(tagText(b, 'LastModified'))
|
|
339
|
+
}, {
|
|
340
|
+
kind: 'lazy',
|
|
341
|
+
fetch: ()=>client.send(client.objectUrl(k), {
|
|
342
|
+
method: 'GET'
|
|
343
|
+
})
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
const prefixes = tagBlocks(xml, 'CommonPrefixes').map((b)=>tagText(b, 'Prefix')).filter((p)=>!!p);
|
|
347
|
+
const cursor = tagText(xml, 'IsTruncated') === 'true' ? tagText(xml, 'NextContinuationToken') : undefined;
|
|
348
|
+
return {
|
|
349
|
+
items,
|
|
350
|
+
prefixes: prefixes.length ? prefixes : undefined,
|
|
351
|
+
cursor
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
async url (key, opts) {
|
|
355
|
+
const forceSign = !!opts?.responseContentDisposition;
|
|
356
|
+
if (options.publicBaseUrl && !forceSign) return joinPublic(options.publicBaseUrl, key);
|
|
357
|
+
const query = opts?.responseContentDisposition ? {
|
|
358
|
+
'response-content-disposition': opts.responseContentDisposition
|
|
359
|
+
} : undefined;
|
|
360
|
+
return client.presign(key, 'GET', opts?.expiresIn ?? expires, query);
|
|
361
|
+
},
|
|
362
|
+
async signedUploadUrl (key, opts) {
|
|
363
|
+
if (opts.maxSize != null || opts.minSize != null) {
|
|
364
|
+
const { url, fields } = await client.signPostPolicy(key, {
|
|
365
|
+
expiresIn: opts.expiresIn,
|
|
366
|
+
contentType: opts.contentType,
|
|
367
|
+
maxSize: opts.maxSize,
|
|
368
|
+
minSize: opts.minSize
|
|
369
|
+
});
|
|
370
|
+
return {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
url,
|
|
373
|
+
fields
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const url = await client.presign(key, 'PUT', opts.expiresIn);
|
|
377
|
+
return {
|
|
378
|
+
method: 'PUT',
|
|
379
|
+
url,
|
|
380
|
+
headers: opts.contentType ? {
|
|
381
|
+
'content-type': opts.contentType
|
|
382
|
+
} : undefined
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
async createMultipartUpload (key, opts) {
|
|
386
|
+
const res = await client.send(client.objectUrl(key, {
|
|
387
|
+
uploads: ''
|
|
388
|
+
}), {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers: uploadHeaders(opts, false),
|
|
391
|
+
signal: opts?.signal
|
|
392
|
+
});
|
|
393
|
+
const xml = await res.text();
|
|
394
|
+
if (!res.ok) throw parseErrorBody(res.status, xml, key);
|
|
395
|
+
const uploadId = tagText(xml, 'UploadId');
|
|
396
|
+
if (!uploadId) throw new StorageError('Parse', 'missing UploadId');
|
|
397
|
+
return multipartHandle(key, uploadId, opts);
|
|
398
|
+
},
|
|
399
|
+
resumeMultipartUpload (key, uploadId) {
|
|
400
|
+
return multipartHandle(key, uploadId);
|
|
401
|
+
},
|
|
402
|
+
signedMultipart
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface S3Credentials {
|
|
2
|
+
accessKeyId: string;
|
|
3
|
+
secretAccessKey: string;
|
|
4
|
+
sessionToken?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface S3DriverOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Service endpoint. Examples:
|
|
9
|
+
* - AWS: `https://s3.us-east-1.amazonaws.com`
|
|
10
|
+
* - R2: `https://<accountId>.r2.cloudflarestorage.com`
|
|
11
|
+
* - MinIO: `http://localhost:9000`
|
|
12
|
+
*/
|
|
13
|
+
endpoint: string;
|
|
14
|
+
/** AWS region (`auto` for R2). */
|
|
15
|
+
region: string;
|
|
16
|
+
bucket: string;
|
|
17
|
+
credentials: S3Credentials;
|
|
18
|
+
/** Path-style addressing (`endpoint/bucket/key`). Required by MinIO/R2/most non-AWS. */
|
|
19
|
+
forcePathStyle?: boolean;
|
|
20
|
+
/** CDN/public origin; when set, `url()` returns a permanent link unless signing is forced. */
|
|
21
|
+
publicBaseUrl?: string;
|
|
22
|
+
/** Default `expiresIn` (seconds) for presigned URLs. Default 3600. */
|
|
23
|
+
defaultUrlExpiresIn?: number;
|
|
24
|
+
/** Override the fetch implementation (tests). */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** Friendly driver name (defaults to `s3`). */
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function unescapeXml(s: string): string;
|
|
2
|
+
export declare function escapeXml(s: string): string;
|
|
3
|
+
/** First `<tag>…</tag>` inner text (entity-decoded), or `undefined`. */
|
|
4
|
+
export declare function tagText(xml: string, tag: string): string | undefined;
|
|
5
|
+
/** All `<tag>…</tag>` inner blocks (raw, not decoded — for nested extraction). */
|
|
6
|
+
export declare function tagBlocks(xml: string, tag: string): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Dependency-free, edge-safe S3 XML helpers. Workers has no `DOMParser` and
|
|
2
|
+
// the AWS XML parser is banned, so we extract from S3's fixed, shallow,
|
|
3
|
+
// documented response shapes (ListBucketResult, Initiate/CompleteMultipartUpload,
|
|
4
|
+
// Error, ...) with anchored, ReDoS-free regexes. We never parse untrusted XML.
|
|
5
|
+
const ENTITIES = {
|
|
6
|
+
amp: '&',
|
|
7
|
+
lt: '<',
|
|
8
|
+
gt: '>',
|
|
9
|
+
quot: '"',
|
|
10
|
+
apos: "'"
|
|
11
|
+
};
|
|
12
|
+
export function unescapeXml(s) {
|
|
13
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (_m, e)=>{
|
|
14
|
+
if (e[0] === '#') {
|
|
15
|
+
const code = e[1] === 'x' ? Number.parseInt(e.slice(2), 16) : Number.parseInt(e.slice(1), 10);
|
|
16
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : _m;
|
|
17
|
+
}
|
|
18
|
+
return ENTITIES[e] ?? _m;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function escapeXml(s) {
|
|
22
|
+
return s.replace(/[&<>"']/g, (c)=>{
|
|
23
|
+
switch(c){
|
|
24
|
+
case '&':
|
|
25
|
+
return '&';
|
|
26
|
+
case '<':
|
|
27
|
+
return '<';
|
|
28
|
+
case '>':
|
|
29
|
+
return '>';
|
|
30
|
+
case '"':
|
|
31
|
+
return '"';
|
|
32
|
+
default:
|
|
33
|
+
return ''';
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// Each pattern uses a single non-greedy `[\s\S]*?` bounded by a literal close
|
|
38
|
+
// tag → linear time, no nested quantifiers, no catastrophic backtracking.
|
|
39
|
+
function tagPattern(tag) {
|
|
40
|
+
return `<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`;
|
|
41
|
+
}
|
|
42
|
+
/** First `<tag>…</tag>` inner text (entity-decoded), or `undefined`. */ export function tagText(xml, tag) {
|
|
43
|
+
const m = new RegExp(tagPattern(tag)).exec(xml);
|
|
44
|
+
return m ? unescapeXml(m[1]) : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** All `<tag>…</tag>` inner blocks (raw, not decoded — for nested extraction). */ export function tagBlocks(xml, tag) {
|
|
47
|
+
const re = new RegExp(tagPattern(tag), 'g');
|
|
48
|
+
const out = [];
|
|
49
|
+
let m;
|
|
50
|
+
while((m = re.exec(xml)) !== null)out.push(m[1]);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
/**
|
|
3
|
+
* The typed action a request wants to perform. The authorizer receives this
|
|
4
|
+
* and can deny, allow, or allow-with-overrides (rewriting the effective key /
|
|
5
|
+
* prefix / limits) — the load-bearing IDOR / cross-tenant guard.
|
|
6
|
+
*/
|
|
7
|
+
export type StorageAction = {
|
|
8
|
+
type: 'sign-upload';
|
|
9
|
+
key: string;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
size?: number;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'multipart-create';
|
|
14
|
+
key: string;
|
|
15
|
+
contentType?: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'multipart-sign-part';
|
|
18
|
+
key: string;
|
|
19
|
+
uploadId: string;
|
|
20
|
+
partNumber: number;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'multipart-complete';
|
|
23
|
+
key: string;
|
|
24
|
+
uploadId: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'multipart-abort';
|
|
27
|
+
key: string;
|
|
28
|
+
uploadId: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'download';
|
|
31
|
+
key: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: 'head';
|
|
34
|
+
key: string;
|
|
35
|
+
} | {
|
|
36
|
+
type: 'list';
|
|
37
|
+
prefix?: string;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'delete';
|
|
40
|
+
keys: string[];
|
|
41
|
+
};
|
|
42
|
+
export interface StorageAuthContext {
|
|
43
|
+
/** The raw Web `Request` (edge-safe). */
|
|
44
|
+
req: Request;
|
|
45
|
+
/** The Hono context — `c.env` (bindings/secrets), `c.get('user')` from an upstream guard. */
|
|
46
|
+
ctx: Context;
|
|
47
|
+
/** The bucket name this controller is bound to. */
|
|
48
|
+
driver: string;
|
|
49
|
+
}
|
|
50
|
+
/** Return this to ALLOW with server-side modifications. */
|
|
51
|
+
export interface StorageAuthResult {
|
|
52
|
+
key?: string;
|
|
53
|
+
keys?: string[];
|
|
54
|
+
prefix?: string;
|
|
55
|
+
maxSize?: number;
|
|
56
|
+
expiresIn?: number;
|
|
57
|
+
metadata?: Record<string, string>;
|
|
58
|
+
}
|
|
59
|
+
export type StorageAuthorizer = (action: StorageAction, context: StorageAuthContext) => boolean | StorageAuthResult | Promise<boolean | StorageAuthResult>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { StorageErrorCode } from '../storage.error';
|
|
2
|
+
import type { ByteRange, StoredFile } from '../storage.types';
|
|
3
|
+
import type { StorageWireErrorCode } from './protocol.types';
|
|
4
|
+
/** Map a core error code to (wire code, HTTP status). */
|
|
5
|
+
export declare function mapError(code: StorageErrorCode): {
|
|
6
|
+
wire: StorageWireErrorCode;
|
|
7
|
+
status: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function serializeStored(file: StoredFile): {
|
|
10
|
+
key: string;
|
|
11
|
+
name: string;
|
|
12
|
+
size: number;
|
|
13
|
+
type: string;
|
|
14
|
+
lastModified: number | undefined;
|
|
15
|
+
etag: string | undefined;
|
|
16
|
+
metadata: Record<string, string> | undefined;
|
|
17
|
+
};
|
|
18
|
+
export declare function clampExpiry(requested: number | undefined, def: number, max: number): number;
|
|
19
|
+
/** Parse an HTTP `Range` header (single range) into a {@link ByteRange}. */
|
|
20
|
+
export declare function parseRange(header: string | undefined | null): ByteRange | undefined;
|
|
21
|
+
/** Content-Disposition header — defaults to `attachment` (anti stored-XSS). */
|
|
22
|
+
export declare function dispositionHeader(disposition: string | undefined, name: string): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Map a core error code to (wire code, HTTP status). */ export function mapError(code) {
|
|
2
|
+
switch(code){
|
|
3
|
+
case 'NotFound':
|
|
4
|
+
return {
|
|
5
|
+
wire: 'not_found',
|
|
6
|
+
status: 404
|
|
7
|
+
};
|
|
8
|
+
case 'AccessDenied':
|
|
9
|
+
case 'ReadOnly':
|
|
10
|
+
return {
|
|
11
|
+
wire: 'forbidden',
|
|
12
|
+
status: 403
|
|
13
|
+
};
|
|
14
|
+
case 'Unsupported':
|
|
15
|
+
return {
|
|
16
|
+
wire: 'capability_unsupported',
|
|
17
|
+
status: 400
|
|
18
|
+
};
|
|
19
|
+
case 'InvalidKey':
|
|
20
|
+
return {
|
|
21
|
+
wire: 'invalid_key',
|
|
22
|
+
status: 400
|
|
23
|
+
};
|
|
24
|
+
case 'InvalidRequest':
|
|
25
|
+
case 'Aborted':
|
|
26
|
+
return {
|
|
27
|
+
wire: 'invalid_request',
|
|
28
|
+
status: 400
|
|
29
|
+
};
|
|
30
|
+
case 'Conflict':
|
|
31
|
+
return {
|
|
32
|
+
wire: 'conflict',
|
|
33
|
+
status: 409
|
|
34
|
+
};
|
|
35
|
+
case 'RateLimited':
|
|
36
|
+
return {
|
|
37
|
+
wire: 'rate_limited',
|
|
38
|
+
status: 429
|
|
39
|
+
};
|
|
40
|
+
case 'Timeout':
|
|
41
|
+
return {
|
|
42
|
+
wire: 'upstream_error',
|
|
43
|
+
status: 504
|
|
44
|
+
};
|
|
45
|
+
default:
|
|
46
|
+
return {
|
|
47
|
+
wire: 'upstream_error',
|
|
48
|
+
status: 502
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function serializeStored(file) {
|
|
53
|
+
return {
|
|
54
|
+
key: file.key,
|
|
55
|
+
name: file.name,
|
|
56
|
+
size: file.size,
|
|
57
|
+
type: file.type,
|
|
58
|
+
lastModified: file.lastModified,
|
|
59
|
+
etag: file.etag,
|
|
60
|
+
metadata: file.metadata
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function clampExpiry(requested, def, max) {
|
|
64
|
+
return Math.min(requested ?? def, max);
|
|
65
|
+
}
|
|
66
|
+
/** Parse an HTTP `Range` header (single range) into a {@link ByteRange}. */ export function parseRange(header) {
|
|
67
|
+
if (!header) return undefined;
|
|
68
|
+
const m = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
|
|
69
|
+
if (!m) return undefined;
|
|
70
|
+
const start = Number(m[1]);
|
|
71
|
+
const end = m[2] === '' ? undefined : Number(m[2]);
|
|
72
|
+
return {
|
|
73
|
+
start,
|
|
74
|
+
end
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Content-Disposition header — defaults to `attachment` (anti stored-XSS). */ export function dispositionHeader(disposition, name) {
|
|
78
|
+
const filename = `filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
79
|
+
return disposition === 'inline' ? `inline; ${filename}` : `attachment; ${filename}`;
|
|
80
|
+
}
|