@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,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;