@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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const fsp = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const debug = require('debug')('henri:uploads');
|
|
6
|
+
|
|
7
|
+
const { isKey } = require('../names');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Storage contract, the way `HenriAdapter` is the store contract.
|
|
11
|
+
*
|
|
12
|
+
* The module builds one with `new Storage(name, config, henri)` and calls
|
|
13
|
+
* `start()` before the first request. Everything else is called per file.
|
|
14
|
+
* The local disk below is one implementation; an object store is another,
|
|
15
|
+
* and the seam is deliberately narrow so that writing the second one is a
|
|
16
|
+
* hundred lines rather than a fork.
|
|
17
|
+
*
|
|
18
|
+
* `temp()` is part of the contract on purpose. A multipart part is streamed
|
|
19
|
+
* somewhere before anything has authorized keeping it, and only the storage
|
|
20
|
+
* knows where that somewhere should be: on the local disk it is a directory
|
|
21
|
+
* inside the root, so promoting a file is a rename on the same filesystem
|
|
22
|
+
* rather than a copy; on an object store it is whatever the machine has.
|
|
23
|
+
*
|
|
24
|
+
* @interface HenriStorage
|
|
25
|
+
* @property {string} name The storage name (`local`, or the module id)
|
|
26
|
+
* @method async start() Prepares the storage; called once, before the server
|
|
27
|
+
* answers
|
|
28
|
+
* @method async stop() Releases what it holds; `start()` may be called again
|
|
29
|
+
* @method async temp() `{ path }`: a private file to stream a part into
|
|
30
|
+
* @method async put(source, key, meta) Moves `source` in under `key`;
|
|
31
|
+
* resolves with the key that was written. `meta` is what the parser
|
|
32
|
+
* already knows -- `{ checksum, name, size, type }` -- which a backend
|
|
33
|
+
* that keeps metadata of its own wants and one that does not ignores
|
|
34
|
+
* @method async get(key) A readable stream of the object
|
|
35
|
+
* @method async stat(key) `{ size, modifiedAt }`, or null when there is none
|
|
36
|
+
* @method async delete(key) Removes it; resolves false when there was none
|
|
37
|
+
* @method url(key, options) A time-limited url handing the object to a
|
|
38
|
+
* client without this process reading it, or null when the storage has no
|
|
39
|
+
* such thing. `{ expiresIn, disposition, type }`; may be a promise
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The mode of the storage root and of the temporary directory */
|
|
43
|
+
const DIR_MODE = 0o700;
|
|
44
|
+
|
|
45
|
+
/** The mode of every stored object: readable by the process, nobody else */
|
|
46
|
+
const FILE_MODE = 0o600;
|
|
47
|
+
|
|
48
|
+
/** Where the parts being read are kept, inside the root */
|
|
49
|
+
const TMP = '.tmp';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How old a part has to be before a boot sweeps it away.
|
|
53
|
+
*
|
|
54
|
+
* The root is shared by everything that runs against it -- two application
|
|
55
|
+
* processes behind a load balancer, a suite whose test files run at the same
|
|
56
|
+
* time -- and a boot cannot tell a part a dead process left behind from one
|
|
57
|
+
* another process is streaming into right now. Age can: a part being written
|
|
58
|
+
* is minutes old at most, so an hour is past every upload the bounds allow
|
|
59
|
+
* and still short enough that nothing accumulates.
|
|
60
|
+
*/
|
|
61
|
+
const STALE = 60 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What is written into the storage root the first time it is created.
|
|
65
|
+
*
|
|
66
|
+
* The root is a directory of an application's repository by default, and an
|
|
67
|
+
* upload directory that reached a commit is the sort of thing that is found
|
|
68
|
+
* later rather than sooner.
|
|
69
|
+
*/
|
|
70
|
+
const GITIGNORE = `# Uploaded files: never committed, never served.\n*\n!.gitignore\n`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The local disk
|
|
74
|
+
*
|
|
75
|
+
* @class LocalStorage
|
|
76
|
+
* @implements {HenriStorage}
|
|
77
|
+
*/
|
|
78
|
+
class LocalStorage {
|
|
79
|
+
/**
|
|
80
|
+
* Creates an instance of LocalStorage.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} name The storage name
|
|
83
|
+
* @param {object} config `{ root }`, resolved against the application
|
|
84
|
+
* @param {object} henri The henri instance
|
|
85
|
+
* @memberof LocalStorage
|
|
86
|
+
*/
|
|
87
|
+
constructor(name, config = {}, henri = null) {
|
|
88
|
+
this.name = name || 'local';
|
|
89
|
+
this.henri = henri;
|
|
90
|
+
this.root = path.resolve(
|
|
91
|
+
(henri && henri.cwd && henri.cwd()) || process.cwd(),
|
|
92
|
+
config.root || 'storage/uploads'
|
|
93
|
+
);
|
|
94
|
+
this.tmp = path.join(this.root, TMP);
|
|
95
|
+
this.started = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates the root and the temporary directory, and leaves a `.gitignore`
|
|
100
|
+
* behind the first time
|
|
101
|
+
*
|
|
102
|
+
* @async
|
|
103
|
+
* @returns {Promise<string>} the root
|
|
104
|
+
* @memberof LocalStorage
|
|
105
|
+
*/
|
|
106
|
+
async start() {
|
|
107
|
+
await fsp.mkdir(this.tmp, { mode: DIR_MODE, recursive: true });
|
|
108
|
+
await fsp.chmod(this.root, DIR_MODE).catch(() => {});
|
|
109
|
+
|
|
110
|
+
const ignore = path.join(this.root, '.gitignore');
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await fsp.writeFile(ignore, GITIGNORE, { flag: 'wx' });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
debug('%s already has a .gitignore', this.root);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await this.sweep();
|
|
119
|
+
|
|
120
|
+
this.started = true;
|
|
121
|
+
|
|
122
|
+
return this.root;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Nothing to release: the disk is the disk
|
|
127
|
+
*
|
|
128
|
+
* @async
|
|
129
|
+
* @returns {Promise<boolean>} true
|
|
130
|
+
* @memberof LocalStorage
|
|
131
|
+
*/
|
|
132
|
+
async stop() {
|
|
133
|
+
this.started = false;
|
|
134
|
+
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Removes the parts a previous process was reading when it died.
|
|
140
|
+
*
|
|
141
|
+
* Every request cleans up after itself, so this only ever finds what a
|
|
142
|
+
* `SIGKILL` or a power cut left behind. It runs at boot, where a stale
|
|
143
|
+
* file is a leak nobody is watching rather than a bug in the request.
|
|
144
|
+
*
|
|
145
|
+
* Only parts older than `STALE` are removed: another process may be
|
|
146
|
+
* streaming into this same directory right now, and unlinking its part
|
|
147
|
+
* would fail its request with something that reads like a bug in the
|
|
148
|
+
* upload rather than what it is.
|
|
149
|
+
*
|
|
150
|
+
* @async
|
|
151
|
+
* @returns {Promise<number>} how many were removed
|
|
152
|
+
* @memberof LocalStorage
|
|
153
|
+
*/
|
|
154
|
+
async sweep() {
|
|
155
|
+
let entries;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
entries = await fsp.readdir(this.tmp);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const before = Date.now() - STALE;
|
|
164
|
+
let removed = 0;
|
|
165
|
+
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.endsWith('.part')) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const file = path.join(this.tmp, entry);
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
if ((await fsp.stat(file)).mtimeMs > before) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await fsp.unlink(file);
|
|
179
|
+
removed++;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
debug('unable to remove the stale part %s: %s', entry, error.message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
removed > 0 && debug('removed %d stale part(s)', removed);
|
|
186
|
+
|
|
187
|
+
return removed;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A private file to stream a part into
|
|
192
|
+
*
|
|
193
|
+
* @async
|
|
194
|
+
* @returns {Promise<{path: string}>} where to write
|
|
195
|
+
* @memberof LocalStorage
|
|
196
|
+
*/
|
|
197
|
+
async temp() {
|
|
198
|
+
await fsp.mkdir(this.tmp, { mode: DIR_MODE, recursive: true });
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
path: path.join(
|
|
202
|
+
this.tmp,
|
|
203
|
+
`${crypto.randomBytes(16).toString('hex')}.part`
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The absolute path of a key, refusing anything henri did not generate.
|
|
210
|
+
*
|
|
211
|
+
* Two locks, because one of them is a regular expression: the key must
|
|
212
|
+
* have the shape `keyFor()` writes, and the path it resolves to must be
|
|
213
|
+
* inside the root. A key that passes the first and fails the second does
|
|
214
|
+
* not exist, which is the point of checking both.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} key the key
|
|
217
|
+
* @returns {string} the absolute path
|
|
218
|
+
* @throws when the key is not one henri could have generated
|
|
219
|
+
* @memberof LocalStorage
|
|
220
|
+
*/
|
|
221
|
+
pathOf(key) {
|
|
222
|
+
if (!isKey(key)) {
|
|
223
|
+
throw new Error(`unsafe storage key: ${JSON.stringify(String(key))}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const full = path.resolve(this.root, key);
|
|
227
|
+
|
|
228
|
+
if (full !== this.root && !full.startsWith(this.root + path.sep)) {
|
|
229
|
+
throw new Error(`storage key escapes the root: ${key}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return full;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Moves a part in under its key
|
|
237
|
+
*
|
|
238
|
+
* @async
|
|
239
|
+
* @param {string} source the temporary file
|
|
240
|
+
* @param {string} key the key to store it under
|
|
241
|
+
* @returns {Promise<string>} the key
|
|
242
|
+
* @throws when the key is unsafe, or the file cannot be written
|
|
243
|
+
* @memberof LocalStorage
|
|
244
|
+
*/
|
|
245
|
+
async put(source, key) {
|
|
246
|
+
const target = this.pathOf(key);
|
|
247
|
+
|
|
248
|
+
await fsp.mkdir(path.dirname(target), { mode: DIR_MODE, recursive: true });
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await fsp.rename(source, target);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// A different filesystem (a bind mount, a tmpfs root): copy, then drop
|
|
254
|
+
// the part. `COPYFILE_EXCL` keeps a key that already exists from being
|
|
255
|
+
// overwritten, which a generated key never is.
|
|
256
|
+
if (error.code !== 'EXDEV') {
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await fsp.copyFile(source, target, fs.constants.COPYFILE_EXCL);
|
|
261
|
+
await fsp.unlink(source).catch(() => {});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await fsp.chmod(target, FILE_MODE).catch(() => {});
|
|
265
|
+
|
|
266
|
+
return key;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A readable stream of a stored object
|
|
271
|
+
*
|
|
272
|
+
* @async
|
|
273
|
+
* @param {string} key the key
|
|
274
|
+
* @returns {Promise<fs.ReadStream>} the stream
|
|
275
|
+
* @throws when the key is unsafe or there is no such object
|
|
276
|
+
* @memberof LocalStorage
|
|
277
|
+
*/
|
|
278
|
+
async get(key) {
|
|
279
|
+
const target = this.pathOf(key);
|
|
280
|
+
|
|
281
|
+
await fsp.access(target, fs.constants.R_OK);
|
|
282
|
+
|
|
283
|
+
return fs.createReadStream(target);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* What is known about a stored object
|
|
288
|
+
*
|
|
289
|
+
* @async
|
|
290
|
+
* @param {string} key the key
|
|
291
|
+
* @returns {Promise<?{size: number, modifiedAt: Date}>} the facts, or null
|
|
292
|
+
* @memberof LocalStorage
|
|
293
|
+
*/
|
|
294
|
+
async stat(key) {
|
|
295
|
+
try {
|
|
296
|
+
const stats = await fsp.stat(this.pathOf(key));
|
|
297
|
+
|
|
298
|
+
return { modifiedAt: stats.mtime, size: stats.size };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Removes a stored object
|
|
306
|
+
*
|
|
307
|
+
* @async
|
|
308
|
+
* @param {string} key the key
|
|
309
|
+
* @returns {Promise<boolean>} true when something was removed
|
|
310
|
+
* @memberof LocalStorage
|
|
311
|
+
*/
|
|
312
|
+
async delete(key) {
|
|
313
|
+
try {
|
|
314
|
+
await fsp.unlink(this.pathOf(key));
|
|
315
|
+
|
|
316
|
+
return true;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The disk signs no url of its own, and answers so.
|
|
324
|
+
*
|
|
325
|
+
* There is nobody else to hand the file to: an object store's presigned
|
|
326
|
+
* url works because the store is a second server the client can reach, and
|
|
327
|
+
* a directory is not. `null` is the contract's word for that, and it is
|
|
328
|
+
* what makes `henri.uploads.url()` sign one itself and mount the route
|
|
329
|
+
* that verifies it (`src/signing.js`, `src/download.js`) -- so an
|
|
330
|
+
* application gets the same call and the same expiry here as it does on
|
|
331
|
+
* an object store, rather than a `null` it has to work around.
|
|
332
|
+
*
|
|
333
|
+
* @returns {null} null
|
|
334
|
+
* @memberof LocalStorage
|
|
335
|
+
*/
|
|
336
|
+
url() {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = LocalStorage;
|
|
342
|
+
module.exports.DIR_MODE = DIR_MODE;
|
|
343
|
+
module.exports.FILE_MODE = FILE_MODE;
|
|
344
|
+
module.exports.GITIGNORE = GITIGNORE;
|
|
345
|
+
module.exports.TMP = TMP;
|