@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/variants.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variants: a derived file is a file with a key.
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* Where the work happens, and what it costs
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
*
|
|
8
|
+
* Three places were possible and only one of them is defensible:
|
|
9
|
+
*
|
|
10
|
+
* - **On write**, inside `store()`. Every upload then pays for every variant
|
|
11
|
+
* an application ever declared, in the request that uploaded, whether or
|
|
12
|
+
* not anybody looks at the file. A resize is hundreds of milliseconds of
|
|
13
|
+
* one CPU, and the request that pays is the one a person is watching.
|
|
14
|
+
* - **On first request, in a job.** The right answer for a big library and
|
|
15
|
+
* the wrong one for the first viewer, who gets a placeholder and has to
|
|
16
|
+
* come back. An application that wants it can have it -- the record has a
|
|
17
|
+
* key, `henri.jobs` is already there, and `variant()` in a job is the
|
|
18
|
+
* whole implementation.
|
|
19
|
+
* - **On demand, once, memoized by the key itself.** Which is this. The
|
|
20
|
+
* first caller pays; everyone after reads a stored object. There is no
|
|
21
|
+
* table, no column and nothing to invalidate, because the key is derived
|
|
22
|
+
* from the source key and a digest of the variant's own terms: the same
|
|
23
|
+
* name over the same file is the same key in every process and every
|
|
24
|
+
* environment, and a variant whose terms changed is simply a different
|
|
25
|
+
* key that nothing has written yet.
|
|
26
|
+
*
|
|
27
|
+
* The cost of the first request is bounded twice: `stat()` before anything
|
|
28
|
+
* is decoded, so a variant that exists costs one `stat`; and one promise per
|
|
29
|
+
* derived key per process, so a hundred concurrent misses run the work once.
|
|
30
|
+
* Across processes the bound is the number of processes, deliberately -- the
|
|
31
|
+
* same trade `henri.cache.fetch()` makes and for the same reason, that a
|
|
32
|
+
* lock needs a lease and a lease needs a guess.
|
|
33
|
+
*
|
|
34
|
+
* ---------------------------------------------------------------------------
|
|
35
|
+
* The dependency, and what an application without it gets
|
|
36
|
+
* ---------------------------------------------------------------------------
|
|
37
|
+
*
|
|
38
|
+
* `sharp` is a native addon: libvips, a platform-specific binary, and a
|
|
39
|
+
* build or a prebuilt download at install time. Making it a dependency of
|
|
40
|
+
* `@usehenri/uploads` would put all of that in the install of every
|
|
41
|
+
* application that accepts a PDF, which is exactly the install-weight
|
|
42
|
+
* problem this package exists on the right side of.
|
|
43
|
+
*
|
|
44
|
+
* So it is an **optional peer dependency**, resolved from the application,
|
|
45
|
+
* the way `@opentelemetry/api` is for telemetry and a store adapter is for a
|
|
46
|
+
* database. An application that installs it gets variants; one that does not
|
|
47
|
+
* gets `HENRI_UPLOAD_NO_IMAGE_LIBRARY` with the install line the first time
|
|
48
|
+
* it asks for one, and pays nothing at all until then -- no require, no
|
|
49
|
+
* probe at boot, no branch on the hot path. `henri doctor` reports it when
|
|
50
|
+
* `uploads.variants` is configured and the package is in no `package.json`,
|
|
51
|
+
* which is where a missing dependency is supposed to be found.
|
|
52
|
+
*
|
|
53
|
+
* ---------------------------------------------------------------------------
|
|
54
|
+
* What is refused, and why
|
|
55
|
+
* ---------------------------------------------------------------------------
|
|
56
|
+
*
|
|
57
|
+
* - **Only a declared variant.** `variant(record, 'thumb')` takes a name out
|
|
58
|
+
* of `config.uploads.variants` and nothing else. An ad-hoc `{ width }` from
|
|
59
|
+
* a request would let one visitor ask for ten thousand distinct sizes, each
|
|
60
|
+
* a decode, a resize and an object written -- a denial of service with a
|
|
61
|
+
* storage bill. A name cannot.
|
|
62
|
+
* - **Only an image henri recognized.** The type comes from the bytes, as
|
|
63
|
+
* everywhere else, and `image/svg+xml` is refused outright: it is one of
|
|
64
|
+
* the two scriptable types, and rendering it means handing untrusted XML
|
|
65
|
+
* to librsvg.
|
|
66
|
+
* - **Bounded pixels, one frame, no metadata.** `limitInputPixels` is
|
|
67
|
+
* explicit, an animated image is its first frame (a ten thousand frame GIF
|
|
68
|
+
* is a bomb whatever its file size), and sharp copies no metadata forward,
|
|
69
|
+
* so a thumbnail does not carry the source's GPS coordinates.
|
|
70
|
+
* - **The output is sniffed like anything else.** What sharp produced is
|
|
71
|
+
* read back with `sniff()` and compared with the format that was asked
|
|
72
|
+
* for. It should always match; a run where it does not is
|
|
73
|
+
* `HENRI_UPLOAD_VARIANT_FAILED` rather than an object stored under a
|
|
74
|
+
* `.webp` key that is not one.
|
|
75
|
+
*/
|
|
76
|
+
const crypto = require('node:crypto');
|
|
77
|
+
const fsp = require('node:fs/promises');
|
|
78
|
+
const debug = require('debug')('henri:uploads');
|
|
79
|
+
|
|
80
|
+
const { SAMPLE, extensionFor, sniff } = require('./sniff');
|
|
81
|
+
const { coded } = require('./errors');
|
|
82
|
+
|
|
83
|
+
/** How the variant's terms are named in a key: half a sha256 */
|
|
84
|
+
const DIGEST = 32;
|
|
85
|
+
|
|
86
|
+
/** The formats a variant may be written in, and what each one is */
|
|
87
|
+
const FORMATS = {
|
|
88
|
+
avif: 'image/avif',
|
|
89
|
+
jpeg: 'image/jpeg',
|
|
90
|
+
png: 'image/png',
|
|
91
|
+
webp: 'image/webp',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** How a resize may fill the box it was given (sharp's own names) */
|
|
95
|
+
const FITS = new Set(['contain', 'cover', 'fill', 'inside', 'outside']);
|
|
96
|
+
|
|
97
|
+
/** The largest side a variant may be asked for */
|
|
98
|
+
const MAX_SIDE = 8192;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How many pixels a source may hold before it is refused.
|
|
102
|
+
*
|
|
103
|
+
* Fifty megapixels is past every camera and every scan, and far below what
|
|
104
|
+
* a file crafted to be a decompression bomb declares. sharp has a limit of
|
|
105
|
+
* its own; this one is henri's, written down, so it does not move when the
|
|
106
|
+
* library's default does.
|
|
107
|
+
*/
|
|
108
|
+
const MAX_PIXELS = 50 * 1000 * 1000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The types a variant may be derived from.
|
|
112
|
+
*
|
|
113
|
+
* Every raster image `sniff.js` recognizes, minus `image/svg+xml`. The
|
|
114
|
+
* refusal is the point of the list: an SVG is text that carries script, and
|
|
115
|
+
* rendering one means parsing untrusted XML with an external entity loader
|
|
116
|
+
* in the process.
|
|
117
|
+
*/
|
|
118
|
+
const SOURCES = new Set([
|
|
119
|
+
'image/avif',
|
|
120
|
+
'image/bmp',
|
|
121
|
+
'image/gif',
|
|
122
|
+
'image/heic',
|
|
123
|
+
'image/jpeg',
|
|
124
|
+
'image/png',
|
|
125
|
+
'image/tiff',
|
|
126
|
+
'image/webp',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One variant, as the configuration declared it, with nothing missing.
|
|
131
|
+
*
|
|
132
|
+
* A spec henri cannot carry out never gets this far -- `base/config-schema.js`
|
|
133
|
+
* refuses it at boot with everything else -- so this reads the values rather
|
|
134
|
+
* than arguing with them, and falls back for an application that built its
|
|
135
|
+
* settings by hand.
|
|
136
|
+
*
|
|
137
|
+
* @param {*} declared what the configuration holds under one name
|
|
138
|
+
* @returns {?object} the spec, or null when there is nothing usable
|
|
139
|
+
*/
|
|
140
|
+
function specOf(declared) {
|
|
141
|
+
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const side = (value) =>
|
|
146
|
+
Number.isInteger(value) && value > 0 && value <= MAX_SIDE ? value : null;
|
|
147
|
+
const width = side(declared.width);
|
|
148
|
+
const height = side(declared.height);
|
|
149
|
+
|
|
150
|
+
if (width === null && height === null) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const quality = Number(declared.quality);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
fit: FITS.has(declared.fit) ? declared.fit : 'cover',
|
|
158
|
+
format: FORMATS[declared.format] ? declared.format : 'webp',
|
|
159
|
+
height,
|
|
160
|
+
quality:
|
|
161
|
+
Number.isInteger(quality) && quality >= 1 && quality <= 100
|
|
162
|
+
? quality
|
|
163
|
+
: 80,
|
|
164
|
+
width,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every declared variant, by name
|
|
170
|
+
*
|
|
171
|
+
* @param {*} declared what the configuration holds under `variants`
|
|
172
|
+
* @returns {?object} the specs, or null when none are declared
|
|
173
|
+
*/
|
|
174
|
+
function variantsOf(declared) {
|
|
175
|
+
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const found = {};
|
|
180
|
+
|
|
181
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
182
|
+
const spec = specOf(value);
|
|
183
|
+
|
|
184
|
+
if (spec && /^[0-9a-z][0-9a-z-]{0,31}$/u.test(name)) {
|
|
185
|
+
found[name] = spec;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return Object.keys(found).length > 0 ? found : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The key a variant is stored under.
|
|
194
|
+
*
|
|
195
|
+
* `<the source's directories>/<the source's name>/<digest>.<extension>`: the
|
|
196
|
+
* source's own random name becomes a directory, so a variant sits beside the
|
|
197
|
+
* file it came from and a listing reads. The digest is of the variant's
|
|
198
|
+
* terms and not of its name, so two names that mean the same thing are one
|
|
199
|
+
* object, and renaming a variant costs nothing.
|
|
200
|
+
*
|
|
201
|
+
* It is a plain digest of a canonical string, the way a retention rule's
|
|
202
|
+
* token is: no secret, so it means the same in development and in
|
|
203
|
+
* production, and a variant made by one process is found by another.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} key the source key
|
|
206
|
+
* @param {object} spec the normalized spec
|
|
207
|
+
* @returns {string} the derived key
|
|
208
|
+
*/
|
|
209
|
+
function keyFor(key, spec) {
|
|
210
|
+
const stem = key.replace(/\.[0-9a-z]{1,8}$/u, '');
|
|
211
|
+
const terms = [
|
|
212
|
+
'v1',
|
|
213
|
+
spec.format,
|
|
214
|
+
spec.fit,
|
|
215
|
+
String(spec.width || ''),
|
|
216
|
+
String(spec.height || ''),
|
|
217
|
+
String(spec.quality),
|
|
218
|
+
].join(':');
|
|
219
|
+
const digest = crypto
|
|
220
|
+
.createHash('sha256')
|
|
221
|
+
.update(terms, 'utf8')
|
|
222
|
+
.digest('hex')
|
|
223
|
+
.slice(0, DIGEST);
|
|
224
|
+
|
|
225
|
+
return `${stem}/${digest}.${extensionFor(FORMATS[spec.format])}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The image library, from the application, or a refusal that says how to
|
|
230
|
+
* get it (`sharp`, which henri does not ship)
|
|
231
|
+
*
|
|
232
|
+
* @param {object} henri the henri instance
|
|
233
|
+
* @returns {function} sharp
|
|
234
|
+
* @throws when the application does not depend on it
|
|
235
|
+
*/
|
|
236
|
+
function imageLibrary(henri) {
|
|
237
|
+
const cwd = (henri && henri.cwd && henri.cwd()) || process.cwd();
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
return require(
|
|
241
|
+
henri && henri.utils && henri.utils.resolveFrom
|
|
242
|
+
? henri.utils.resolveFrom('sharp', cwd)
|
|
243
|
+
: 'sharp'
|
|
244
|
+
);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw coded(
|
|
247
|
+
'HENRI_UPLOAD_NO_IMAGE_LIBRARY',
|
|
248
|
+
'variants need an image library, which henri does not ship: pnpm add sharp',
|
|
249
|
+
{ cause: error }
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Reads a stream into memory, refusing past a bound
|
|
256
|
+
*
|
|
257
|
+
* @param {stream.Readable} stream the source
|
|
258
|
+
* @param {number} cap how many bytes to accept
|
|
259
|
+
* @returns {Promise<Buffer>} the bytes
|
|
260
|
+
* @throws when there are more of them than that
|
|
261
|
+
*/
|
|
262
|
+
async function bufferOf(stream, cap) {
|
|
263
|
+
const chunks = [];
|
|
264
|
+
let seen = 0;
|
|
265
|
+
|
|
266
|
+
for await (const chunk of stream) {
|
|
267
|
+
seen += chunk.length;
|
|
268
|
+
|
|
269
|
+
if (cap !== false && seen > cap) {
|
|
270
|
+
stream.destroy();
|
|
271
|
+
throw coded(
|
|
272
|
+
'HENRI_UPLOAD_VARIANT_FAILED',
|
|
273
|
+
`this file is larger than the ${cap} bytes a variant is derived from`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
chunks.push(chunk);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return Buffer.concat(chunks);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The bytes of one variant
|
|
285
|
+
*
|
|
286
|
+
* @param {function} sharp the image library
|
|
287
|
+
* @param {Buffer} source the source image
|
|
288
|
+
* @param {object} spec the normalized spec
|
|
289
|
+
* @returns {Promise<Buffer>} the derived image
|
|
290
|
+
* @throws when the source cannot be read, or the result is not what was asked
|
|
291
|
+
*/
|
|
292
|
+
async function derive(sharp, source, spec) {
|
|
293
|
+
const wanted = FORMATS[spec.format];
|
|
294
|
+
let data;
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
data = await sharp(source, {
|
|
298
|
+
// One frame: an animated image is its first, whatever its file size
|
|
299
|
+
animated: false,
|
|
300
|
+
limitInputPixels: MAX_PIXELS,
|
|
301
|
+
sequentialRead: true,
|
|
302
|
+
})
|
|
303
|
+
// The EXIF orientation, applied and then dropped with the rest of the
|
|
304
|
+
// metadata: a thumbnail carries no GPS coordinates
|
|
305
|
+
.rotate()
|
|
306
|
+
.resize({
|
|
307
|
+
fit: spec.fit,
|
|
308
|
+
height: spec.height || undefined,
|
|
309
|
+
width: spec.width || undefined,
|
|
310
|
+
withoutEnlargement: true,
|
|
311
|
+
})
|
|
312
|
+
.toFormat(spec.format, { quality: spec.quality })
|
|
313
|
+
.toBuffer();
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw coded(
|
|
316
|
+
'HENRI_UPLOAD_VARIANT_FAILED',
|
|
317
|
+
`this file could not be resized: ${error.message}`,
|
|
318
|
+
{ cause: error }
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const found = sniff(data.subarray(0, SAMPLE), data.length <= SAMPLE);
|
|
323
|
+
|
|
324
|
+
if (found.type !== wanted) {
|
|
325
|
+
throw coded(
|
|
326
|
+
'HENRI_UPLOAD_VARIANT_FAILED',
|
|
327
|
+
`the resize produced ${found.type} rather than the ${wanted} it was asked for`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return data;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Derives one variant and stores it under its key
|
|
336
|
+
*
|
|
337
|
+
* @param {object} options everything the work needs
|
|
338
|
+
* @param {object} options.henri the henri instance
|
|
339
|
+
* @param {object} options.storage the storage
|
|
340
|
+
* @param {object} options.record the source record
|
|
341
|
+
* @param {object} options.spec the normalized spec
|
|
342
|
+
* @param {string} options.key the derived key
|
|
343
|
+
* @param {(number|false)} options.maxFileSize the bound on the source
|
|
344
|
+
* @returns {Promise<object>} the variant record
|
|
345
|
+
*/
|
|
346
|
+
async function produce({ henri, key, maxFileSize, record, spec, storage }) {
|
|
347
|
+
const sharp = imageLibrary(henri);
|
|
348
|
+
const source = await bufferOf(await storage.get(record.key), maxFileSize);
|
|
349
|
+
const data = await derive(sharp, source, spec);
|
|
350
|
+
const type = FORMATS[spec.format];
|
|
351
|
+
const temp = await storage.temp();
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await fsp.writeFile(temp.path, data, { mode: 0o600 });
|
|
355
|
+
await storage.put(temp.path, key, {
|
|
356
|
+
checksum: crypto.createHash('sha256').update(data).digest('hex'),
|
|
357
|
+
name: record.name,
|
|
358
|
+
size: data.length,
|
|
359
|
+
type,
|
|
360
|
+
});
|
|
361
|
+
} finally {
|
|
362
|
+
await fsp.unlink(temp.path).catch(() => {});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
debug('derived %s from %s (%d bytes)', key, record.key, data.length);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
key,
|
|
369
|
+
name: record.name,
|
|
370
|
+
of: record.key,
|
|
371
|
+
size: data.length,
|
|
372
|
+
storage: storage.name,
|
|
373
|
+
type,
|
|
374
|
+
uploadedAt: new Date().toISOString(),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
DIGEST,
|
|
380
|
+
FITS,
|
|
381
|
+
FORMATS,
|
|
382
|
+
MAX_PIXELS,
|
|
383
|
+
MAX_SIDE,
|
|
384
|
+
SOURCES,
|
|
385
|
+
bufferOf,
|
|
386
|
+
derive,
|
|
387
|
+
imageLibrary,
|
|
388
|
+
keyFor,
|
|
389
|
+
produce,
|
|
390
|
+
specOf,
|
|
391
|
+
variantsOf,
|
|
392
|
+
};
|