@usehenri/s3 0.0.0 → 1.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 +61 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +36 -0
- package/package.json +42 -10
- package/src/client.js +552 -0
- package/src/errors.js +49 -0
- package/src/signature.js +407 -0
- package/src/storage.js +404 -0
package/src/storage.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@usehenri/s3`: the object store an application names when one machine is
|
|
3
|
+
* not enough.
|
|
4
|
+
*
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
* Why this is a package of its own
|
|
7
|
+
* ---------------------------------------------------------------------------
|
|
8
|
+
*
|
|
9
|
+
* `@usehenri/redis` is the precedent, and the shape is the same one:
|
|
10
|
+
* `config.shared` names `redis`, core resolves `@usehenri/redis` from the
|
|
11
|
+
* application, and an application that counts in this process installs
|
|
12
|
+
* nothing. Here `config.uploads.storage` names `s3`, `@usehenri/uploads`
|
|
13
|
+
* resolves `@usehenri/s3` from the application, and an application that keeps
|
|
14
|
+
* its files on the disk installs nothing.
|
|
15
|
+
*
|
|
16
|
+
* The alternative was a backend inside `@usehenri/uploads`, and it is worse
|
|
17
|
+
* for a reason that has nothing to do with taste: everyone who accepts a
|
|
18
|
+
* file installs that package, and most of them keep their files on one
|
|
19
|
+
* machine. A signature implementation, an HTTP client, a retry policy and a
|
|
20
|
+
* presigner would then be dead weight in every one of those installs, and
|
|
21
|
+
* every fix to any of them would be a release of the package that parses
|
|
22
|
+
* multipart bodies. The seam already existed for exactly this; using it is
|
|
23
|
+
* the point.
|
|
24
|
+
*
|
|
25
|
+
* ---------------------------------------------------------------------------
|
|
26
|
+
* One backend, four providers
|
|
27
|
+
* ---------------------------------------------------------------------------
|
|
28
|
+
*
|
|
29
|
+
* S3, R2, Spaces, MinIO and GCS's interoperability mode all speak the same
|
|
30
|
+
* API, and what tells them apart is an endpoint and a region -- not four
|
|
31
|
+
* backends:
|
|
32
|
+
*
|
|
33
|
+
* ```json
|
|
34
|
+
* {
|
|
35
|
+
* "uploads": {
|
|
36
|
+
* "storage": {
|
|
37
|
+
* "adapter": "s3",
|
|
38
|
+
* "bucket": "henri-uploads",
|
|
39
|
+
* "region": "auto",
|
|
40
|
+
* "endpoint": "https://<account>.r2.cloudflarestorage.com"
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* The credentials are read from `AWS_ACCESS_KEY_ID` and
|
|
47
|
+
* `AWS_SECRET_ACCESS_KEY` unless the block names them, because a key in a
|
|
48
|
+
* configuration file is a key in a repository.
|
|
49
|
+
*
|
|
50
|
+
* ---------------------------------------------------------------------------
|
|
51
|
+
* What is kept from the local disk, and why
|
|
52
|
+
* ---------------------------------------------------------------------------
|
|
53
|
+
*
|
|
54
|
+
* Every safety property of `@usehenri/uploads` is a property of the *key* and
|
|
55
|
+
* of the *parser*, not of the filesystem, so all of them survive the move:
|
|
56
|
+
*
|
|
57
|
+
* - the key is generated and the storage refuses any other shape -- `isKey()`
|
|
58
|
+
* is asked here before a request is built, exactly as `pathOf()` asks it
|
|
59
|
+
* before a path is;
|
|
60
|
+
* - the type comes from the bytes, which happened before this ever saw the
|
|
61
|
+
* file, and it is what the object's `Content-Type` is set to (so a
|
|
62
|
+
* presigned url hands back the type henri decided on, not one a client
|
|
63
|
+
* claimed);
|
|
64
|
+
* - the original name is metadata, and it stays metadata -- it goes in
|
|
65
|
+
* `x-amz-meta-name`, never in the key;
|
|
66
|
+
* - nothing is kept unless a controller called `store()`, because a part is
|
|
67
|
+
* streamed to a local temporary file first and only promoted then.
|
|
68
|
+
*
|
|
69
|
+
* `temp()` is a local directory, which is what the contract exists for: a
|
|
70
|
+
* part has to land somewhere before anything has authorized keeping it, and
|
|
71
|
+
* that somewhere cannot be the object store -- an upload that was refused
|
|
72
|
+
* would already have been paid for and would still have to be deleted. So
|
|
73
|
+
* the temporary area is `LocalStorage`, reused rather than reimplemented,
|
|
74
|
+
* which brings its `0700`/`0600` modes, its `.gitignore` and its sweep of
|
|
75
|
+
* what a killed process left behind.
|
|
76
|
+
*/
|
|
77
|
+
const crypto = require('node:crypto');
|
|
78
|
+
const fs = require('node:fs');
|
|
79
|
+
const fsp = require('node:fs/promises');
|
|
80
|
+
const debug = require('debug')('henri:s3');
|
|
81
|
+
|
|
82
|
+
const {
|
|
83
|
+
LocalStorage,
|
|
84
|
+
contentDisposition,
|
|
85
|
+
isKey,
|
|
86
|
+
} = require('@usehenri/uploads');
|
|
87
|
+
|
|
88
|
+
const { S3Client } = require('./client');
|
|
89
|
+
const { coded } = require('./errors');
|
|
90
|
+
|
|
91
|
+
/** How long a presigned url lasts when nobody said */
|
|
92
|
+
const EXPIRES_IN = 300;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The credentials, from the block or from the environment.
|
|
96
|
+
*
|
|
97
|
+
* The environment first in the documentation and second in the code: an
|
|
98
|
+
* application that wrote them down means it, and the reason to read the
|
|
99
|
+
* environment is that most applications should not write them down at all.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} options the storage block
|
|
102
|
+
* @param {object} [env=process.env] the environment
|
|
103
|
+
* @returns {object} the block, with credentials
|
|
104
|
+
*/
|
|
105
|
+
const withEnvironment = (options, env = process.env) =>
|
|
106
|
+
Object.assign({}, options, {
|
|
107
|
+
accessKeyId: options.accessKeyId || env.AWS_ACCESS_KEY_ID || '',
|
|
108
|
+
secretAccessKey: options.secretAccessKey || env.AWS_SECRET_ACCESS_KEY || '',
|
|
109
|
+
sessionToken: options.sessionToken || env.AWS_SESSION_TOKEN || null,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The sha256 of a local file, which is what `x-amz-content-sha256` is
|
|
114
|
+
*
|
|
115
|
+
* @param {string} file the path
|
|
116
|
+
* @returns {Promise<string>} the digest, hex
|
|
117
|
+
*/
|
|
118
|
+
async function digest(file) {
|
|
119
|
+
const hash = crypto.createHash('sha256');
|
|
120
|
+
|
|
121
|
+
for await (const chunk of fs.createReadStream(file)) {
|
|
122
|
+
hash.update(chunk);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return hash.digest('hex');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An object store, over the S3 API
|
|
130
|
+
*
|
|
131
|
+
* @class S3Storage
|
|
132
|
+
* @implements {HenriStorage}
|
|
133
|
+
*/
|
|
134
|
+
class S3Storage {
|
|
135
|
+
/**
|
|
136
|
+
* Creates an instance of S3Storage.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} name The storage name
|
|
139
|
+
* @param {object} [config={}] `{ options, root }` from `createStorage()`
|
|
140
|
+
* @param {object} [henri=null] The henri instance
|
|
141
|
+
* @memberof S3Storage
|
|
142
|
+
*/
|
|
143
|
+
constructor(name, config = {}, henri = null) {
|
|
144
|
+
const options = config.options || config || {};
|
|
145
|
+
|
|
146
|
+
this.name = name || 's3';
|
|
147
|
+
this.henri = henri;
|
|
148
|
+
this.options = options;
|
|
149
|
+
this.client = new S3Client(withEnvironment(options));
|
|
150
|
+
this.expiresIn =
|
|
151
|
+
Number(options.expiresIn) > 0 ? Number(options.expiresIn) : EXPIRES_IN;
|
|
152
|
+
|
|
153
|
+
// The temporary area is local, and it is the local storage: same modes,
|
|
154
|
+
// same .gitignore, same sweep of the parts a killed process left behind
|
|
155
|
+
this.local = new LocalStorage(
|
|
156
|
+
this.name,
|
|
157
|
+
{ root: options.tmp || config.root || 'storage/uploads' },
|
|
158
|
+
henri
|
|
159
|
+
);
|
|
160
|
+
this.started = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Checks the configuration, prepares the temporary area and says whether
|
|
165
|
+
* the bucket answers.
|
|
166
|
+
*
|
|
167
|
+
* A configuration that cannot be right fails the boot; a bucket that did
|
|
168
|
+
* not answer is a warning, the way a store that did not connect is. The
|
|
169
|
+
* first is a mistake in the application, the second is the network, and
|
|
170
|
+
* only one of them is fixed by refusing to start.
|
|
171
|
+
*
|
|
172
|
+
* @async
|
|
173
|
+
* @returns {Promise<string>} the bucket
|
|
174
|
+
* @throws when the configuration is unusable
|
|
175
|
+
* @memberof S3Storage
|
|
176
|
+
*/
|
|
177
|
+
async start() {
|
|
178
|
+
this.client.check();
|
|
179
|
+
await this.local.start();
|
|
180
|
+
|
|
181
|
+
const failure = await this.ping();
|
|
182
|
+
|
|
183
|
+
if (failure && this.henri && this.henri.pen) {
|
|
184
|
+
this.henri.pen.warn(
|
|
185
|
+
'uploads',
|
|
186
|
+
`${this.client.bucket} did not answer`,
|
|
187
|
+
failure.message
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.started = true;
|
|
192
|
+
|
|
193
|
+
return this.client.bucket;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Asks the bucket whether it is there
|
|
198
|
+
*
|
|
199
|
+
* @async
|
|
200
|
+
* @returns {Promise<?Error>} what went wrong, or null
|
|
201
|
+
* @memberof S3Storage
|
|
202
|
+
*/
|
|
203
|
+
async ping() {
|
|
204
|
+
try {
|
|
205
|
+
// `HEAD` on the bucket itself, which is the one request that answers
|
|
206
|
+
// all three questions at once: the endpoint resolves, the credentials
|
|
207
|
+
// sign something the store accepts, and the bucket is there
|
|
208
|
+
const found = await this.client.stat('');
|
|
209
|
+
|
|
210
|
+
return found
|
|
211
|
+
? null
|
|
212
|
+
: coded(
|
|
213
|
+
'HENRI_UPLOAD_STORAGE_MISCONFIGURED',
|
|
214
|
+
`there is no bucket named ${this.client.bucket} at ${this.client.host}, or these credentials cannot see it`
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
debug('the bucket did not answer: %s', error.message);
|
|
218
|
+
|
|
219
|
+
return error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Releases the temporary area
|
|
225
|
+
*
|
|
226
|
+
* @async
|
|
227
|
+
* @returns {Promise<boolean>} true
|
|
228
|
+
* @memberof S3Storage
|
|
229
|
+
*/
|
|
230
|
+
async stop() {
|
|
231
|
+
this.started = false;
|
|
232
|
+
|
|
233
|
+
return this.local.stop();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A private local file to stream a part into
|
|
238
|
+
*
|
|
239
|
+
* @async
|
|
240
|
+
* @returns {Promise<{path: string}>} where to write
|
|
241
|
+
* @memberof S3Storage
|
|
242
|
+
*/
|
|
243
|
+
async temp() {
|
|
244
|
+
return this.local.temp();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The key, or a readable refusal.
|
|
249
|
+
*
|
|
250
|
+
* The same two words the local disk says, for the same reason: a key henri
|
|
251
|
+
* did not generate is a key an application built out of something a client
|
|
252
|
+
* sent, and there is no version of that worth making a request for.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} key the key
|
|
255
|
+
* @returns {string} the key
|
|
256
|
+
* @throws when the key is not one henri could have generated
|
|
257
|
+
* @memberof S3Storage
|
|
258
|
+
*/
|
|
259
|
+
keyOf(key) {
|
|
260
|
+
if (!isKey(key)) {
|
|
261
|
+
throw coded(
|
|
262
|
+
'HENRI_UPLOAD_STORAGE_FAILED',
|
|
263
|
+
`unsafe storage key: ${JSON.stringify(String(key))}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return key;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Uploads a part under its key and removes the part
|
|
272
|
+
*
|
|
273
|
+
* @async
|
|
274
|
+
* @param {string} source the temporary file
|
|
275
|
+
* @param {string} key the key to store it under
|
|
276
|
+
* @param {object} [meta={}] `{ checksum, name, size, type }`, what the
|
|
277
|
+
* parser already knows about the file
|
|
278
|
+
* @returns {Promise<string>} the key
|
|
279
|
+
* @throws when the key is unsafe or the store refused
|
|
280
|
+
* @memberof S3Storage
|
|
281
|
+
*/
|
|
282
|
+
async put(source, key, meta = {}) {
|
|
283
|
+
this.keyOf(key);
|
|
284
|
+
|
|
285
|
+
const { size } = await fsp.stat(source);
|
|
286
|
+
// The parser hashed the bytes on their way to the disk, and that digest
|
|
287
|
+
// is exactly what `x-amz-content-sha256` wants: the file is read twice
|
|
288
|
+
// only when it was handed over without one
|
|
289
|
+
const checksum = /^[0-9a-f]{64}$/u.test(String(meta.checksum))
|
|
290
|
+
? meta.checksum
|
|
291
|
+
: await digest(source);
|
|
292
|
+
|
|
293
|
+
await this.client.put(key, {
|
|
294
|
+
checksum,
|
|
295
|
+
file: source,
|
|
296
|
+
length: size,
|
|
297
|
+
name: meta.name || null,
|
|
298
|
+
type: meta.type || null,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await fsp.unlink(source).catch(() => {});
|
|
302
|
+
|
|
303
|
+
return key;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A readable stream of a stored object
|
|
308
|
+
*
|
|
309
|
+
* @async
|
|
310
|
+
* @param {string} key the key
|
|
311
|
+
* @returns {Promise<stream.Readable>} the stream
|
|
312
|
+
* @throws when the key is unsafe or there is no such object
|
|
313
|
+
* @memberof S3Storage
|
|
314
|
+
*/
|
|
315
|
+
async get(key) {
|
|
316
|
+
return this.client.get(this.keyOf(key));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* What is known about a stored object
|
|
321
|
+
*
|
|
322
|
+
* @async
|
|
323
|
+
* @param {string} key the key
|
|
324
|
+
* @returns {Promise<?{size: number, modifiedAt: Date}>} the facts, or null
|
|
325
|
+
* @memberof S3Storage
|
|
326
|
+
*/
|
|
327
|
+
async stat(key) {
|
|
328
|
+
try {
|
|
329
|
+
return await this.client.stat(this.keyOf(key));
|
|
330
|
+
} catch (error) {
|
|
331
|
+
debug('unable to stat %s: %s', key, error.message);
|
|
332
|
+
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Removes a stored object
|
|
339
|
+
*
|
|
340
|
+
* @async
|
|
341
|
+
* @param {string} key the key
|
|
342
|
+
* @returns {Promise<boolean>} true when something was removed
|
|
343
|
+
* @memberof S3Storage
|
|
344
|
+
*/
|
|
345
|
+
async delete(key) {
|
|
346
|
+
try {
|
|
347
|
+
return await this.client.delete(this.keyOf(key));
|
|
348
|
+
} catch (error) {
|
|
349
|
+
debug('unable to delete %s: %s', key, error.message);
|
|
350
|
+
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A time-limited url that hands the object to the client directly.
|
|
357
|
+
*
|
|
358
|
+
* The provider's own signature, not henri's: it covers the method, the
|
|
359
|
+
* host, the key and every query parameter -- the expiry among them -- so a
|
|
360
|
+
* url cannot be edited to name another object or to last longer, and the
|
|
361
|
+
* store refuses it once `X-Amz-Expires` seconds have passed since
|
|
362
|
+
* `X-Amz-Date`.
|
|
363
|
+
*
|
|
364
|
+
* `disposition`, `filename` and `type` are signed too, as
|
|
365
|
+
* `response-content-*` overrides, which is what keeps a link to an
|
|
366
|
+
* uploaded file a download rather than a page: the store answers with the
|
|
367
|
+
* header the signature named, and a client that edits it has a url the
|
|
368
|
+
* store refuses.
|
|
369
|
+
*
|
|
370
|
+
* @param {string} key the key
|
|
371
|
+
* @param {object} [options={}] `{ expiresIn, disposition, filename, type, now }`
|
|
372
|
+
* @returns {string} the url
|
|
373
|
+
* @throws when the key is unsafe or the window is one S3 refuses
|
|
374
|
+
* @memberof S3Storage
|
|
375
|
+
*/
|
|
376
|
+
url(key, options = {}) {
|
|
377
|
+
this.keyOf(key);
|
|
378
|
+
|
|
379
|
+
const query = {};
|
|
380
|
+
|
|
381
|
+
if (options.disposition) {
|
|
382
|
+
query['response-content-disposition'] = contentDisposition(
|
|
383
|
+
options.filename || 'file',
|
|
384
|
+
options.disposition
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (options.type) {
|
|
389
|
+
query['response-content-type'] = options.type;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return this.client.url(key, {
|
|
393
|
+
expiresIn: options.expiresIn || this.expiresIn,
|
|
394
|
+
now: options.now,
|
|
395
|
+
query,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = S3Storage;
|
|
401
|
+
module.exports.EXPIRES_IN = EXPIRES_IN;
|
|
402
|
+
module.exports.S3Storage = S3Storage;
|
|
403
|
+
module.exports.digest = digest;
|
|
404
|
+
module.exports.withEnvironment = withEnvironment;
|