@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.63.1",
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
+ }