@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/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;