@naturalcycles/backend-lib 9.63.1 → 9.64.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,43 @@
|
|
|
1
|
+
import type { NumberOfBytes } from '@naturalcycles/js-lib/types';
|
|
2
|
+
import type { BackendRequest } from '../server/server.model.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parses a `multipart/form-data` request body (on top of `busboy`), populates `req.body` with the
|
|
5
|
+
* form fields, and returns the requested files keyed by their form field name:
|
|
6
|
+
*
|
|
7
|
+
* const { csv } = await getUploadedFiles(req, { files: ['csv'] })
|
|
8
|
+
* const { csv, avatar } = await getUploadedFiles(req, {
|
|
9
|
+
* files: ['csv'], // required -> UploadedFile
|
|
10
|
+
* optionalFiles: ['avatar'], // optional -> UploadedFile | undefined
|
|
11
|
+
* })
|
|
12
|
+
*
|
|
13
|
+
* `files` are required: each is asserted to be present and non-empty (throws 400 otherwise).
|
|
14
|
+
* `optionalFiles` are returned as `UploadedFile | undefined` — `undefined` when the field is
|
|
15
|
+
* absent or empty (e.g. a blank file input), so "optional" is genuinely optional.
|
|
16
|
+
*
|
|
17
|
+
* Throws 413 as soon as ANY uploaded file exceeds `maxFileSize` bytes (default: 10 MB), aborting
|
|
18
|
+
* the parse immediately without buffering the rest of the body.
|
|
19
|
+
*
|
|
20
|
+
* Meant to be called from inside a request handler, AFTER authentication — so an
|
|
21
|
+
* unauthenticated/unauthorized request is rejected before its (potentially large) multipart body
|
|
22
|
+
* is buffered into memory.
|
|
23
|
+
*/
|
|
24
|
+
export declare function getUploadedFiles<const FILENAMES extends string = never, const OPTIONAL_FILENAMES extends string = never>(req: BackendRequest, opt: {
|
|
25
|
+
files?: readonly FILENAMES[];
|
|
26
|
+
optionalFiles?: readonly OPTIONAL_FILENAMES[];
|
|
27
|
+
maxFileSize?: NumberOfBytes;
|
|
28
|
+
}): Promise<Record<FILENAMES, UploadedFile> & Record<OPTIONAL_FILENAMES, UploadedFile | undefined>>;
|
|
29
|
+
/**
|
|
30
|
+
* A single uploaded file, buffered in memory.
|
|
31
|
+
*/
|
|
32
|
+
export interface UploadedFile {
|
|
33
|
+
/** Original filename from the client. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Content-Type of the file part. */
|
|
36
|
+
mimeType: string;
|
|
37
|
+
/** Content-Transfer-Encoding of the file part. */
|
|
38
|
+
encoding: string;
|
|
39
|
+
/** File contents. */
|
|
40
|
+
data: Buffer;
|
|
41
|
+
/** Size of `data` in bytes. */
|
|
42
|
+
size: NumberOfBytes;
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { _assert, AppError } from '@naturalcycles/js-lib/error';
|
|
2
|
+
import busboy from 'busboy';
|
|
3
|
+
/**
|
|
4
|
+
* Parses a `multipart/form-data` request body (on top of `busboy`), populates `req.body` with the
|
|
5
|
+
* form fields, and returns the requested files keyed by their form field name:
|
|
6
|
+
*
|
|
7
|
+
* const { csv } = await getUploadedFiles(req, { files: ['csv'] })
|
|
8
|
+
* const { csv, avatar } = await getUploadedFiles(req, {
|
|
9
|
+
* files: ['csv'], // required -> UploadedFile
|
|
10
|
+
* optionalFiles: ['avatar'], // optional -> UploadedFile | undefined
|
|
11
|
+
* })
|
|
12
|
+
*
|
|
13
|
+
* `files` are required: each is asserted to be present and non-empty (throws 400 otherwise).
|
|
14
|
+
* `optionalFiles` are returned as `UploadedFile | undefined` — `undefined` when the field is
|
|
15
|
+
* absent or empty (e.g. a blank file input), so "optional" is genuinely optional.
|
|
16
|
+
*
|
|
17
|
+
* Throws 413 as soon as ANY uploaded file exceeds `maxFileSize` bytes (default: 10 MB), aborting
|
|
18
|
+
* the parse immediately without buffering the rest of the body.
|
|
19
|
+
*
|
|
20
|
+
* Meant to be called from inside a request handler, AFTER authentication — so an
|
|
21
|
+
* unauthenticated/unauthorized request is rejected before its (potentially large) multipart body
|
|
22
|
+
* is buffered into memory.
|
|
23
|
+
*/
|
|
24
|
+
export async function getUploadedFiles(req, opt) {
|
|
25
|
+
const { files: requiredNames = [], optionalFiles = [], maxFileSize = 10 * MB } = opt;
|
|
26
|
+
const uploadedFiles = await parseMultipart(req, maxFileSize);
|
|
27
|
+
const result = {};
|
|
28
|
+
for (const name of requiredNames) {
|
|
29
|
+
const file = uploadedFiles[name];
|
|
30
|
+
_assert(file, `Uploaded file "${name}" is missing`, { backendResponseStatusCode: 400 });
|
|
31
|
+
_assert(file.size > 0, `Uploaded file "${name}" is empty`, { backendResponseStatusCode: 400 });
|
|
32
|
+
result[name] = file;
|
|
33
|
+
}
|
|
34
|
+
for (const name of optionalFiles) {
|
|
35
|
+
const file = uploadedFiles[name];
|
|
36
|
+
// Absent or empty (e.g. a blank file input) is treated as "not provided".
|
|
37
|
+
result[name] = file && file.size > 0 ? file : undefined;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Streams the request through busboy, buffering each file into memory and collecting the form
|
|
43
|
+
* fields onto `req.body`. Resolves with the uploaded files keyed by their form field name.
|
|
44
|
+
*
|
|
45
|
+
* Rejects (aborting the parse) the moment a file exceeds `maxFileSize`.
|
|
46
|
+
*/
|
|
47
|
+
async function parseMultipart(req, maxFileSize) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const files = {};
|
|
50
|
+
const body = {};
|
|
51
|
+
let bb;
|
|
52
|
+
try {
|
|
53
|
+
bb = busboy({ headers: req.headers, limits: { fileSize: maxFileSize } });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Not a multipart request (or malformed Content-Type) — no files to parse
|
|
57
|
+
resolve(files);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
bb.on('field', (name, value) => {
|
|
61
|
+
body[name] = value;
|
|
62
|
+
});
|
|
63
|
+
bb.on('file', (name, stream, info) => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
66
|
+
// Abort as soon as the size limit is hit: stop feeding busboy and reject right away,
|
|
67
|
+
// rather than buffering the whole over-limit body and reporting it at the end.
|
|
68
|
+
stream.on('limit', () => {
|
|
69
|
+
req.unpipe(bb);
|
|
70
|
+
req.resume(); // drain the remainder so the connection can close cleanly
|
|
71
|
+
reject(new AppError(`Uploaded file "${name}" exceeds the size limit of ${maxFileSize} bytes`, {
|
|
72
|
+
backendResponseStatusCode: 413,
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
stream.on('close', () => {
|
|
76
|
+
const data = Buffer.concat(chunks);
|
|
77
|
+
files[name] = {
|
|
78
|
+
name: info.filename,
|
|
79
|
+
mimeType: info.mimeType,
|
|
80
|
+
encoding: info.encoding,
|
|
81
|
+
data,
|
|
82
|
+
size: data.length,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
bb.on('error', reject);
|
|
87
|
+
bb.on('close', () => {
|
|
88
|
+
req.body = body;
|
|
89
|
+
resolve(files);
|
|
90
|
+
});
|
|
91
|
+
req.pipe(bb);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const MB = 1024 * 1024;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/backend-lib",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.64.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@naturalcycles/db-lib": "^10",
|
|
7
7
|
"@naturalcycles/js-lib": "^15",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"@types/on-finished": "^2",
|
|
15
15
|
"@types/on-headers": "^1",
|
|
16
16
|
"@types/vary": "^1",
|
|
17
|
+
"busboy": "^1",
|
|
17
18
|
"compressible": "^2",
|
|
18
19
|
"cookie-parser": "^1",
|
|
19
20
|
"cors": "^2",
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@sentry/node-core": "^10",
|
|
47
|
+
"@types/busboy": "^1",
|
|
46
48
|
"typescript": "rc",
|
|
47
49
|
"fastify": "^5",
|
|
48
50
|
"@naturalcycles/dev-lib": "0.0.0"
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"./deploy": "./dist/deploy/index.js",
|
|
56
58
|
"./deploy/*.js": "./dist/deploy/*.js",
|
|
57
59
|
"./express/*.js": "./dist/express/*.js",
|
|
60
|
+
"./upload": "./dist/upload/getUploadedFiles.js",
|
|
58
61
|
"./validateRequest": "./dist/validation/ajv/validateRequest.js",
|
|
59
62
|
"./onFinished": "./dist/onFinished.js",
|
|
60
63
|
"./testing": "./dist/testing/index.js"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { _assert, AppError } from '@naturalcycles/js-lib/error'
|
|
2
|
+
import type { NumberOfBytes } from '@naturalcycles/js-lib/types'
|
|
3
|
+
import busboy from 'busboy'
|
|
4
|
+
import type { Busboy, FileInfo } from 'busboy'
|
|
5
|
+
import type { BackendRequest } from '../server/server.model.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses a `multipart/form-data` request body (on top of `busboy`), populates `req.body` with the
|
|
9
|
+
* form fields, and returns the requested files keyed by their form field name:
|
|
10
|
+
*
|
|
11
|
+
* const { csv } = await getUploadedFiles(req, { files: ['csv'] })
|
|
12
|
+
* const { csv, avatar } = await getUploadedFiles(req, {
|
|
13
|
+
* files: ['csv'], // required -> UploadedFile
|
|
14
|
+
* optionalFiles: ['avatar'], // optional -> UploadedFile | undefined
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* `files` are required: each is asserted to be present and non-empty (throws 400 otherwise).
|
|
18
|
+
* `optionalFiles` are returned as `UploadedFile | undefined` — `undefined` when the field is
|
|
19
|
+
* absent or empty (e.g. a blank file input), so "optional" is genuinely optional.
|
|
20
|
+
*
|
|
21
|
+
* Throws 413 as soon as ANY uploaded file exceeds `maxFileSize` bytes (default: 10 MB), aborting
|
|
22
|
+
* the parse immediately without buffering the rest of the body.
|
|
23
|
+
*
|
|
24
|
+
* Meant to be called from inside a request handler, AFTER authentication — so an
|
|
25
|
+
* unauthenticated/unauthorized request is rejected before its (potentially large) multipart body
|
|
26
|
+
* is buffered into memory.
|
|
27
|
+
*/
|
|
28
|
+
export async function getUploadedFiles<
|
|
29
|
+
const FILENAMES extends string = never,
|
|
30
|
+
const OPTIONAL_FILENAMES extends string = never,
|
|
31
|
+
>(
|
|
32
|
+
req: BackendRequest,
|
|
33
|
+
opt: {
|
|
34
|
+
files?: readonly FILENAMES[]
|
|
35
|
+
optionalFiles?: readonly OPTIONAL_FILENAMES[]
|
|
36
|
+
maxFileSize?: NumberOfBytes
|
|
37
|
+
},
|
|
38
|
+
): Promise<Record<FILENAMES, UploadedFile> & Record<OPTIONAL_FILENAMES, UploadedFile | undefined>> {
|
|
39
|
+
const { files: requiredNames = [], optionalFiles = [], maxFileSize = 10 * MB } = opt
|
|
40
|
+
|
|
41
|
+
const uploadedFiles = await parseMultipart(req, maxFileSize)
|
|
42
|
+
const result: Record<string, UploadedFile | undefined> = {}
|
|
43
|
+
|
|
44
|
+
for (const name of requiredNames) {
|
|
45
|
+
const file = uploadedFiles[name]
|
|
46
|
+
_assert(file, `Uploaded file "${name}" is missing`, { backendResponseStatusCode: 400 })
|
|
47
|
+
_assert(file.size > 0, `Uploaded file "${name}" is empty`, { backendResponseStatusCode: 400 })
|
|
48
|
+
result[name] = file
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const name of optionalFiles) {
|
|
52
|
+
const file = uploadedFiles[name]
|
|
53
|
+
// Absent or empty (e.g. a blank file input) is treated as "not provided".
|
|
54
|
+
result[name] = file && file.size > 0 ? file : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result as Record<FILENAMES, UploadedFile> &
|
|
58
|
+
Record<OPTIONAL_FILENAMES, UploadedFile | undefined>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Streams the request through busboy, buffering each file into memory and collecting the form
|
|
63
|
+
* fields onto `req.body`. Resolves with the uploaded files keyed by their form field name.
|
|
64
|
+
*
|
|
65
|
+
* Rejects (aborting the parse) the moment a file exceeds `maxFileSize`.
|
|
66
|
+
*/
|
|
67
|
+
async function parseMultipart(
|
|
68
|
+
req: BackendRequest,
|
|
69
|
+
maxFileSize: NumberOfBytes,
|
|
70
|
+
): Promise<Record<string, UploadedFile>> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const files: Record<string, UploadedFile> = {}
|
|
73
|
+
const body: Record<string, string> = {}
|
|
74
|
+
|
|
75
|
+
let bb: Busboy
|
|
76
|
+
try {
|
|
77
|
+
bb = busboy({ headers: req.headers, limits: { fileSize: maxFileSize } })
|
|
78
|
+
} catch {
|
|
79
|
+
// Not a multipart request (or malformed Content-Type) — no files to parse
|
|
80
|
+
resolve(files)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
bb.on('field', (name, value) => {
|
|
85
|
+
body[name] = value
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
bb.on('file', (name: string, stream, info: FileInfo) => {
|
|
89
|
+
const chunks: Buffer[] = []
|
|
90
|
+
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
91
|
+
// Abort as soon as the size limit is hit: stop feeding busboy and reject right away,
|
|
92
|
+
// rather than buffering the whole over-limit body and reporting it at the end.
|
|
93
|
+
stream.on('limit', () => {
|
|
94
|
+
req.unpipe(bb)
|
|
95
|
+
req.resume() // drain the remainder so the connection can close cleanly
|
|
96
|
+
reject(
|
|
97
|
+
new AppError(`Uploaded file "${name}" exceeds the size limit of ${maxFileSize} bytes`, {
|
|
98
|
+
backendResponseStatusCode: 413,
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
stream.on('close', () => {
|
|
103
|
+
const data = Buffer.concat(chunks)
|
|
104
|
+
files[name] = {
|
|
105
|
+
name: info.filename,
|
|
106
|
+
mimeType: info.mimeType,
|
|
107
|
+
encoding: info.encoding,
|
|
108
|
+
data,
|
|
109
|
+
size: data.length,
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
bb.on('error', reject)
|
|
115
|
+
bb.on('close', () => {
|
|
116
|
+
req.body = body
|
|
117
|
+
resolve(files)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
req.pipe(bb)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const MB = 1024 * 1024
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A single uploaded file, buffered in memory.
|
|
128
|
+
*/
|
|
129
|
+
export interface UploadedFile {
|
|
130
|
+
/** Original filename from the client. */
|
|
131
|
+
name: string
|
|
132
|
+
/** Content-Type of the file part. */
|
|
133
|
+
mimeType: string
|
|
134
|
+
/** Content-Transfer-Encoding of the file part. */
|
|
135
|
+
encoding: string
|
|
136
|
+
/** File contents. */
|
|
137
|
+
data: Buffer
|
|
138
|
+
/** Size of `data` in bytes. */
|
|
139
|
+
size: NumberOfBytes
|
|
140
|
+
}
|