@filelayer/core 0.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/CHANGELOG.md +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- package/tsconfig.json +19 -0
package/dist/storage.js
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STORAGE ADAPTERS
|
|
3
|
+
*
|
|
4
|
+
* P2 (no ambient authority) is why this interface is deliberately dumb: it
|
|
5
|
+
* takes an opaque key and moves bytes. It has no idea what an org is, cannot
|
|
6
|
+
* be asked "who may read this", and is never consulted during an authorization
|
|
7
|
+
* decision. If the adapter could answer access questions there would be two
|
|
8
|
+
* authorization paths, and the second one is always the one that leaks.
|
|
9
|
+
*
|
|
10
|
+
* Corollary: object keys are NOT secrets and are not treated as such anywhere.
|
|
11
|
+
*
|
|
12
|
+
* -----------------------------------------------------------------------------
|
|
13
|
+
* WHAT CHANGED, AND WHY
|
|
14
|
+
* -----------------------------------------------------------------------------
|
|
15
|
+
*
|
|
16
|
+
* 1. `provider` is now part of the interface.
|
|
17
|
+
*
|
|
18
|
+
* `file.storage_provider` was written as the literal `'memory'` in
|
|
19
|
+
* `Filelayer.upload()`, regardless of which adapter was configured. Against
|
|
20
|
+
* `CREATE UNIQUE INDEX file_storage_key_idx ON file (storage_provider,
|
|
21
|
+
* storage_key)` that means a production database records every object as
|
|
22
|
+
* living in an in-memory store, and the one column that says WHERE the bytes
|
|
23
|
+
* are is wrong for every row. An adapter must therefore be able to name
|
|
24
|
+
* itself, and the name must come from the adapter rather than from the call
|
|
25
|
+
* site.
|
|
26
|
+
*
|
|
27
|
+
* 2. Everything is streaming-capable.
|
|
28
|
+
*
|
|
29
|
+
* `put()` took a `Uint8Array` and delivery returned one. That is a permanent
|
|
30
|
+
* tax on the heap: a 2 GB upload was 2 GB of resident memory in the API
|
|
31
|
+
* process, twice (once in the adapter, once in the response). `put()` now
|
|
32
|
+
* accepts a `ReadableStream` and uses S3 multipart above a part threshold;
|
|
33
|
+
* `stream()` returns a byte stream plus the metadata a correct HTTP response
|
|
34
|
+
* needs, and supports ranged reads.
|
|
35
|
+
*
|
|
36
|
+
* 3. `head()` exists.
|
|
37
|
+
*
|
|
38
|
+
* A `stat()` that has to download the object to learn its size is not a
|
|
39
|
+
* stat.
|
|
40
|
+
*
|
|
41
|
+
* 4. `presignGet()` is OPTIONAL, and its optionality is the point.
|
|
42
|
+
*
|
|
43
|
+
* It is the only capability the redirect delivery mode needs, and an adapter
|
|
44
|
+
* that cannot mint a presigned URL simply does not offer redirect delivery
|
|
45
|
+
* (`MemoryStorage` does not). Nothing else in the system may call it:
|
|
46
|
+
* a presigned URL is authority that outlives the decision that produced it,
|
|
47
|
+
* which is exactly the property P4 exists to deny, so it is reachable only
|
|
48
|
+
* through the explicitly-acknowledged redirect mode in `delivery.ts`.
|
|
49
|
+
*
|
|
50
|
+
* 5. `list()` is OPTIONAL and exists for exactly one caller: orphan collection.
|
|
51
|
+
*
|
|
52
|
+
* The storage write is not transactional (see `db.ts`). Bytes are written
|
|
53
|
+
* before the metadata transaction commits, so a crash in between leaves an
|
|
54
|
+
* object with no `file` row. That is a garbage-collection problem, not a
|
|
55
|
+
* correctness one -- an orphan is unreachable, because every read path starts
|
|
56
|
+
* from a `file` row -- but it is still our problem. `list()` is what makes it
|
|
57
|
+
* collectable.
|
|
58
|
+
*/
|
|
59
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
60
|
+
/** Narrowing helpers, so callers do not hand-roll `typeof x.presignGet`. */
|
|
61
|
+
export function canPresign(s) {
|
|
62
|
+
return typeof s.presignGet === 'function';
|
|
63
|
+
}
|
|
64
|
+
export function canList(s) {
|
|
65
|
+
return typeof s.list === 'function';
|
|
66
|
+
}
|
|
67
|
+
// -----------------------------------------------------------------------------
|
|
68
|
+
// Stream helpers
|
|
69
|
+
// -----------------------------------------------------------------------------
|
|
70
|
+
/** Collect a byte stream, refusing to exceed `limit`. */
|
|
71
|
+
export async function collectStream(stream, limit = 512 * 1024 * 1024) {
|
|
72
|
+
const reader = stream.getReader();
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let total = 0;
|
|
75
|
+
try {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done)
|
|
79
|
+
break;
|
|
80
|
+
if (!value)
|
|
81
|
+
continue;
|
|
82
|
+
total += value.byteLength;
|
|
83
|
+
if (total > limit) {
|
|
84
|
+
await reader.cancel('limit exceeded').catch(() => { });
|
|
85
|
+
throw new Error(`object exceeds the ${limit}-byte buffered read limit; use stream()`);
|
|
86
|
+
}
|
|
87
|
+
chunks.push(value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
reader.releaseLock();
|
|
92
|
+
}
|
|
93
|
+
const out = new Uint8Array(total);
|
|
94
|
+
let at = 0;
|
|
95
|
+
for (const c of chunks) {
|
|
96
|
+
out.set(c, at);
|
|
97
|
+
at += c.byteLength;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
export function bytesToStream(body) {
|
|
102
|
+
return new ReadableStream({
|
|
103
|
+
start(controller) {
|
|
104
|
+
controller.enqueue(body);
|
|
105
|
+
controller.close();
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// -----------------------------------------------------------------------------
|
|
110
|
+
// In-memory adapter (tests, local dev)
|
|
111
|
+
// -----------------------------------------------------------------------------
|
|
112
|
+
export class MemoryStorage {
|
|
113
|
+
provider = 'memory';
|
|
114
|
+
objects = new Map();
|
|
115
|
+
async put(key, body, contentType) {
|
|
116
|
+
const bytes = body instanceof Uint8Array ? body : await collectStream(body);
|
|
117
|
+
const etag = `"${createHash('md5').update(bytes).digest('hex')}"`;
|
|
118
|
+
this.objects.set(key, { body: bytes, contentType, etag, lastModified: new Date() });
|
|
119
|
+
return { bytes: bytes.byteLength, etag };
|
|
120
|
+
}
|
|
121
|
+
async get(key) {
|
|
122
|
+
return this.objects.get(key)?.body ?? null;
|
|
123
|
+
}
|
|
124
|
+
async head(key) {
|
|
125
|
+
const o = this.objects.get(key);
|
|
126
|
+
if (!o)
|
|
127
|
+
return null;
|
|
128
|
+
return {
|
|
129
|
+
size: o.body.byteLength,
|
|
130
|
+
contentType: o.contentType,
|
|
131
|
+
etag: o.etag,
|
|
132
|
+
lastModified: o.lastModified,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async delete(key) {
|
|
136
|
+
this.objects.delete(key);
|
|
137
|
+
}
|
|
138
|
+
async stream(key, opts = {}) {
|
|
139
|
+
const o = this.objects.get(key);
|
|
140
|
+
if (!o)
|
|
141
|
+
return null;
|
|
142
|
+
const total = o.body.byteLength;
|
|
143
|
+
if (opts.range) {
|
|
144
|
+
const start = Math.max(0, opts.range.start);
|
|
145
|
+
const end = Math.min(opts.range.end ?? total - 1, total - 1);
|
|
146
|
+
if (start > end)
|
|
147
|
+
return null;
|
|
148
|
+
const slice = o.body.subarray(start, end + 1);
|
|
149
|
+
return {
|
|
150
|
+
body: bytesToStream(slice),
|
|
151
|
+
size: slice.byteLength,
|
|
152
|
+
contentType: o.contentType,
|
|
153
|
+
etag: o.etag,
|
|
154
|
+
range: { start, end, total },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { body: bytesToStream(o.body), size: total, contentType: o.contentType, etag: o.etag };
|
|
158
|
+
}
|
|
159
|
+
async list(prefix, opts = {}) {
|
|
160
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 1000, 1000));
|
|
161
|
+
const after = opts.cursor ?? '';
|
|
162
|
+
const all = [...this.objects.entries()]
|
|
163
|
+
.filter(([k]) => k.startsWith(prefix) && k > after)
|
|
164
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
165
|
+
const page = all.slice(0, limit);
|
|
166
|
+
return {
|
|
167
|
+
entries: page.map(([k, v]) => ({
|
|
168
|
+
key: k,
|
|
169
|
+
size: v.body.byteLength,
|
|
170
|
+
lastModified: v.lastModified,
|
|
171
|
+
})),
|
|
172
|
+
cursor: all.length > limit ? (page[page.length - 1]?.[0] ?? null) : null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* DELIBERATELY ABSENT: `presignGet`.
|
|
177
|
+
*
|
|
178
|
+
* There is no URL that reaches an in-process Map, so redirect delivery is
|
|
179
|
+
* structurally unavailable here rather than fake. A test that wants to
|
|
180
|
+
* exercise redirect delivery must run against something that can actually
|
|
181
|
+
* mint one -- which is the point.
|
|
182
|
+
*/
|
|
183
|
+
/** Test-only: lets a test assert that delete really removed the bytes. */
|
|
184
|
+
keys() {
|
|
185
|
+
return [...this.objects.keys()];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
189
|
+
const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
|
|
190
|
+
export class S3Storage {
|
|
191
|
+
provider;
|
|
192
|
+
cfg;
|
|
193
|
+
constructor(cfg) {
|
|
194
|
+
for (const k of ['endpoint', 'bucket', 'region', 'accessKeyId', 'secretAccessKey']) {
|
|
195
|
+
if (typeof cfg[k] !== 'string' || cfg[k].length === 0) {
|
|
196
|
+
throw new Error(`S3Storage: missing required config '${k}'`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const endpoint = cfg.endpoint.replace(/\/+$/, '');
|
|
200
|
+
this.provider =
|
|
201
|
+
cfg.provider ?? (/\.r2\.cloudflarestorage\.com$/i.test(new URL(endpoint).hostname) ? 'r2' : 's3');
|
|
202
|
+
if (!/^[a-z0-9_-]{1,32}$/.test(this.provider)) {
|
|
203
|
+
throw new Error(`S3Storage: implausible provider name ${JSON.stringify(this.provider)}`);
|
|
204
|
+
}
|
|
205
|
+
this.cfg = {
|
|
206
|
+
endpoint,
|
|
207
|
+
bucket: cfg.bucket,
|
|
208
|
+
region: cfg.region,
|
|
209
|
+
accessKeyId: cfg.accessKeyId,
|
|
210
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
211
|
+
pathStyle: cfg.pathStyle ?? true,
|
|
212
|
+
partSizeBytes: Math.max(MIN_PART_SIZE, cfg.partSizeBytes ?? DEFAULT_PART_SIZE),
|
|
213
|
+
maxPresignSeconds: Math.max(1, Math.min(cfg.maxPresignSeconds ?? 3600, 7 * 24 * 3600)),
|
|
214
|
+
...(cfg.sessionToken !== undefined ? { sessionToken: cfg.sessionToken } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// --- writes ---------------------------------------------------------------
|
|
218
|
+
async put(key, body, contentType, opts = {}) {
|
|
219
|
+
if (body instanceof Uint8Array)
|
|
220
|
+
return this.putBuffer(key, body, contentType);
|
|
221
|
+
return this.putStream(key, body, contentType, opts);
|
|
222
|
+
}
|
|
223
|
+
async putBuffer(key, body, contentType) {
|
|
224
|
+
const res = await this.signedFetch({
|
|
225
|
+
method: 'PUT',
|
|
226
|
+
key,
|
|
227
|
+
body,
|
|
228
|
+
headers: { 'content-type': contentType },
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok)
|
|
231
|
+
throw await s3Error('put', res);
|
|
232
|
+
await res.arrayBuffer(); // drain; undici leaks the connection otherwise
|
|
233
|
+
return { bytes: body.byteLength, etag: res.headers.get('etag') };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Streaming upload.
|
|
237
|
+
*
|
|
238
|
+
* The first `partSizeBytes` are buffered because we cannot know until we have
|
|
239
|
+
* them whether this is a one-shot PUT or a multipart upload, and a one-shot
|
|
240
|
+
* PUT needs `content-length` (and a payload hash) up front. Everything beyond
|
|
241
|
+
* that is uploaded part by part and never all resident at once, so peak
|
|
242
|
+
* memory is bounded by ONE part regardless of object size. That bound is the
|
|
243
|
+
* entire point of this method.
|
|
244
|
+
*/
|
|
245
|
+
async putStream(key, body, contentType, opts) {
|
|
246
|
+
const partSize = this.cfg.partSizeBytes;
|
|
247
|
+
const reader = body.getReader();
|
|
248
|
+
const first = await readAtLeast(reader, partSize);
|
|
249
|
+
if (first.done) {
|
|
250
|
+
// Whole object fits in one part.
|
|
251
|
+
reader.releaseLock();
|
|
252
|
+
return this.putBuffer(key, first.chunk, contentType);
|
|
253
|
+
}
|
|
254
|
+
if (opts.contentLength !== undefined && opts.contentLength <= partSize) {
|
|
255
|
+
// Caller lied about the length; trust the bytes, not the claim.
|
|
256
|
+
}
|
|
257
|
+
const uploadId = await this.createMultipartUpload(key, contentType);
|
|
258
|
+
const parts = [];
|
|
259
|
+
let total = 0;
|
|
260
|
+
let partNumber = 0;
|
|
261
|
+
let pending = first.chunk;
|
|
262
|
+
try {
|
|
263
|
+
for (;;) {
|
|
264
|
+
partNumber += 1;
|
|
265
|
+
parts.push({ partNumber, etag: await this.uploadPart(key, uploadId, partNumber, pending) });
|
|
266
|
+
total += pending.byteLength;
|
|
267
|
+
const next = await readAtLeast(reader, partSize);
|
|
268
|
+
if (next.chunk.byteLength === 0 && next.done)
|
|
269
|
+
break;
|
|
270
|
+
pending = next.chunk;
|
|
271
|
+
if (next.done) {
|
|
272
|
+
partNumber += 1;
|
|
273
|
+
parts.push({
|
|
274
|
+
partNumber,
|
|
275
|
+
etag: await this.uploadPart(key, uploadId, partNumber, pending),
|
|
276
|
+
});
|
|
277
|
+
total += pending.byteLength;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const etag = await this.completeMultipartUpload(key, uploadId, parts);
|
|
282
|
+
return { bytes: total, etag };
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
// An abandoned multipart upload is billable storage that no `file` row
|
|
286
|
+
// points at -- the orphan problem, in its most expensive form. Abort is
|
|
287
|
+
// best-effort because the original error is the one worth reporting.
|
|
288
|
+
await this.abortMultipartUpload(key, uploadId).catch(() => { });
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
reader.releaseLock();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async createMultipartUpload(key, contentType) {
|
|
296
|
+
const res = await this.signedFetch({
|
|
297
|
+
method: 'POST',
|
|
298
|
+
key,
|
|
299
|
+
query: { uploads: '' },
|
|
300
|
+
body: new Uint8Array(),
|
|
301
|
+
headers: { 'content-type': contentType },
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok)
|
|
304
|
+
throw await s3Error('createMultipartUpload', res);
|
|
305
|
+
const xml = await res.text();
|
|
306
|
+
const uploadId = xmlTag(xml, 'UploadId');
|
|
307
|
+
if (!uploadId)
|
|
308
|
+
throw new Error('storage createMultipartUpload: no UploadId in response');
|
|
309
|
+
return uploadId;
|
|
310
|
+
}
|
|
311
|
+
async uploadPart(key, uploadId, partNumber, body) {
|
|
312
|
+
const res = await this.signedFetch({
|
|
313
|
+
method: 'PUT',
|
|
314
|
+
key,
|
|
315
|
+
query: { partNumber: String(partNumber), uploadId },
|
|
316
|
+
body,
|
|
317
|
+
});
|
|
318
|
+
if (!res.ok)
|
|
319
|
+
throw await s3Error('uploadPart', res);
|
|
320
|
+
await res.arrayBuffer();
|
|
321
|
+
const etag = res.headers.get('etag');
|
|
322
|
+
if (!etag)
|
|
323
|
+
throw new Error('storage uploadPart: no ETag in response');
|
|
324
|
+
return etag;
|
|
325
|
+
}
|
|
326
|
+
async completeMultipartUpload(key, uploadId, parts) {
|
|
327
|
+
const xml = '<CompleteMultipartUpload>' +
|
|
328
|
+
parts
|
|
329
|
+
.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`)
|
|
330
|
+
.join('') +
|
|
331
|
+
'</CompleteMultipartUpload>';
|
|
332
|
+
const res = await this.signedFetch({
|
|
333
|
+
method: 'POST',
|
|
334
|
+
key,
|
|
335
|
+
query: { uploadId },
|
|
336
|
+
body: new TextEncoder().encode(xml),
|
|
337
|
+
headers: { 'content-type': 'application/xml' },
|
|
338
|
+
});
|
|
339
|
+
if (!res.ok)
|
|
340
|
+
throw await s3Error('completeMultipartUpload', res);
|
|
341
|
+
const text = await res.text();
|
|
342
|
+
// S3 can return 200 with an <Error> body on this call specifically. Treating
|
|
343
|
+
// that as success would report a successful upload of a broken object.
|
|
344
|
+
if (/<Error>/.test(text)) {
|
|
345
|
+
throw new Error(`storage completeMultipartUpload failed with 200 + Error body: ${xmlTag(text, 'Code') ?? text.slice(0, 200)}`);
|
|
346
|
+
}
|
|
347
|
+
return xmlTag(text, 'ETag');
|
|
348
|
+
}
|
|
349
|
+
async abortMultipartUpload(key, uploadId) {
|
|
350
|
+
const res = await this.signedFetch({ method: 'DELETE', key, query: { uploadId } });
|
|
351
|
+
await res.arrayBuffer().catch(() => { });
|
|
352
|
+
}
|
|
353
|
+
// --- reads ----------------------------------------------------------------
|
|
354
|
+
async get(key) {
|
|
355
|
+
const res = await this.signedFetch({ method: 'GET', key });
|
|
356
|
+
if (res.status === 404) {
|
|
357
|
+
await res.arrayBuffer().catch(() => { });
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
if (!res.ok)
|
|
361
|
+
throw await s3Error('get', res);
|
|
362
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
363
|
+
}
|
|
364
|
+
async head(key) {
|
|
365
|
+
const res = await this.signedFetch({ method: 'HEAD', key });
|
|
366
|
+
// A HEAD has no body to read, but undici still wants the (empty) body
|
|
367
|
+
// consumed before the socket goes back to the pool.
|
|
368
|
+
await res.arrayBuffer().catch(() => { });
|
|
369
|
+
if (res.status === 404)
|
|
370
|
+
return null;
|
|
371
|
+
if (!res.ok)
|
|
372
|
+
throw new Error(`storage head failed: ${res.status}`);
|
|
373
|
+
const len = res.headers.get('content-length');
|
|
374
|
+
const lm = res.headers.get('last-modified');
|
|
375
|
+
return {
|
|
376
|
+
size: len === null ? 0 : Number(len),
|
|
377
|
+
contentType: res.headers.get('content-type'),
|
|
378
|
+
etag: res.headers.get('etag'),
|
|
379
|
+
lastModified: lm ? new Date(lm) : null,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
async stream(key, opts = {}) {
|
|
383
|
+
const headers = {};
|
|
384
|
+
if (opts.range) {
|
|
385
|
+
headers['range'] =
|
|
386
|
+
opts.range.end === undefined
|
|
387
|
+
? `bytes=${opts.range.start}-`
|
|
388
|
+
: `bytes=${opts.range.start}-${opts.range.end}`;
|
|
389
|
+
}
|
|
390
|
+
const res = await this.signedFetch({ method: 'GET', key, headers });
|
|
391
|
+
if (res.status === 404) {
|
|
392
|
+
await res.arrayBuffer().catch(() => { });
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
// 416 is "the range you asked for does not exist", which for our purposes is
|
|
396
|
+
// the same answer as "no such bytes" rather than a 500.
|
|
397
|
+
if (res.status === 416) {
|
|
398
|
+
await res.arrayBuffer().catch(() => { });
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
if (!res.ok)
|
|
402
|
+
throw await s3Error('stream', res);
|
|
403
|
+
if (!res.body)
|
|
404
|
+
throw new Error('storage stream: response had no body');
|
|
405
|
+
const len = res.headers.get('content-length');
|
|
406
|
+
const out = {
|
|
407
|
+
body: res.body,
|
|
408
|
+
size: len === null ? null : Number(len),
|
|
409
|
+
contentType: res.headers.get('content-type'),
|
|
410
|
+
etag: res.headers.get('etag'),
|
|
411
|
+
};
|
|
412
|
+
const cr = res.headers.get('content-range');
|
|
413
|
+
const m = cr && /^bytes (\d+)-(\d+)\/(\d+)$/.exec(cr);
|
|
414
|
+
if (m)
|
|
415
|
+
out.range = { start: Number(m[1]), end: Number(m[2]), total: Number(m[3]) };
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
async delete(key) {
|
|
419
|
+
const res = await this.signedFetch({ method: 'DELETE', key });
|
|
420
|
+
await res.arrayBuffer().catch(() => { });
|
|
421
|
+
// S3 returns 204 for a delete of a key that never existed. 404 is here for
|
|
422
|
+
// S3-compatible stores that disagree; either way "it is gone" is success.
|
|
423
|
+
if (!res.ok && res.status !== 404)
|
|
424
|
+
throw new Error(`storage delete failed: ${res.status}`);
|
|
425
|
+
}
|
|
426
|
+
async list(prefix, opts = {}) {
|
|
427
|
+
const query = {
|
|
428
|
+
'list-type': '2',
|
|
429
|
+
prefix,
|
|
430
|
+
'max-keys': String(Math.max(1, Math.min(opts.limit ?? 1000, 1000))),
|
|
431
|
+
};
|
|
432
|
+
if (opts.cursor)
|
|
433
|
+
query['continuation-token'] = opts.cursor;
|
|
434
|
+
const res = await this.signedFetch({ method: 'GET', key: '', query });
|
|
435
|
+
if (!res.ok)
|
|
436
|
+
throw await s3Error('list', res);
|
|
437
|
+
const xml = await res.text();
|
|
438
|
+
const entries = [];
|
|
439
|
+
for (const c of xml.match(/<Contents>[\s\S]*?<\/Contents>/g) ?? []) {
|
|
440
|
+
const key = xmlTag(c, 'Key');
|
|
441
|
+
if (key === null)
|
|
442
|
+
continue;
|
|
443
|
+
const lm = xmlTag(c, 'LastModified');
|
|
444
|
+
entries.push({
|
|
445
|
+
key: unescapeXml(key),
|
|
446
|
+
size: Number(xmlTag(c, 'Size') ?? 0),
|
|
447
|
+
lastModified: lm ? new Date(lm) : null,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const truncated = xmlTag(xml, 'IsTruncated') === 'true';
|
|
451
|
+
return { entries, cursor: truncated ? xmlTag(xml, 'NextContinuationToken') : null };
|
|
452
|
+
}
|
|
453
|
+
// --- presigning -----------------------------------------------------------
|
|
454
|
+
/**
|
|
455
|
+
* A query-string-signed GET URL.
|
|
456
|
+
*
|
|
457
|
+
* READ THE WARNING IN `delivery.ts` BEFORE CALLING THIS. The URL is bearer
|
|
458
|
+
* authority that the object store will honour until it expires, and the
|
|
459
|
+
* object store has never heard of a grant, a revocation or an org. That is
|
|
460
|
+
* why it is not reachable from any ordinary delivery path.
|
|
461
|
+
*
|
|
462
|
+
* `response-content-type` and `response-content-disposition` are signed into
|
|
463
|
+
* the URL, so the object store -- not the client -- decides what the bytes are
|
|
464
|
+
* served as. Without them a redirect would drop the `nosniff`/`attachment`
|
|
465
|
+
* protections that `deliveryHeaders()` exists to guarantee.
|
|
466
|
+
*/
|
|
467
|
+
async presignGet(key, opts) {
|
|
468
|
+
const expires = Math.max(1, Math.min(Math.floor(opts.expiresInSeconds), this.cfg.maxPresignSeconds));
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const amzDate = amzDateOf(now);
|
|
471
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
472
|
+
const scope = `${dateStamp}/${this.cfg.region}/s3/aws4_request`;
|
|
473
|
+
const { url, canonicalUri } = this.objectUrl(key);
|
|
474
|
+
const query = {
|
|
475
|
+
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
|
476
|
+
'X-Amz-Credential': `${this.cfg.accessKeyId}/${scope}`,
|
|
477
|
+
'X-Amz-Date': amzDate,
|
|
478
|
+
'X-Amz-Expires': String(expires),
|
|
479
|
+
'X-Amz-SignedHeaders': 'host',
|
|
480
|
+
};
|
|
481
|
+
if (this.cfg.sessionToken)
|
|
482
|
+
query['X-Amz-Security-Token'] = this.cfg.sessionToken;
|
|
483
|
+
if (opts.responseContentType)
|
|
484
|
+
query['response-content-type'] = opts.responseContentType;
|
|
485
|
+
if (opts.responseContentDisposition) {
|
|
486
|
+
query['response-content-disposition'] = opts.responseContentDisposition;
|
|
487
|
+
}
|
|
488
|
+
const canonicalRequest = [
|
|
489
|
+
'GET',
|
|
490
|
+
canonicalUri,
|
|
491
|
+
canonicalQueryString(query),
|
|
492
|
+
`host:${url.host}\n`,
|
|
493
|
+
'host',
|
|
494
|
+
'UNSIGNED-PAYLOAD',
|
|
495
|
+
].join('\n');
|
|
496
|
+
const signature = this.sign(dateStamp, amzDate, scope, canonicalRequest);
|
|
497
|
+
query['X-Amz-Signature'] = signature;
|
|
498
|
+
return `${url.origin}${canonicalUri}?${canonicalQueryString(query)}`;
|
|
499
|
+
}
|
|
500
|
+
// --- signing --------------------------------------------------------------
|
|
501
|
+
/**
|
|
502
|
+
* Path-style: `https://host/<bucket>/<key>`. Virtual-hosted:
|
|
503
|
+
* `https://<bucket>.host/<key>`.
|
|
504
|
+
*
|
|
505
|
+
* The canonical URI is built by RFC-3986-encoding each SEGMENT and is carried
|
|
506
|
+
* separately from `URL.pathname`, because `new URL()` normalises `.`/`..`
|
|
507
|
+
* segments and re-encodes some characters. Signing one string and sending
|
|
508
|
+
* another is the classic SigV4 bug and produces a 403 that looks like a
|
|
509
|
+
* credentials problem.
|
|
510
|
+
*/
|
|
511
|
+
objectUrl(key) {
|
|
512
|
+
const base = new URL(this.cfg.endpoint);
|
|
513
|
+
const segments = key === '' ? [] : key.split('/');
|
|
514
|
+
let host = base.host;
|
|
515
|
+
let path;
|
|
516
|
+
if (this.cfg.pathStyle) {
|
|
517
|
+
path = '/' + [this.cfg.bucket, ...segments].map(rfc3986).join('/');
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
host = `${this.cfg.bucket}.${base.host}`;
|
|
521
|
+
path = '/' + segments.map(rfc3986).join('/');
|
|
522
|
+
}
|
|
523
|
+
const url = new URL(`${base.protocol}//${host}${path}`);
|
|
524
|
+
return { url, canonicalUri: path };
|
|
525
|
+
}
|
|
526
|
+
sign(dateStamp, _amzDate, scope, canonicalRequest) {
|
|
527
|
+
const stringToSign = [
|
|
528
|
+
'AWS4-HMAC-SHA256',
|
|
529
|
+
_amzDate,
|
|
530
|
+
scope,
|
|
531
|
+
sha256Hex(Buffer.from(canonicalRequest, 'utf8')),
|
|
532
|
+
].join('\n');
|
|
533
|
+
let k = Buffer.from(`AWS4${this.cfg.secretAccessKey}`, 'utf8');
|
|
534
|
+
for (const part of [dateStamp, this.cfg.region, 's3', 'aws4_request']) {
|
|
535
|
+
k = createHmac('sha256', k).update(part, 'utf8').digest();
|
|
536
|
+
}
|
|
537
|
+
return createHmac('sha256', k).update(stringToSign, 'utf8').digest('hex');
|
|
538
|
+
}
|
|
539
|
+
async signedFetch(req) {
|
|
540
|
+
const { url, canonicalUri } = this.objectUrl(req.key);
|
|
541
|
+
const query = req.query ?? {};
|
|
542
|
+
const cqs = canonicalQueryString(query);
|
|
543
|
+
const now = new Date();
|
|
544
|
+
const amzDate = amzDateOf(now);
|
|
545
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
546
|
+
const payloadHash = sha256Hex(req.body ?? new Uint8Array());
|
|
547
|
+
const headers = {
|
|
548
|
+
host: url.host,
|
|
549
|
+
'x-amz-content-sha256': payloadHash,
|
|
550
|
+
'x-amz-date': amzDate,
|
|
551
|
+
};
|
|
552
|
+
if (this.cfg.sessionToken)
|
|
553
|
+
headers['x-amz-security-token'] = this.cfg.sessionToken;
|
|
554
|
+
// Lowercase every supplied header name: SigV4 canonicalises on the lowercase
|
|
555
|
+
// name and sorts on it, so a `Content-Type` from a caller would sort into a
|
|
556
|
+
// different position than the `content-type` actually sent.
|
|
557
|
+
for (const [k, v] of Object.entries(req.headers ?? {}))
|
|
558
|
+
headers[k.toLowerCase()] = v;
|
|
559
|
+
const names = Object.keys(headers).sort();
|
|
560
|
+
const signedHeaders = names.join(';');
|
|
561
|
+
// Header values are trimmed AND internal whitespace runs collapsed, per the
|
|
562
|
+
// SigV4 spec. Skipping the collapse silently breaks any value with a double
|
|
563
|
+
// space in it -- e.g. a content-disposition filename.
|
|
564
|
+
const canonicalHeaders = names.map((h) => `${h}:${canonicalHeaderValue(headers[h])}\n`).join('');
|
|
565
|
+
const canonicalRequest = [
|
|
566
|
+
req.method,
|
|
567
|
+
canonicalUri,
|
|
568
|
+
cqs,
|
|
569
|
+
canonicalHeaders,
|
|
570
|
+
signedHeaders,
|
|
571
|
+
payloadHash,
|
|
572
|
+
].join('\n');
|
|
573
|
+
const scope = `${dateStamp}/${this.cfg.region}/s3/aws4_request`;
|
|
574
|
+
const signature = this.sign(dateStamp, amzDate, scope, canonicalRequest);
|
|
575
|
+
headers['authorization'] =
|
|
576
|
+
`AWS4-HMAC-SHA256 Credential=${this.cfg.accessKeyId}/${scope}, ` +
|
|
577
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
578
|
+
const target = cqs === '' ? `${url.origin}${canonicalUri}` : `${url.origin}${canonicalUri}?${cqs}`;
|
|
579
|
+
// `host` is set by the HTTP client from the URL and cannot be overridden in
|
|
580
|
+
// undici; it is in `headers` only so that it is signed. Sending it too is
|
|
581
|
+
// harmless where allowed and rejected where not, so it is dropped here and
|
|
582
|
+
// the signature is still computed over the value the client will send.
|
|
583
|
+
const { host: _host, ...wire } = headers;
|
|
584
|
+
return fetch(target, {
|
|
585
|
+
method: req.method,
|
|
586
|
+
headers: wire,
|
|
587
|
+
...(req.body !== undefined && req.method !== 'GET' && req.method !== 'HEAD'
|
|
588
|
+
? { body: req.body }
|
|
589
|
+
: {}),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// -----------------------------------------------------------------------------
|
|
594
|
+
// SigV4 primitives
|
|
595
|
+
// -----------------------------------------------------------------------------
|
|
596
|
+
function sha256Hex(data) {
|
|
597
|
+
return createHash('sha256').update(data).digest('hex');
|
|
598
|
+
}
|
|
599
|
+
function amzDateOf(d) {
|
|
600
|
+
return d.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* RFC 3986 unreserved-set encoding.
|
|
604
|
+
*
|
|
605
|
+
* `encodeURIComponent` leaves `!'()*` alone and `encodeURI` additionally leaves
|
|
606
|
+
* `#?&=+,:;@$` alone. The previous implementation used `encodeURI` on the whole
|
|
607
|
+
* key, which meant a key containing `#` truncated the URL at the fragment, a key
|
|
608
|
+
* containing `?` started a query string, and a key containing `+` signed one
|
|
609
|
+
* byte sequence and sent another. Keys are constructed by us today, but "the
|
|
610
|
+
* caller never puts a `#` in a key" is not a property the type system carries.
|
|
611
|
+
*/
|
|
612
|
+
export function rfc3986(s) {
|
|
613
|
+
return encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
614
|
+
}
|
|
615
|
+
/** Sorted by encoded key, then encoded value. Empty values keep their `=`. */
|
|
616
|
+
export function canonicalQueryString(query) {
|
|
617
|
+
return Object.entries(query)
|
|
618
|
+
.map(([k, v]) => [rfc3986(k), rfc3986(v)])
|
|
619
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
620
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
621
|
+
.join('&');
|
|
622
|
+
}
|
|
623
|
+
function canonicalHeaderValue(v) {
|
|
624
|
+
return v.trim().replace(/\s+/g, ' ');
|
|
625
|
+
}
|
|
626
|
+
// -----------------------------------------------------------------------------
|
|
627
|
+
// Small helpers
|
|
628
|
+
// -----------------------------------------------------------------------------
|
|
629
|
+
/**
|
|
630
|
+
* Read until `n` bytes are available or the stream ends.
|
|
631
|
+
*
|
|
632
|
+
* `done` means "the stream is finished AND this is everything that was left",
|
|
633
|
+
* which is what lets `putStream` decide between a one-shot PUT and multipart
|
|
634
|
+
* without a second read.
|
|
635
|
+
*/
|
|
636
|
+
async function readAtLeast(reader, n) {
|
|
637
|
+
const chunks = [];
|
|
638
|
+
let total = 0;
|
|
639
|
+
while (total < n) {
|
|
640
|
+
const { done, value } = await reader.read();
|
|
641
|
+
if (done)
|
|
642
|
+
return { chunk: concat(chunks, total), done: true };
|
|
643
|
+
if (!value || value.byteLength === 0)
|
|
644
|
+
continue;
|
|
645
|
+
chunks.push(value);
|
|
646
|
+
total += value.byteLength;
|
|
647
|
+
}
|
|
648
|
+
return { chunk: concat(chunks, total), done: false };
|
|
649
|
+
}
|
|
650
|
+
function concat(chunks, total) {
|
|
651
|
+
if (chunks.length === 1 && chunks[0].byteLength === total)
|
|
652
|
+
return chunks[0];
|
|
653
|
+
const out = new Uint8Array(total);
|
|
654
|
+
let at = 0;
|
|
655
|
+
for (const c of chunks) {
|
|
656
|
+
out.set(c, at);
|
|
657
|
+
at += c.byteLength;
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
function xmlTag(xml, tag) {
|
|
662
|
+
const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
|
|
663
|
+
return m ? (m[1] ?? null) : null;
|
|
664
|
+
}
|
|
665
|
+
function escapeXml(s) {
|
|
666
|
+
return s.replace(/[<>&'"]/g, (c) => c === '<' ? '<' : c === '>' ? '>' : c === '&' ? '&' : c === "'" ? ''' : '"');
|
|
667
|
+
}
|
|
668
|
+
function unescapeXml(s) {
|
|
669
|
+
return s
|
|
670
|
+
.replace(/</g, '<')
|
|
671
|
+
.replace(/>/g, '>')
|
|
672
|
+
.replace(/"/g, '"')
|
|
673
|
+
.replace(/'/g, "'")
|
|
674
|
+
.replace(/&/g, '&');
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Turn an S3 error response into an Error carrying the store's own error code.
|
|
678
|
+
*
|
|
679
|
+
* The status alone is not enough to act on: `AccessDenied`, `SignatureDoesNotMatch`
|
|
680
|
+
* and `InvalidAccessKeyId` are all 403 and mean three completely different
|
|
681
|
+
* operational problems (permissions / our bug / rotated key). Throwing the raw
|
|
682
|
+
* status is how a signing bug spends a week being investigated as an IAM policy.
|
|
683
|
+
*/
|
|
684
|
+
async function s3Error(op, res) {
|
|
685
|
+
let code = null;
|
|
686
|
+
let message = null;
|
|
687
|
+
try {
|
|
688
|
+
const text = await res.text();
|
|
689
|
+
code = xmlTag(text, 'Code');
|
|
690
|
+
message = xmlTag(text, 'Message');
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
/* body already consumed or not XML */
|
|
694
|
+
}
|
|
695
|
+
const err = new Error(`storage ${op} failed: ${res.status}${code ? ` ${code}` : ''}${message ? ` -- ${message}` : ''}`);
|
|
696
|
+
err.s3Code = code;
|
|
697
|
+
err.status = res.status;
|
|
698
|
+
return err;
|
|
699
|
+
}
|
|
700
|
+
//# sourceMappingURL=storage.js.map
|