@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,126 @@
1
+ /**
2
+ * The route that verifies henri's own signed urls and streams the file.
3
+ *
4
+ * It exists only for a storage that cannot sign its own -- the local disk --
5
+ * and only when `uploads.urls` says so. An object store signs a url the
6
+ * client fetches from the store itself, and nothing here ever runs for it.
7
+ *
8
+ * **Why it is a middleware and not a route of `config/routes.js`.** The same
9
+ * reason the parser is: the uploads module is at runlevel 3 and the router
10
+ * is at 5, so there is no route table yet to add to. That is not a
11
+ * workaround, it is the right place -- this url carries its own
12
+ * authorization in its signature, so it wants no session, no CSRF token and
13
+ * no policy. The rate limit and helmet are mounted at runlevel 2, before
14
+ * this, so a signed url is rate limited and gets the same headers as
15
+ * everything else.
16
+ *
17
+ * **Why it fails through `next(error)`.** `res.boom` does not exist yet at
18
+ * this point in the chain either; core's error handler negotiates an
19
+ * `UploadError` into the JSON body a client expects or the page a browser
20
+ * does, and the code (`HENRI_UPLOAD_URL_INVALID`, `HENRI_UPLOAD_URL_EXPIRED`)
21
+ * reaches both. The parser refuses the same way, for the same reason.
22
+ */
23
+ const debug = require('debug')('henri:uploads');
24
+
25
+ const { UploadError } = require('./errors');
26
+
27
+ /** The methods a signed url answers: reading, and asking about reading */
28
+ const METHODS = new Set(['GET', 'HEAD']);
29
+
30
+ /**
31
+ * The storage key a request names, or null when the path is not one of ours
32
+ *
33
+ * @param {string} pathname the path of the request
34
+ * @param {string} prefix where the route is mounted
35
+ * @returns {?string} the key
36
+ */
37
+ function keyIn(pathname, prefix) {
38
+ if (!pathname.startsWith(`${prefix}/`)) {
39
+ return null;
40
+ }
41
+
42
+ try {
43
+ return decodeURIComponent(pathname.slice(prefix.length + 1));
44
+ } catch (error) {
45
+ debug('undecodable path %s: %s', pathname, error.message);
46
+
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The middleware the module mounts next to the parser.
53
+ *
54
+ * Like the parser, it reads the settings and the signer off the module on
55
+ * every request rather than closing over them: a reload changes what it
56
+ * does, never whether it is there.
57
+ *
58
+ * @param {object} module the uploads module (`henri.uploads`)
59
+ * @returns {function} express middleware
60
+ */
61
+ function downloads(module) {
62
+ return function uploadDownload(req, res, next) {
63
+ const { settings, signer, storage } = module;
64
+
65
+ if (!module.enabled || !signer || !settings.urls || !storage) {
66
+ return next();
67
+ }
68
+
69
+ if (!METHODS.has(req.method)) {
70
+ return next();
71
+ }
72
+
73
+ const [pathname, search] = String(req.originalUrl || req.url).split('?');
74
+ const key = keyIn(pathname, settings.urls.path);
75
+
76
+ if (key === null) {
77
+ return next();
78
+ }
79
+
80
+ const verdict = signer.verify(key, new URLSearchParams(search || ''));
81
+
82
+ if (!verdict.ok) {
83
+ return next(
84
+ verdict.reason === 'expired'
85
+ ? new UploadError(
86
+ 'URL_EXPIRED',
87
+ 'this link has expired; ask the application for another one',
88
+ {
89
+ expires: Number(
90
+ new URLSearchParams(search || '').get('expires')
91
+ ),
92
+ }
93
+ )
94
+ : new UploadError('URL_INVALID', 'this link is not valid')
95
+ );
96
+ }
97
+
98
+ // Everything below is about a link henri really signed. An object that
99
+ // is no longer there answers what an invalid link answers, rather than
100
+ // a 404: the two are indistinguishable to a holder of the link, and a
101
+ // link that is refused says nothing about what the storage holds
102
+ return storage
103
+ .stat(key)
104
+ .then((found) => {
105
+ if (!found) {
106
+ return next(
107
+ new UploadError('URL_INVALID', 'this file is no longer there')
108
+ );
109
+ }
110
+
111
+ return module.send(
112
+ res,
113
+ {
114
+ key,
115
+ name: verdict.claims.filename || 'file',
116
+ size: found.size,
117
+ type: verdict.claims.type,
118
+ },
119
+ { disposition: verdict.claims.disposition }
120
+ );
121
+ })
122
+ .catch(next);
123
+ };
124
+ }
125
+
126
+ module.exports = { METHODS, downloads, keyIn };
package/src/errors.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What a refused upload is.
3
+ *
4
+ * Every refusal is an `UploadError` carrying an HTTP status and a code, and
5
+ * it reaches the client through `next(error)` -- core's error handler
6
+ * negotiates it into the JSON shape a client expects or the page a browser
7
+ * does, and its own logging quotes the request id. Nothing here writes a
8
+ * response itself: an upload is refused in a middleware, and the middleware
9
+ * that owns the answer is the one at the end of the chain.
10
+ */
11
+
12
+ /** The codes a refusal carries, and the status each one answers with */
13
+ const CODES = {
14
+ FIELD_NAME_TOO_LONG: 413,
15
+ FILE_TOO_LARGE: 413,
16
+ MALFORMED_MULTIPART: 400,
17
+ TOO_MANY_FIELDS: 413,
18
+ TOO_MANY_FILES: 413,
19
+ TOTAL_TOO_LARGE: 413,
20
+ TYPE_NOT_ALLOWED: 415,
21
+ URL_EXPIRED: 403,
22
+ URL_INVALID: 403,
23
+ VALUE_TOO_LARGE: 413,
24
+ };
25
+
26
+ /**
27
+ * The catalogue code a refusal also carries, where there is one.
28
+ *
29
+ * `error.code` is the short name a client reads next to the status, and
30
+ * core's `coded()` looks at `henriCode` when `code` is not one of the
31
+ * catalogue's -- the same arrangement an `ENOENT` gets. So a refusal can
32
+ * keep the name it always had and still reach the JSON body, the log line
33
+ * and `henri mcp` as `HENRI_UPLOAD_*`.
34
+ */
35
+ const HENRI = {
36
+ URL_EXPIRED: 'HENRI_UPLOAD_URL_EXPIRED',
37
+ URL_INVALID: 'HENRI_UPLOAD_URL_INVALID',
38
+ };
39
+
40
+ /**
41
+ * A refused upload
42
+ *
43
+ * @class UploadError
44
+ * @extends {Error}
45
+ */
46
+ class UploadError extends Error {
47
+ /**
48
+ * Creates an instance of UploadError.
49
+ *
50
+ * @param {string} code one of `CODES`
51
+ * @param {string} message what to tell the client
52
+ * @param {object} [data={}] what to add to the answer (a field name, a limit)
53
+ * @memberof UploadError
54
+ */
55
+ constructor(code, message, data = {}) {
56
+ super(message);
57
+
58
+ this.name = 'UploadError';
59
+ this.code = code;
60
+ this.status = CODES[code] || 400;
61
+ this.statusCode = this.status;
62
+ this.data = data;
63
+
64
+ if (HENRI[code]) {
65
+ this.henriCode = HENRI[code];
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A failure carrying one of the catalogue's codes and nothing else.
72
+ *
73
+ * The three lines a package that only peer-depends on core writes for
74
+ * itself, the way `@usehenri/webhooks` and `@usehenri/s3` do: a code is a
75
+ * string, so raising one imports nothing.
76
+ *
77
+ * @param {string} code a henri error code, from core's own catalogue
78
+ * @param {string} message what went wrong
79
+ * @param {object} [rest={}] `cause` and anything to carry on the error
80
+ * @returns {Error} the error
81
+ */
82
+ function coded(code, message, rest = {}) {
83
+ const { cause, ...extra } = rest;
84
+ const error = new Error(message, cause ? { cause } : undefined);
85
+
86
+ error.code = code;
87
+ Object.assign(error, extra);
88
+
89
+ return error;
90
+ }
91
+
92
+ module.exports = { CODES, HENRI, UploadError, coded };
package/src/file.js ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * One file that arrived.
3
+ *
4
+ * It exists on disk from the moment the parser finished reading it until the
5
+ * end of the request, and it is temporary for the whole of that time: an
6
+ * upload is not kept because it arrived, it is kept because a controller
7
+ * said so with `store()`. Everything else is swept when the response closes,
8
+ * whether the request was answered, refused, timed out or abandoned
9
+ * half-way.
10
+ *
11
+ * What `store()` resolves with is the record: the plain object a controller
12
+ * writes to a model. It holds the key, the cleaned original name, the type
13
+ * the *bytes* were recognized as, the size and a sha256 of the content --
14
+ * everything needed to hand the file back later, and nothing that would let
15
+ * the row alone reconstruct where on the machine it sits.
16
+ */
17
+ const fsp = require('node:fs/promises');
18
+ const debug = require('debug')('henri:uploads');
19
+
20
+ const { extensionFor } = require('./sniff');
21
+ const { keyFor, safeName } = require('./names');
22
+
23
+ /**
24
+ * A file the parser read and put somewhere private
25
+ *
26
+ * @class UploadedFile
27
+ */
28
+ class UploadedFile {
29
+ /**
30
+ * Creates an instance of UploadedFile.
31
+ *
32
+ * @param {object} options what the parser found out
33
+ * @param {string} options.field the form field it arrived in
34
+ * @param {string} options.name the cleaned original name
35
+ * @param {string} options.declaredType what the client called it
36
+ * @param {string} options.type what the bytes say it is
37
+ * @param {boolean} options.sniffed whether henri recognized those bytes
38
+ * @param {number} options.size how many bytes arrived
39
+ * @param {string} options.checksum sha256 of the content, hex
40
+ * @param {string} options.path where it is, until the response closes
41
+ * @param {object} options.storage the storage that will keep it
42
+ * @param {number} [options.order=0] which part of the body it was, so that
43
+ * `req.files.photos[0]` is the first photo the form sent whatever order
44
+ * the reads happened to finish in
45
+ * @memberof UploadedFile
46
+ */
47
+ constructor({
48
+ checksum,
49
+ declaredType,
50
+ field,
51
+ name,
52
+ order = 0,
53
+ path: temporary,
54
+ size,
55
+ sniffed,
56
+ storage,
57
+ type,
58
+ }) {
59
+ this.order = order;
60
+ this.field = field;
61
+ this.name = name;
62
+ this.declaredType = declaredType;
63
+ this.type = type;
64
+ this.sniffed = sniffed;
65
+ this.size = size;
66
+ this.checksum = checksum;
67
+ this.path = temporary;
68
+ this.storage = storage;
69
+
70
+ /** The record `store()` resolved with, once it has */
71
+ this.stored = null;
72
+
73
+ /** True once the temporary file is gone, however it went */
74
+ this.released = false;
75
+ }
76
+
77
+ /**
78
+ * Did the client say one thing and the bytes another?
79
+ *
80
+ * @returns {boolean} true when the declared type is not the real one
81
+ * @memberof UploadedFile
82
+ */
83
+ get mistyped() {
84
+ return (
85
+ this.sniffed &&
86
+ Boolean(this.declaredType) &&
87
+ this.declaredType !== this.type
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Keeps the file, and answers the record to write to a model
93
+ *
94
+ * @async
95
+ * @param {object} [options={}] the options
96
+ * @param {string} [options.prefix] a directory to file it under
97
+ * @param {object} [options.storage] another storage than the default one
98
+ * @returns {Promise<object>} `{ checksum, key, name, size, storage, type, uploadedAt }`
99
+ * @throws when there is nothing left to store, or the storage refuses
100
+ * @memberof UploadedFile
101
+ */
102
+ async store(options = {}) {
103
+ if (this.stored) {
104
+ return this.stored;
105
+ }
106
+
107
+ if (this.released) {
108
+ throw new Error(
109
+ `the upload "${this.name}" was already released; store() has to be called before the response closes`
110
+ );
111
+ }
112
+
113
+ const storage = options.storage || this.storage;
114
+ // What the parser already knows travels with the file: a backend that
115
+ // keeps metadata of its own (an object store keeps a `Content-Type` and
116
+ // wants the digest it is signing) would otherwise read the bytes again
117
+ // to learn what was measured on the way in
118
+ const key = await storage.put(
119
+ this.path,
120
+ keyFor({
121
+ extension: extensionFor(this.type),
122
+ prefix: options.prefix || null,
123
+ }),
124
+ {
125
+ checksum: this.checksum,
126
+ name: this.name,
127
+ size: this.size,
128
+ type: this.type,
129
+ }
130
+ );
131
+
132
+ this.released = true;
133
+ this.stored = {
134
+ checksum: this.checksum,
135
+ key,
136
+ name: this.name,
137
+ size: this.size,
138
+ storage: storage.name,
139
+ type: this.type,
140
+ uploadedAt: new Date().toISOString(),
141
+ };
142
+
143
+ return this.stored;
144
+ }
145
+
146
+ /**
147
+ * Throws the file away now, rather than at the end of the request
148
+ *
149
+ * @async
150
+ * @returns {Promise<boolean>} true when something was removed
151
+ * @memberof UploadedFile
152
+ */
153
+ async discard() {
154
+ if (this.released) {
155
+ return false;
156
+ }
157
+
158
+ this.released = true;
159
+
160
+ try {
161
+ await fsp.unlink(this.path);
162
+
163
+ return true;
164
+ } catch (error) {
165
+ debug('unable to remove %s: %s', this.path, error.message);
166
+
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * What a view or a JSON answer gets: the record when it was stored, the
173
+ * facts without the temporary path when it was not
174
+ *
175
+ * @returns {object} a plain object
176
+ * @memberof UploadedFile
177
+ */
178
+ toJSON() {
179
+ return (
180
+ this.stored || {
181
+ checksum: this.checksum,
182
+ field: this.field,
183
+ name: this.name,
184
+ size: this.size,
185
+ type: this.type,
186
+ }
187
+ );
188
+ }
189
+ }
190
+
191
+ /**
192
+ * An UploadedFile, with its name cleaned and its type decided
193
+ *
194
+ * @param {object} options see the constructor, plus `maxFilenameLength`
195
+ * @returns {UploadedFile} the file
196
+ */
197
+ const fileOf = (options) =>
198
+ new UploadedFile(
199
+ Object.assign({}, options, {
200
+ name: safeName(options.name, options.maxFilenameLength),
201
+ })
202
+ );
203
+
204
+ module.exports = { UploadedFile, fileOf };