@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/src/sniff.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * What the bytes say a file is.
3
+ *
4
+ * A multipart part carries a `Content-Type` and a filename, both written by
5
+ * whoever is uploading. Neither is evidence: `avatar.png` with
6
+ * `Content-Type: image/png` is one HTTP header and one string away from
7
+ * being a PHP script, and every framework that stored the declared type and
8
+ * served it back has learned that the hard way.
9
+ *
10
+ * So henri reads the first bytes instead. A signature it recognizes is the
11
+ * type; the declared one is kept beside it as `declaredType`, for the record
12
+ * and for nothing else. What henri recognizes is the table below plus one
13
+ * inference -- a sample that decodes as UTF-8 with no control characters is
14
+ * text -- and everything else is `application/octet-stream`. That last line
15
+ * is the honest part: henri never guesses a type from an extension, so a
16
+ * format with no signature stays unrecognized rather than being described by
17
+ * its name.
18
+ *
19
+ * Two entries exist only to be refused: `text/html` and `image/svg+xml` are
20
+ * text formats that carry script. They are named so that an `allow` list can
21
+ * exclude them, so that a descriptor never claims a scriptable file is
22
+ * `text/plain`, and so that `extensionFor()` can store them under a name no
23
+ * web server would render.
24
+ */
25
+
26
+ /** How many bytes of a file are enough to recognize it */
27
+ const SAMPLE = 4096;
28
+
29
+ /**
30
+ * The signatures henri recognizes, longest and most specific first.
31
+ *
32
+ * `at` is where the bytes sit, `hex` is what they are, and `also` is a second
33
+ * run of bytes the container format needs (RIFF and ISO base media both put
34
+ * the interesting part after a length).
35
+ */
36
+ const SIGNATURES = [
37
+ // Executables, first: whatever else they look like, this is what they are
38
+ { hex: '7f454c46', type: 'application/x-elf' },
39
+ { hex: '4d5a', type: 'application/x-msdownload' },
40
+ { hex: 'cffaedfe', type: 'application/x-mach-binary' },
41
+ { hex: 'cefaedfe', type: 'application/x-mach-binary' },
42
+ { hex: 'feedface', type: 'application/x-mach-binary' },
43
+ { hex: 'feedfacf', type: 'application/x-mach-binary' },
44
+ { hex: 'cafebabe', type: 'application/x-mach-binary' },
45
+ { hex: '23212f', type: 'text/x-shellscript' },
46
+
47
+ // Images
48
+ { hex: '89504e470d0a1a0a', type: 'image/png' },
49
+ { hex: 'ffd8ff', type: 'image/jpeg' },
50
+ { hex: '474946383761', type: 'image/gif' },
51
+ { hex: '474946383961', type: 'image/gif' },
52
+ { also: { at: 8, hex: '57454250' }, hex: '52494646', type: 'image/webp' },
53
+ { hex: '424d', type: 'image/bmp' },
54
+ { hex: '49492a00', type: 'image/tiff' },
55
+ { hex: '4d4d002a', type: 'image/tiff' },
56
+ {
57
+ also: { at: 8, hex: '61766966' },
58
+ at: 4,
59
+ hex: '66747970',
60
+ type: 'image/avif',
61
+ },
62
+ {
63
+ also: { at: 8, hex: '68656963' },
64
+ at: 4,
65
+ hex: '66747970',
66
+ type: 'image/heic',
67
+ },
68
+
69
+ // Documents and archives
70
+ { hex: '255044462d', type: 'application/pdf' },
71
+ { hex: '504b0304', type: 'application/zip' },
72
+ { hex: '504b0506', type: 'application/zip' },
73
+ { hex: '504b0708', type: 'application/zip' },
74
+ { hex: '1f8b', type: 'application/gzip' },
75
+ { hex: '377abcaf271c', type: 'application/x-7z-compressed' },
76
+ { hex: '526172211a07', type: 'application/vnd.rar' },
77
+ { hex: '425a68', type: 'application/x-bzip2' },
78
+ { hex: 'd0cf11e0a1b11ae1', type: 'application/x-cfb' },
79
+
80
+ // Audio and video
81
+ { also: { at: 8, hex: '57415645' }, hex: '52494646', type: 'audio/wav' },
82
+ { hex: '494433', type: 'audio/mpeg' },
83
+ { hex: 'fffb', type: 'audio/mpeg' },
84
+ { hex: '4f676753', type: 'audio/ogg' },
85
+ { hex: '664c6143', type: 'audio/flac' },
86
+ { hex: '1a45dfa3', type: 'video/webm' },
87
+ { at: 4, hex: '66747970', type: 'video/mp4' },
88
+ ];
89
+
90
+ /**
91
+ * The extension a stored object is given, by type.
92
+ *
93
+ * The name on disk is generated, so this is cosmetic -- except for the two
94
+ * scriptable types, which are stored as `.bin` on purpose. Uploads are never
95
+ * served from a directory the application serves, and this is the second
96
+ * lock on that door: a web server accidentally pointed at the storage
97
+ * directory still has nothing there it would render or execute.
98
+ */
99
+ const EXTENSIONS = {
100
+ 'application/gzip': 'gz',
101
+ 'application/pdf': 'pdf',
102
+ 'application/vnd.rar': 'rar',
103
+ 'application/x-7z-compressed': '7z',
104
+ 'application/x-bzip2': 'bz2',
105
+ 'application/x-cfb': 'bin',
106
+ 'application/x-elf': 'bin',
107
+ 'application/x-mach-binary': 'bin',
108
+ 'application/x-msdownload': 'bin',
109
+ 'application/zip': 'zip',
110
+ 'audio/flac': 'flac',
111
+ 'audio/mpeg': 'mp3',
112
+ 'audio/ogg': 'ogg',
113
+ 'audio/wav': 'wav',
114
+ 'image/avif': 'avif',
115
+ 'image/bmp': 'bmp',
116
+ 'image/gif': 'gif',
117
+ 'image/heic': 'heic',
118
+ 'image/jpeg': 'jpg',
119
+ 'image/png': 'png',
120
+ 'image/svg+xml': 'bin',
121
+ 'image/tiff': 'tiff',
122
+ 'image/webp': 'webp',
123
+ 'text/html': 'bin',
124
+ 'text/plain': 'txt',
125
+ 'text/x-shellscript': 'bin',
126
+ 'video/mp4': 'mp4',
127
+ 'video/webm': 'webm',
128
+ };
129
+
130
+ /** What a file henri recognizes nothing about is called */
131
+ const UNKNOWN = 'application/octet-stream';
132
+
133
+ /** The extension of everything else */
134
+ const UNKNOWN_EXTENSION = 'bin';
135
+
136
+ /** Control characters a text file may hold: tab, newline, carriage return */
137
+ const TEXT_CONTROLS = new Set([9, 10, 13]);
138
+
139
+ /**
140
+ * Does the sample start with these bytes at this offset?
141
+ *
142
+ * @param {Buffer} sample the first bytes of the file
143
+ * @param {number} at the offset
144
+ * @param {string} hex the bytes, as hex
145
+ * @returns {boolean} true when they match
146
+ */
147
+ function matches(sample, at, hex) {
148
+ const wanted = Buffer.from(hex, 'hex');
149
+
150
+ return (
151
+ sample.length >= at + wanted.length &&
152
+ sample.compare(wanted, 0, wanted.length, at, at + wanted.length) === 0
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Is this sample text? (valid UTF-8, no control characters but tab and the
158
+ * two newline ones)
159
+ *
160
+ * The sample is a prefix of the file, so it may end in the middle of a
161
+ * multi-byte character; the last three bytes are dropped before decoding
162
+ * rather than being read as a broken encoding.
163
+ *
164
+ * @param {Buffer} sample the first bytes of the file
165
+ * @param {boolean} complete whether the sample is the whole file
166
+ * @returns {boolean} true when every byte is printable text
167
+ */
168
+ function isText(sample, complete) {
169
+ if (sample.length === 0) {
170
+ return true;
171
+ }
172
+
173
+ const usable = complete ? sample : sample.subarray(0, sample.length - 3);
174
+
175
+ for (const byte of usable) {
176
+ if (byte < 32 && !TEXT_CONTROLS.has(byte)) {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ try {
182
+ new TextDecoder('utf-8', { fatal: true }).decode(usable);
183
+ } catch (error) {
184
+ return false;
185
+ }
186
+
187
+ return true;
188
+ }
189
+
190
+ /**
191
+ * The type of a text sample: the two scriptable ones, or plain text
192
+ *
193
+ * @param {Buffer} sample the first bytes of the file
194
+ * @returns {string} a media type
195
+ */
196
+ function textType(sample) {
197
+ const head = sample.subarray(0, 1024).toString('utf8').trimStart();
198
+ const lower = head.toLowerCase();
199
+
200
+ if (lower.startsWith('<!doctype html') || lower.startsWith('<html')) {
201
+ return 'text/html';
202
+ }
203
+
204
+ if (
205
+ lower.startsWith('<svg') ||
206
+ (lower.startsWith('<?xml') && lower.includes('<svg'))
207
+ ) {
208
+ return 'image/svg+xml';
209
+ }
210
+
211
+ return 'text/plain';
212
+ }
213
+
214
+ /**
215
+ * What the bytes say this file is
216
+ *
217
+ * @param {Buffer} sample the first bytes of the file (up to `SAMPLE`)
218
+ * @param {boolean} [complete=false] whether the sample is the whole file
219
+ * @returns {{type: string, sniffed: boolean}} the type, and whether henri
220
+ * recognized it rather than giving up
221
+ */
222
+ function sniff(sample, complete = false) {
223
+ const buffer = Buffer.isBuffer(sample) ? sample : Buffer.alloc(0);
224
+
225
+ for (const entry of SIGNATURES) {
226
+ if (
227
+ matches(buffer, entry.at || 0, entry.hex) &&
228
+ (!entry.also || matches(buffer, entry.also.at, entry.also.hex))
229
+ ) {
230
+ return { sniffed: true, type: entry.type };
231
+ }
232
+ }
233
+
234
+ if (isText(buffer, complete)) {
235
+ return { sniffed: true, type: textType(buffer) };
236
+ }
237
+
238
+ return { sniffed: false, type: UNKNOWN };
239
+ }
240
+
241
+ /**
242
+ * The extension a stored object gets for a type
243
+ *
244
+ * @param {string} type a media type
245
+ * @returns {string} an extension, without its dot
246
+ */
247
+ const extensionFor = (type) => EXTENSIONS[type] || UNKNOWN_EXTENSION;
248
+
249
+ /**
250
+ * Does a type match one entry of an `allow` list?
251
+ *
252
+ * An entry is a media type (`image/png`), a wildcard subtype (`image/*`) or
253
+ * `*` for everything. Nothing else: a pattern language is a place for a
254
+ * mistake to hide.
255
+ *
256
+ * @param {string} type the type of the file
257
+ * @param {string} pattern one entry of the list
258
+ * @returns {boolean} true when it matches
259
+ */
260
+ function accepts(type, pattern) {
261
+ const wanted = String(pattern).trim().toLowerCase();
262
+
263
+ if (wanted === '*' || wanted === '*/*') {
264
+ return true;
265
+ }
266
+
267
+ return wanted.endsWith('/*')
268
+ ? type.startsWith(`${wanted.slice(0, -1)}`)
269
+ : type === wanted;
270
+ }
271
+
272
+ /**
273
+ * Is this type allowed?
274
+ *
275
+ * @param {string} type the type of the file
276
+ * @param {?Array<string>} allow the allow list, or null for every type
277
+ * @returns {boolean} true when the file may be kept
278
+ */
279
+ const allowed = (type, allow) =>
280
+ !Array.isArray(allow) || allow.some((pattern) => accepts(type, pattern));
281
+
282
+ module.exports = {
283
+ EXTENSIONS,
284
+ SAMPLE,
285
+ SIGNATURES,
286
+ UNKNOWN,
287
+ accepts,
288
+ allowed,
289
+ extensionFor,
290
+ isText,
291
+ sniff,
292
+ };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Which storage an application uses.
3
+ *
4
+ * `"storage": "local"` is the disk below, and it is what an application that
5
+ * says nothing gets. Every other name is a package resolved from the
6
+ * application, the way `config.shared.adapter` resolves `@usehenri/redis`
7
+ * and `config.rateLimit.store` resolves a module of its own: `s3` is
8
+ * `@usehenri/s3`, and anything else is taken as a module id.
9
+ *
10
+ * ```json
11
+ * {
12
+ * "uploads": {
13
+ * "storage": {
14
+ * "adapter": "s3",
15
+ * "bucket": "henri-uploads",
16
+ * "region": "auto",
17
+ * "endpoint": "https://<account>.r2.cloudflarestorage.com"
18
+ * }
19
+ * }
20
+ * }
21
+ * ```
22
+ *
23
+ * The object form is the one a backend needs, because a backend has
24
+ * settings: `adapter` names it and everything else reaches it, exactly as
25
+ * `config.shared` and `config.stores.<name>` already work. The string form
26
+ * is the same thing without settings, and it still works.
27
+ *
28
+ * **What changed in 1.2, and what did not.** henri used to ship no client for
29
+ * anybody's object store, on the grounds that an S3 client is a dependency,
30
+ * a credential chain, a region, a retry policy and a bill. Two of those
31
+ * were true and are still true, which is why the client is not here: it is
32
+ * `@usehenri/s3`, installed by the application that wants it and by nobody
33
+ * else. What was wrong was the conclusion -- that the framework could
34
+ * therefore ship nothing at all -- because "an upload works until you run a
35
+ * second process" is not a decision an application makes, it is a trap the
36
+ * framework sets.
37
+ */
38
+ const path = require('node:path');
39
+
40
+ const LocalStorage = require('./local');
41
+
42
+ /** The storage names henri implements itself */
43
+ const BUILTIN = { local: LocalStorage };
44
+
45
+ /**
46
+ * The names that are a package of henri's, the way `shared.adapter` maps
47
+ * `redis` to `@usehenri/redis`
48
+ */
49
+ const PACKAGES = { s3: '@usehenri/s3' };
50
+
51
+ /**
52
+ * Builds the storage the configuration names
53
+ *
54
+ * @param {object} henri the henri instance
55
+ * @param {object} settings the normalized upload settings
56
+ * @returns {object} a storage implementing `HenriStorage`
57
+ * @throws when the module cannot be loaded, or is not a storage
58
+ */
59
+ function createStorage(henri, settings) {
60
+ const { root, storage: name, storageOptions: options = {} } = settings;
61
+
62
+ if (BUILTIN[name]) {
63
+ return new BUILTIN[name](name, { options, root }, henri);
64
+ }
65
+
66
+ const cwd = henri.cwd();
67
+ const named = PACKAGES[name];
68
+ const target =
69
+ named || (name.startsWith('.') ? path.resolve(cwd, name) : name);
70
+ let loaded;
71
+
72
+ try {
73
+ loaded = require(henri.utils.resolveFrom(target, cwd));
74
+ } catch (error) {
75
+ throw new Error(
76
+ named
77
+ ? `the '${name}' storage needs ${named}: pnpm add ${named} (${error.message})`
78
+ : `unable to load the storage '${name}': ${error.message}`,
79
+ { cause: error }
80
+ );
81
+ }
82
+
83
+ const mod = loaded && loaded.default ? loaded.default : loaded;
84
+ const built = build(mod, name, settings, henri);
85
+
86
+ if (!built || typeof built.put !== 'function') {
87
+ throw new Error(
88
+ `the storage '${name}' does not implement HenriStorage (put, get, stat, delete, temp)`
89
+ );
90
+ }
91
+
92
+ return built;
93
+ }
94
+
95
+ /**
96
+ * The storage a module exported: a class to build, a factory to call, or an
97
+ * object that is already one
98
+ *
99
+ * A class is told apart from a factory by its prototype carrying `put`,
100
+ * which is the one method of the contract nothing else has a reason to have.
101
+ *
102
+ * @param {*} mod what the module exported
103
+ * @param {string} name the storage name
104
+ * @param {object} settings the normalized upload settings
105
+ * @param {object} henri the henri instance
106
+ * @returns {*} the storage
107
+ */
108
+ function build(mod, name, settings, henri) {
109
+ if (typeof mod !== 'function') {
110
+ return mod;
111
+ }
112
+
113
+ const Storage = mod;
114
+ const config = {
115
+ config: settings,
116
+ options: settings.storageOptions || {},
117
+ root: settings.root,
118
+ };
119
+
120
+ return Storage.prototype && typeof Storage.prototype.put === 'function'
121
+ ? new Storage(name, config, henri)
122
+ : Storage(henri, { config: settings, name, options: config.options });
123
+ }
124
+
125
+ module.exports = { BUILTIN, LocalStorage, PACKAGES, createStorage };