@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.
@@ -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
+ };