@usehenri/uploads 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 +117 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +42 -0
- package/module.js +8 -0
- package/package.json +56 -10
- package/src/bytes.js +86 -0
- package/src/config.js +243 -0
- package/src/download.js +126 -0
- package/src/errors.js +92 -0
- package/src/file.js +204 -0
- package/src/module.js +694 -0
- package/src/multipart.js +696 -0
- package/src/names.js +191 -0
- package/src/signing.js +333 -0
- package/src/sniff.js +292 -0
- package/src/storage/index.js +125 -0
- package/src/storage/local.js +345 -0
- package/src/variants.js +392 -0
package/src/module.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
const BaseModule = require('@usehenri/core/module');
|
|
2
|
+
|
|
3
|
+
const debug = require('debug')('henri:uploads');
|
|
4
|
+
|
|
5
|
+
const { DEFAULTS, settings: settingsOf } = require('./config');
|
|
6
|
+
const { UploadedFile } = require('./file');
|
|
7
|
+
const { UrlSigner } = require('./signing');
|
|
8
|
+
const { coded } = require('./errors');
|
|
9
|
+
const { contentDisposition } = require('./names');
|
|
10
|
+
const { createStorage } = require('./storage');
|
|
11
|
+
const { downloads } = require('./download');
|
|
12
|
+
const { format } = require('./bytes');
|
|
13
|
+
const { middleware } = require('./multipart');
|
|
14
|
+
const {
|
|
15
|
+
FORMATS,
|
|
16
|
+
SOURCES,
|
|
17
|
+
keyFor: variantKeyFor,
|
|
18
|
+
produce,
|
|
19
|
+
} = require('./variants');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A key of the shape henri generates that names no object, for asking a
|
|
23
|
+
* storage whether it signs its own urls without naming anybody's file
|
|
24
|
+
*/
|
|
25
|
+
const PROBE = `${'0'.repeat(32)}.bin`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* File uploads: the henri module this package ships.
|
|
29
|
+
*
|
|
30
|
+
* ---------------------------------------------------------------------------
|
|
31
|
+
* The design, and why it is this one
|
|
32
|
+
* ---------------------------------------------------------------------------
|
|
33
|
+
*
|
|
34
|
+
* **A package, not core, and not core behind a key.** An upload needs a
|
|
35
|
+
* multipart parser, and a multipart parser is a dependency of every
|
|
36
|
+
* application that installs the framework -- including the many that will
|
|
37
|
+
* never accept a file. henri already has the shape for this: `@usehenri/jobs`
|
|
38
|
+
* carries the queue, `@usehenri/graphql` carries Apollo, a store adapter is
|
|
39
|
+
* resolved from the application rather than bundled, and a package says it
|
|
40
|
+
* ships a module with `"henri": { "module": "./module.js" }` in its own
|
|
41
|
+
* package.json. So busboy is a dependency of this package and of nothing
|
|
42
|
+
* else, and an application accepts files by installing it. The one thing
|
|
43
|
+
* that does live in core is the `uploads` *key* of the configuration schema,
|
|
44
|
+
* validated at boot whether or not the package is there -- exactly as
|
|
45
|
+
* `graphql` is, so that a typo is a boot error rather than a silence.
|
|
46
|
+
*
|
|
47
|
+
* **busboy.** It is what the ecosystem sits on (multer is a thin Express
|
|
48
|
+
* wrapper around it, and so are most of the others), it is a streaming
|
|
49
|
+
* parser with no opinion about the filesystem, and its limits are enforced
|
|
50
|
+
* by the state machine as it reads rather than by a check afterwards, which
|
|
51
|
+
* is the property this whole feature is built on. formidable writes files
|
|
52
|
+
* itself, with its own naming and its own temporary directory, which is
|
|
53
|
+
* precisely the part henri wants to own; multer would mean wrapping busboy
|
|
54
|
+
* in something that then has to be un-wrapped to control where the bytes go.
|
|
55
|
+
*
|
|
56
|
+
* **Where it sits in the request.** Runlevel 3, `before: ['user', 'router']`.
|
|
57
|
+
* It has to be before the user module, because the `_csrf` field of a
|
|
58
|
+
* `multipart/form-data` form is *inside the body*, and the CSRF middleware
|
|
59
|
+
* reads `req.body`: parse later and no plain HTML upload form can ever pass
|
|
60
|
+
* the token check. The consequence is that the parser is the first thing an
|
|
61
|
+
* unauthenticated request meets, which is why every limit is enforced before
|
|
62
|
+
* a byte is read, why `paths` exists to narrow the surface further, and why
|
|
63
|
+
* nothing is ever kept unless a controller says so.
|
|
64
|
+
*
|
|
65
|
+
* **The limits, and how they relate to `bodyLimit`.** `config.bodyLimit`
|
|
66
|
+
* (1mb) is what `express.json()` and `express.urlencoded()` accept for a
|
|
67
|
+
* whole body; it does not apply to `multipart/form-data`, which those
|
|
68
|
+
* parsers never look at. The multipart equivalents are `maxTotalSize` for
|
|
69
|
+
* the whole body (25mb), `maxFileSize` for one file (10mb), `maxFiles` (10),
|
|
70
|
+
* `maxFields` (100), `maxFieldNameSize` (100 bytes) and `maxFieldSize`,
|
|
71
|
+
* which defaults to `config.bodyLimit` itself so that one text field of a
|
|
72
|
+
* form costs the same whichever encoding the form was posted with. Every one
|
|
73
|
+
* of them reaches busboy as a limit; the total is also counted as the bytes
|
|
74
|
+
* arrive, because `Content-Length` is absent from a chunked request and
|
|
75
|
+
* optional in the honesty of any other.
|
|
76
|
+
*
|
|
77
|
+
* **The type is the bytes.** A part's `Content-Type` and its filename are
|
|
78
|
+
* written by whoever is uploading. henri reads the first 4kb instead and
|
|
79
|
+
* matches a signature table (`sniff.js`); what it recognizes is the type,
|
|
80
|
+
* what it does not is `application/octet-stream`, and the client's claim is
|
|
81
|
+
* kept as `declaredType` for the record and used for nothing. It does not
|
|
82
|
+
* guess a type from an extension, ever, and it does not open archives: a
|
|
83
|
+
* `.docx` is `application/zip`, which is what it is. `allow` matches the
|
|
84
|
+
* type henri decided on, so `allow: ['image/png']` cannot be satisfied by
|
|
85
|
+
* naming a zip `avatar.png`.
|
|
86
|
+
*
|
|
87
|
+
* **The name never reaches the filesystem.** The stored name is generated:
|
|
88
|
+
* `<yyyy>/<mm>/<32 hex characters>.<extension from the sniffed type>`. The
|
|
89
|
+
* original is cleaned and kept as metadata, for the record and for the
|
|
90
|
+
* `Content-Disposition` of a download. That is one answer to a long list of
|
|
91
|
+
* problems that are really one problem -- `../../etc/passwd`, `/etc/passwd`,
|
|
92
|
+
* `C:\boot.ini`, a NUL byte, `CON`, `.htaccess`, `avatar.php`, four thousand
|
|
93
|
+
* characters -- because none of them are consulted when a path is built.
|
|
94
|
+
*
|
|
95
|
+
* **Where the files go.** `storage/uploads` in the application, outside
|
|
96
|
+
* `app/views/public` (which `express.static` serves) and outside `.henri`
|
|
97
|
+
* (which `henri clean` removes). The directory is created `0700`, every
|
|
98
|
+
* object `0600`, and a `.gitignore` is written into it the first time so
|
|
99
|
+
* that uploads never reach a commit. Nothing is ever served from it: a file
|
|
100
|
+
* is handed back by `henri.uploads.send()`, which streams it with
|
|
101
|
+
* `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` and
|
|
102
|
+
* the type henri recognized -- through a controller, which is where the
|
|
103
|
+
* decision about who may read it belongs. The two scriptable types henri
|
|
104
|
+
* recognizes, `text/html` and `image/svg+xml`, are stored under a `.bin`
|
|
105
|
+
* extension as well, so that a web server misconfigured to serve the
|
|
106
|
+
* directory still has nothing there it would render.
|
|
107
|
+
*
|
|
108
|
+
* **The storage seam.** `HenriStorage` (documented at the top of
|
|
109
|
+
* `src/storage/local.js`) is to uploads what `HenriAdapter` is to the
|
|
110
|
+
* stores: `start`, `stop`, `temp`, `put`, `get`, `stat`, `delete`, `url`.
|
|
111
|
+
* The local disk is one implementation and ships; an object store is
|
|
112
|
+
* another, `@usehenri/s3`, which `config.uploads.storage` names and which
|
|
113
|
+
* the application installs -- so a signature implementation and an HTTP
|
|
114
|
+
* client are not in the install of everyone who accepts a file.
|
|
115
|
+
* `temp()` is part of the contract because only the storage knows where a
|
|
116
|
+
* part should land so that keeping it is cheap -- a rename on the same
|
|
117
|
+
* filesystem, locally.
|
|
118
|
+
*
|
|
119
|
+
* **Signed urls.** `url()` used to be allowed to answer `null` forever, and
|
|
120
|
+
* on the local disk it did. That is a hole rather than a design: an
|
|
121
|
+
* application that wanted a link had to write a controller, a route and an
|
|
122
|
+
* authorization check for every file it showed, and the framework said
|
|
123
|
+
* nothing about how. `henri.uploads.url(record)` is now one call whatever
|
|
124
|
+
* the backend: an object store presigns it (the provider's own signature),
|
|
125
|
+
* and the local disk gets henri's own -- an HMAC over the key, the expiry,
|
|
126
|
+
* the disposition, the name and the type, verified by a route this module
|
|
127
|
+
* mounts (`src/signing.js`, `src/download.js`). Both are off until
|
|
128
|
+
* `config.uploads.urls` says otherwise, because a signed url is a bearer
|
|
129
|
+
* capability and that is a decision, not a default.
|
|
130
|
+
*
|
|
131
|
+
* **Variants.** A derived file is a file with a key, so the storage seam was
|
|
132
|
+
* already the right shape for one: `variant(record, 'thumb')` answers a
|
|
133
|
+
* record like any other, and `send()`, `url()` and `delete()` take it
|
|
134
|
+
* unchanged. The key is the source's plus a digest of the variant's *terms*,
|
|
135
|
+
* so the work happens once, on demand, and every caller after that reads a
|
|
136
|
+
* stored object -- never in the request that uploaded, which would make
|
|
137
|
+
* every upload pay for every variant nobody looked at. `sharp` is an
|
|
138
|
+
* optional peer dependency the application installs: a native addon is not
|
|
139
|
+
* something to acquire by accepting a PDF, and without it `variant()`
|
|
140
|
+
* refuses with the install line rather than quietly answering the original.
|
|
141
|
+
* The reasoning is in `src/variants.js`.
|
|
142
|
+
*
|
|
143
|
+
* **Nothing is kept by default.** A parsed file lives in the storage's
|
|
144
|
+
* temporary area until a controller calls `store()`. Everything else is
|
|
145
|
+
* removed when the response closes -- answered, refused, timed out or
|
|
146
|
+
* abandoned, which is the whole list of ways a request ends -- and
|
|
147
|
+
* `permitFiles()` removes what a controller did not ask for immediately
|
|
148
|
+
* rather than at the end. A `SIGKILL` is the one case a request cannot clean
|
|
149
|
+
* up after, so the storage sweeps its temporary area at boot.
|
|
150
|
+
*
|
|
151
|
+
* ---------------------------------------------------------------------------
|
|
152
|
+
* What an application sees
|
|
153
|
+
* ---------------------------------------------------------------------------
|
|
154
|
+
*
|
|
155
|
+
* ```js
|
|
156
|
+
* // app/controllers/artworks.js
|
|
157
|
+
* async create(req, res) {
|
|
158
|
+
* const data = req.permit('title', 'year');
|
|
159
|
+
* const { scan } = req.permitFiles('scan');
|
|
160
|
+
*
|
|
161
|
+
* if (scan) {
|
|
162
|
+
* data.scan = await scan[0].store({ prefix: 'artworks' });
|
|
163
|
+
* }
|
|
164
|
+
*
|
|
165
|
+
* const artwork = await Artwork.create(data);
|
|
166
|
+
*
|
|
167
|
+
* return res.resource(artwork);
|
|
168
|
+
* }
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* `req.files` is `{ [field]: UploadedFile[] }`, `req.file('scan')` is the
|
|
172
|
+
* first one of a field, and `req.permitFiles(...)` is `req.permit(...)` for
|
|
173
|
+
* files. `store()` resolves with the record to write to a model:
|
|
174
|
+
* `{ checksum, key, name, size, storage, type, uploadedAt }`.
|
|
175
|
+
*
|
|
176
|
+
* @class UploadsModule
|
|
177
|
+
* @extends {BaseModule}
|
|
178
|
+
*/
|
|
179
|
+
class UploadsModule extends BaseModule {
|
|
180
|
+
/**
|
|
181
|
+
* Creates an instance of UploadsModule.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} [henri=null] A henri instance
|
|
184
|
+
* @memberof UploadsModule
|
|
185
|
+
*/
|
|
186
|
+
constructor(henri = null) {
|
|
187
|
+
super();
|
|
188
|
+
|
|
189
|
+
this.reloadable = true;
|
|
190
|
+
this.needs = ['config', 'server'];
|
|
191
|
+
this.before = ['user', 'router'];
|
|
192
|
+
this.runlevel = 3;
|
|
193
|
+
this.name = 'uploads';
|
|
194
|
+
this.henri = henri;
|
|
195
|
+
|
|
196
|
+
this.enabled = false;
|
|
197
|
+
this.settings = null;
|
|
198
|
+
this.storage = null;
|
|
199
|
+
this.signer = null;
|
|
200
|
+
|
|
201
|
+
this._mounted = false;
|
|
202
|
+
// One promise per derived key while the work runs, so a hundred
|
|
203
|
+
// concurrent misses in this process derive the variant once
|
|
204
|
+
this._deriving = new Map();
|
|
205
|
+
|
|
206
|
+
this.init = this.init.bind(this);
|
|
207
|
+
this.reload = this.reload.bind(this);
|
|
208
|
+
this.stop = this.stop.bind(this);
|
|
209
|
+
this.send = this.send.bind(this);
|
|
210
|
+
this.url = this.url.bind(this);
|
|
211
|
+
this.variant = this.variant.bind(this);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Module initialization
|
|
216
|
+
* Called after being loaded by Modules
|
|
217
|
+
*
|
|
218
|
+
* @async
|
|
219
|
+
* @returns {Promise<string>} The name of the module
|
|
220
|
+
* @throws when the storage cannot be prepared
|
|
221
|
+
* @memberof UploadsModule
|
|
222
|
+
*/
|
|
223
|
+
async init() {
|
|
224
|
+
const { pen, server } = this.henri;
|
|
225
|
+
|
|
226
|
+
this.settings = settingsOf(this.henri.config);
|
|
227
|
+
|
|
228
|
+
// Mounted whether or not uploads are on: `req.files`, `req.file()` and
|
|
229
|
+
// `req.permitFiles()` exist on every request the way `req.permit()` does,
|
|
230
|
+
// and a reload that turns uploads on finds the middleware already in
|
|
231
|
+
// place -- an express app cannot be given one later, only more of them.
|
|
232
|
+
this.mount(server);
|
|
233
|
+
|
|
234
|
+
if (!this.settings.enabled) {
|
|
235
|
+
pen.info('uploads', 'disabled by configuration');
|
|
236
|
+
|
|
237
|
+
return this.name;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.storage = createStorage(this.henri, this.settings);
|
|
241
|
+
this.signer = this.signerOf();
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await this.storage.start();
|
|
245
|
+
} catch (error) {
|
|
246
|
+
pen.error('uploads', 'unable to prepare the storage', error.message);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.enabled = true;
|
|
251
|
+
|
|
252
|
+
const { maxFiles, maxFileSize, maxTotalSize } = this.settings;
|
|
253
|
+
|
|
254
|
+
pen.info(
|
|
255
|
+
'uploads',
|
|
256
|
+
`${this.storage.name} storage`,
|
|
257
|
+
`${format(maxFileSize)} per file, ${format(maxTotalSize)} per request, ${
|
|
258
|
+
maxFiles === false ? 'any number of' : maxFiles
|
|
259
|
+
} files`
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (this.settings.allow) {
|
|
263
|
+
pen.info('uploads', 'accepted types', this.settings.allow.join(', '));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (this.settings.urls) {
|
|
267
|
+
pen.info(
|
|
268
|
+
'uploads',
|
|
269
|
+
'signed urls',
|
|
270
|
+
`${this.settings.urls.expiresIn}s, ${
|
|
271
|
+
this.signs()
|
|
272
|
+
? `signed by ${this.storage.name}`
|
|
273
|
+
: `verified at ${this.settings.urls.path}`
|
|
274
|
+
}`
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (!this.signs() && !this.signer.usable) {
|
|
278
|
+
pen.warn(
|
|
279
|
+
'uploads',
|
|
280
|
+
'signed urls are on, but this application has no secret',
|
|
281
|
+
'henri.uploads.url() will refuse: set HENRI_SECRET'
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (this.settings.variants) {
|
|
287
|
+
pen.info(
|
|
288
|
+
'uploads',
|
|
289
|
+
'variants',
|
|
290
|
+
`${Object.keys(this.settings.variants).join(', ')} (derived once, on demand; needs sharp)`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!this.settings.sniff) {
|
|
295
|
+
pen.warn(
|
|
296
|
+
'uploads',
|
|
297
|
+
'content sniffing is off: the type of a file is whatever the client says it is'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return this.name;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Mounts the parser, once: a reload changes what it reads, never where it
|
|
306
|
+
* sits in the chain (an express app has no way to remove a middleware, and
|
|
307
|
+
* the position is the whole point -- before sessions and CSRF)
|
|
308
|
+
*
|
|
309
|
+
* @param {object} server the server module
|
|
310
|
+
* @returns {boolean} whether it was mounted by this call
|
|
311
|
+
* @memberof UploadsModule
|
|
312
|
+
*/
|
|
313
|
+
mount(server) {
|
|
314
|
+
if (this._mounted || !server || !server.app) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
server.app.use(middleware(this));
|
|
319
|
+
// After the parser, because the parser's position is what cannot move.
|
|
320
|
+
// Both are mounted whether or not anything is on: what a reload changes
|
|
321
|
+
// is what they do, never whether they are there
|
|
322
|
+
server.app.use(downloads(this));
|
|
323
|
+
|
|
324
|
+
this._mounted = true;
|
|
325
|
+
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The signer of henri's own urls, built from `config.secret`
|
|
331
|
+
*
|
|
332
|
+
* @returns {UrlSigner} the signer
|
|
333
|
+
* @memberof UploadsModule
|
|
334
|
+
*/
|
|
335
|
+
signerOf() {
|
|
336
|
+
const urls = this.settings.urls || {};
|
|
337
|
+
|
|
338
|
+
return new UrlSigner({
|
|
339
|
+
cdn: urls.cdn,
|
|
340
|
+
expiresIn: urls.expiresIn,
|
|
341
|
+
path: urls.path,
|
|
342
|
+
secret: this.henri.config.get('secret'),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Does the storage sign its own urls?
|
|
348
|
+
*
|
|
349
|
+
* Asked rather than declared: the contract's `url()` answers null when
|
|
350
|
+
* there is no such thing, so the answer is what it answers. The probe is a
|
|
351
|
+
* key of the shape henri generates that names no object, because both
|
|
352
|
+
* backends refuse anything else before they look at whether it is there.
|
|
353
|
+
*
|
|
354
|
+
* @returns {boolean} true when the storage has urls of its own
|
|
355
|
+
* @memberof UploadsModule
|
|
356
|
+
*/
|
|
357
|
+
signs() {
|
|
358
|
+
if (!this.storage || typeof this.storage.url !== 'function') {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
return typeof this.storage.url(PROBE, { expiresIn: 60 }) === 'string';
|
|
364
|
+
} catch (error) {
|
|
365
|
+
debug('%s signs no url: %s', this.storage.name, error.message);
|
|
366
|
+
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Re-reads the configuration and the storage
|
|
373
|
+
*
|
|
374
|
+
* @async
|
|
375
|
+
* @returns {Promise<string>} The name of the module
|
|
376
|
+
* @memberof UploadsModule
|
|
377
|
+
*/
|
|
378
|
+
async reload() {
|
|
379
|
+
const previous = this.storage;
|
|
380
|
+
|
|
381
|
+
this.settings = settingsOf(this.henri.config);
|
|
382
|
+
this.signer = this.signerOf();
|
|
383
|
+
this.mount(this.henri.server);
|
|
384
|
+
|
|
385
|
+
if (!this.settings.enabled) {
|
|
386
|
+
this.enabled = false;
|
|
387
|
+
previous && (await previous.stop());
|
|
388
|
+
this.storage = null;
|
|
389
|
+
|
|
390
|
+
return this.name;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.storage = createStorage(this.henri, this.settings);
|
|
394
|
+
await this.storage.start();
|
|
395
|
+
this.enabled = true;
|
|
396
|
+
|
|
397
|
+
if (previous && previous !== this.storage) {
|
|
398
|
+
await previous.stop();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return this.name;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Hands a stored file back to a client.
|
|
406
|
+
*
|
|
407
|
+
* A download, not a page: `Content-Disposition: attachment` and
|
|
408
|
+
* `X-Content-Type-Options: nosniff`, so nothing an application stored is
|
|
409
|
+
* ever rendered on its own origin. `{ disposition: 'inline' }` is there for
|
|
410
|
+
* the types an application knows it can trust -- an image it generated, a
|
|
411
|
+
* PDF it wrote -- and is never the default.
|
|
412
|
+
*
|
|
413
|
+
* @async
|
|
414
|
+
* @param {Express.Response} res the response
|
|
415
|
+
* @param {object} record what `store()` returned, as read back from a model
|
|
416
|
+
* @param {object} [options={}] `{ disposition, maxAge }`
|
|
417
|
+
* @returns {Promise<Express.Response>} the response
|
|
418
|
+
* @throws when the record names no key
|
|
419
|
+
* @memberof UploadsModule
|
|
420
|
+
*/
|
|
421
|
+
async send(res, record, options = {}) {
|
|
422
|
+
const { disposition = 'attachment', maxAge = 0 } = options;
|
|
423
|
+
const file = typeof record === 'string' ? { key: record } : record || {};
|
|
424
|
+
|
|
425
|
+
if (!file.key) {
|
|
426
|
+
throw new Error('henri.uploads.send() needs the record store() returned');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const stream = await this.ready().get(file.key);
|
|
430
|
+
|
|
431
|
+
res.set('Content-Type', file.type || 'application/octet-stream');
|
|
432
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
433
|
+
res.set(
|
|
434
|
+
'Content-Disposition',
|
|
435
|
+
contentDisposition(file.name || 'file', disposition)
|
|
436
|
+
);
|
|
437
|
+
res.set(
|
|
438
|
+
'Cache-Control',
|
|
439
|
+
maxAge > 0 ? `private, max-age=${Math.floor(maxAge / 1000)}` : 'private'
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (typeof file.size === 'number') {
|
|
443
|
+
res.set('Content-Length', String(file.size));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The headers go out before the first chunk, so the response is
|
|
447
|
+
// committed by the time this resolves: a controller that returns what
|
|
448
|
+
// `send()` gives it has answered, and the action wrapper knows it
|
|
449
|
+
typeof res.flushHeaders === 'function' && res.flushHeaders();
|
|
450
|
+
|
|
451
|
+
await new Promise((resolve) => {
|
|
452
|
+
stream.on('error', (error) => {
|
|
453
|
+
debug('unable to stream %s: %s', file.key, error.message);
|
|
454
|
+
res.destroy();
|
|
455
|
+
resolve();
|
|
456
|
+
});
|
|
457
|
+
res.on('close', resolve);
|
|
458
|
+
res.on('finish', resolve);
|
|
459
|
+
stream.pipe(res);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return res;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A time-limited url that hands a stored file to a client.
|
|
467
|
+
*
|
|
468
|
+
* One call, two implementations, the same semantics. On an object store it
|
|
469
|
+
* is the provider's own signature, which covers the method, the host, the
|
|
470
|
+
* key and every query parameter -- the expiry among them -- and the store
|
|
471
|
+
* refuses it once the window has passed; the bytes never reach this
|
|
472
|
+
* process. On the local disk it is henri's own (`src/signing.js`), covering
|
|
473
|
+
* the key, the expiry, the disposition, the download name and the type,
|
|
474
|
+
* verified by the route this module mounts.
|
|
475
|
+
*
|
|
476
|
+
* Neither can be edited to name another object and neither survives its
|
|
477
|
+
* expiry. What both **are**, until then, is a bearer capability: whoever
|
|
478
|
+
* holds the link holds the file, and no session is consulted. That is the
|
|
479
|
+
* point of a signed url and the reason they are off unless
|
|
480
|
+
* `config.uploads.urls` turns them on -- a file that must be checked per
|
|
481
|
+
* viewer is handed back by a controller and `send()` instead.
|
|
482
|
+
*
|
|
483
|
+
* @async
|
|
484
|
+
* @param {(object|string)} record what `store()` returned, or its key
|
|
485
|
+
* @param {object} [options={}] `{ expiresIn, disposition, filename, type }`
|
|
486
|
+
* @returns {Promise<string>} the url
|
|
487
|
+
* @throws when signed urls are off, unsignable, or the argument is not a record
|
|
488
|
+
* @memberof UploadsModule
|
|
489
|
+
*/
|
|
490
|
+
async url(record, options = {}) {
|
|
491
|
+
const file = typeof record === 'string' ? { key: record } : record || {};
|
|
492
|
+
const storage = this.ready();
|
|
493
|
+
|
|
494
|
+
if (!file.key) {
|
|
495
|
+
throw new Error('henri.uploads.url() needs the record store() returned');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!this.settings.urls) {
|
|
499
|
+
throw coded(
|
|
500
|
+
'HENRI_UPLOAD_URLS_DISABLED',
|
|
501
|
+
'this application hands out no signed urls: add { "uploads": { "urls": { "expiresIn": 300 } } } to the configuration, or hand the file back from a controller with henri.uploads.send()',
|
|
502
|
+
{ key: file.key }
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const asked = {
|
|
507
|
+
disposition: options.disposition || 'attachment',
|
|
508
|
+
expiresIn:
|
|
509
|
+
options.expiresIn === undefined || options.expiresIn === null
|
|
510
|
+
? this.settings.urls.expiresIn
|
|
511
|
+
: options.expiresIn,
|
|
512
|
+
filename: options.filename || file.name || null,
|
|
513
|
+
now: options.now,
|
|
514
|
+
type: options.type || file.type || null,
|
|
515
|
+
};
|
|
516
|
+
const own =
|
|
517
|
+
typeof storage.url === 'function'
|
|
518
|
+
? await storage.url(file.key, asked)
|
|
519
|
+
: null;
|
|
520
|
+
|
|
521
|
+
if (typeof own === 'string') {
|
|
522
|
+
return own;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!this.signer.usable) {
|
|
526
|
+
throw coded(
|
|
527
|
+
'HENRI_UPLOAD_URLS_DISABLED',
|
|
528
|
+
`${storage.name} signs no url of its own, and this application has no secret for henri to sign one with: set HENRI_SECRET`,
|
|
529
|
+
{ key: file.key }
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return this.signer.sign(file.key, asked);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* A derived file: the record of one declared variant of a stored image.
|
|
538
|
+
*
|
|
539
|
+
* The work happens here, once, and only when somebody asks: the derived
|
|
540
|
+
* key is a digest of the variant's own terms, so an object that is
|
|
541
|
+
* already there is one `stat()` away and a hundred concurrent callers in
|
|
542
|
+
* one process derive it once. The result is a record with a key like any
|
|
543
|
+
* other, so `send()`, `url()` and `delete()` take it unchanged.
|
|
544
|
+
*
|
|
545
|
+
* What it needs is `sharp`, an optional peer dependency the application
|
|
546
|
+
* installs; without it this refuses with `HENRI_UPLOAD_NO_IMAGE_LIBRARY`
|
|
547
|
+
* and the install line, rather than quietly answering the original.
|
|
548
|
+
*
|
|
549
|
+
* @async
|
|
550
|
+
* @param {(object|string)} record what `store()` returned, or its key
|
|
551
|
+
* @param {string} name a variant declared in `config.uploads.variants`
|
|
552
|
+
* @returns {Promise<object>} `{ key, name, of, size, storage, type, uploadedAt }`
|
|
553
|
+
* @throws when the variant is unknown, the source cannot be one, or the
|
|
554
|
+
* application has no image library
|
|
555
|
+
* @memberof UploadsModule
|
|
556
|
+
*/
|
|
557
|
+
async variant(record, name) {
|
|
558
|
+
const file = typeof record === 'string' ? { key: record } : record || {};
|
|
559
|
+
const storage = this.ready();
|
|
560
|
+
const declared = this.settings.variants || {};
|
|
561
|
+
const spec = declared[name];
|
|
562
|
+
|
|
563
|
+
if (!file.key) {
|
|
564
|
+
throw new Error(
|
|
565
|
+
'henri.uploads.variant() needs the record store() returned'
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!spec) {
|
|
570
|
+
throw coded(
|
|
571
|
+
'HENRI_UPLOAD_VARIANT_UNKNOWN',
|
|
572
|
+
`there is no variant called ${JSON.stringify(String(name))}${
|
|
573
|
+
Object.keys(declared).length > 0
|
|
574
|
+
? `; this application declares ${Object.keys(declared).join(', ')}`
|
|
575
|
+
: ': declare one under uploads.variants'
|
|
576
|
+
}`,
|
|
577
|
+
{ key: file.key, variant: name }
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!SOURCES.has(file.type)) {
|
|
582
|
+
throw coded(
|
|
583
|
+
'HENRI_UPLOAD_VARIANT_UNSUPPORTED',
|
|
584
|
+
`${file.type || 'this file'} is not an image a variant can be derived from${
|
|
585
|
+
file.type === 'image/svg+xml'
|
|
586
|
+
? ': an SVG is text that carries script, and rendering one means parsing it'
|
|
587
|
+
: ''
|
|
588
|
+
}`,
|
|
589
|
+
{ key: file.key, type: file.type || null, variant: name }
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const key = variantKeyFor(file.key, spec);
|
|
594
|
+
const found = await storage.stat(key);
|
|
595
|
+
|
|
596
|
+
if (found) {
|
|
597
|
+
return {
|
|
598
|
+
key,
|
|
599
|
+
name: file.name || null,
|
|
600
|
+
of: file.key,
|
|
601
|
+
size: found.size,
|
|
602
|
+
storage: storage.name,
|
|
603
|
+
type: FORMATS[spec.format],
|
|
604
|
+
uploadedAt: new Date(found.modifiedAt).toISOString(),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (this._deriving.has(key)) {
|
|
609
|
+
return this._deriving.get(key);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const work = produce({
|
|
613
|
+
henri: this.henri,
|
|
614
|
+
key,
|
|
615
|
+
maxFileSize: this.settings.maxFileSize,
|
|
616
|
+
record: file,
|
|
617
|
+
spec,
|
|
618
|
+
storage,
|
|
619
|
+
}).finally(() => this._deriving.delete(key));
|
|
620
|
+
|
|
621
|
+
this._deriving.set(key, work);
|
|
622
|
+
|
|
623
|
+
return work;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A readable stream of a stored file
|
|
628
|
+
*
|
|
629
|
+
* @async
|
|
630
|
+
* @param {(object|string)} record the record, or its key
|
|
631
|
+
* @returns {Promise<stream.Readable>} the stream
|
|
632
|
+
* @memberof UploadsModule
|
|
633
|
+
*/
|
|
634
|
+
async get(record) {
|
|
635
|
+
return this.ready().get(typeof record === 'string' ? record : record.key);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Removes a stored file
|
|
640
|
+
*
|
|
641
|
+
* @async
|
|
642
|
+
* @param {(object|string)} record the record, or its key
|
|
643
|
+
* @returns {Promise<boolean>} true when something was removed
|
|
644
|
+
* @memberof UploadsModule
|
|
645
|
+
*/
|
|
646
|
+
async delete(record) {
|
|
647
|
+
return this.ready().delete(
|
|
648
|
+
typeof record === 'string' ? record : record.key
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The storage, or a readable error
|
|
654
|
+
*
|
|
655
|
+
* @returns {object} the storage
|
|
656
|
+
* @throws when uploads are turned off
|
|
657
|
+
* @memberof UploadsModule
|
|
658
|
+
*/
|
|
659
|
+
ready() {
|
|
660
|
+
if (this.enabled && this.storage) {
|
|
661
|
+
return this.storage;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
throw this.henri.pen.fatal(
|
|
665
|
+
'uploads',
|
|
666
|
+
`
|
|
667
|
+
this application asked for an uploaded file, but uploads are off.
|
|
668
|
+
Remove "uploads": false from the configuration`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Stops the module: releases the storage
|
|
674
|
+
*
|
|
675
|
+
* @async
|
|
676
|
+
* @returns {Promise<(string|boolean)>} the module name, or false
|
|
677
|
+
* @memberof UploadsModule
|
|
678
|
+
*/
|
|
679
|
+
async stop() {
|
|
680
|
+
if (!this.storage) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
await this.storage.stop();
|
|
685
|
+
this.enabled = false;
|
|
686
|
+
this.signer = null;
|
|
687
|
+
|
|
688
|
+
return this.name;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
module.exports = UploadsModule;
|
|
693
|
+
module.exports.DEFAULTS = DEFAULTS;
|
|
694
|
+
module.exports.UploadedFile = UploadedFile;
|