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