@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/download.js
ADDED
|
@@ -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 };
|