@lenne.tech/nest-server 11.32.3 → 11.32.4

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.
Files changed (27) hide show
  1. package/FRAMEWORK-API.md +1 -1
  2. package/bin/migrate.js +13 -3
  3. package/dist/core/common/helpers/file.helper.d.ts +14 -2
  4. package/dist/core/common/helpers/file.helper.js +48 -9
  5. package/dist/core/common/helpers/file.helper.js.map +1 -1
  6. package/dist/core/modules/file/core-file.controller.d.ts +4 -1
  7. package/dist/core/modules/file/core-file.controller.js +39 -6
  8. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  9. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
  10. package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
  11. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  12. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
  13. package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
  14. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  15. package/dist/tsconfig.build.tsbuildinfo +1 -1
  16. package/docs/security-overrides.md +9 -2
  17. package/migration-guides/11.32.3-to-11.32.4.md +323 -0
  18. package/package.json +1 -1
  19. package/src/core/common/helpers/file.helper.spec.ts +145 -0
  20. package/src/core/common/helpers/file.helper.ts +148 -10
  21. package/src/core/modules/file/README.md +59 -0
  22. package/src/core/modules/file/core-file.controller.spec.ts +164 -0
  23. package/src/core/modules/file/core-file.controller.ts +100 -8
  24. package/src/core/modules/migrate/README.md +35 -0
  25. package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
  26. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
  27. package/src/core/modules/migrate/helpers/migration.helper.ts +131 -4
@@ -2,6 +2,81 @@ import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer
2
2
  import { diskStorage } from 'multer';
3
3
  import { extname } from 'path';
4
4
 
5
+ /**
6
+ * What an upload endpoint accepts: exact mimetypes and exact file extensions.
7
+ *
8
+ * Prefer this over the legacy `RegExp` form. A single expression `.test()`ed
9
+ * against BOTH the mimetype and the extension searches for a SUBSTRING, so every
10
+ * alternative matches anywhere inside either value. An allow-list of `text` /
11
+ * `txt` therefore also accepts `text/html` and `text/xml`, and one containing
12
+ * `md` accepts every mimetype with "md" in it. Consumers hit this in practice:
13
+ * a filter documented as "no html" happily accepted a file named `x.txt` that
14
+ * was sent as `text/html`.
15
+ *
16
+ * Two separate sets also remove the reason the expression could not simply be
17
+ * anchored: it had to carry mimetype FRAGMENTS (`wordprocessingml`, `ms-excel`)
18
+ * next to bare extensions, and no anchoring satisfies both at once.
19
+ */
20
+ export interface UploadAllowList {
21
+ /** Allowed file extensions, lowercase and WITH the leading dot. */
22
+ extensions: readonly string[];
23
+ /** Allowed mimetypes, lowercase and without parameters. */
24
+ mimeTypes: readonly string[];
25
+ }
26
+
27
+ /**
28
+ * Default for image uploads — the exact-matching equivalent of the legacy
29
+ * `/jpeg|jpg|png/`.
30
+ */
31
+ export const IMAGE_UPLOAD_ALLOW_LIST: UploadAllowList = {
32
+ extensions: ['.jpeg', '.jpg', '.png'],
33
+ mimeTypes: ['image/jpeg', 'image/png'],
34
+ };
35
+
36
+ /**
37
+ * Types a browser may execute as script when it renders the stored file.
38
+ *
39
+ * These are rejected by {@link multerFileFilter} REGARDLESS of the allow-list,
40
+ * because the danger does not depend on what a given endpoint meant to accept:
41
+ * an upload served back from the API origin with one of these content types
42
+ * runs in that origin, with the victim's session. The check is what makes the
43
+ * legacy `RegExp` path safe for existing consumers without breaking their
44
+ * expressions — see `allowScriptableTypes` to opt out.
45
+ */
46
+ export const SCRIPTABLE_UPLOAD_MIME_TYPES: readonly string[] = [
47
+ 'application/javascript',
48
+ 'application/xhtml+xml',
49
+ 'application/xml',
50
+ 'image/svg+xml',
51
+ 'text/html',
52
+ 'text/javascript',
53
+ 'text/xml',
54
+ ];
55
+
56
+ /** Extensions matching {@link SCRIPTABLE_UPLOAD_MIME_TYPES}. */
57
+ export const SCRIPTABLE_UPLOAD_EXTENSIONS: readonly string[] = [
58
+ '.htm',
59
+ '.html',
60
+ '.js',
61
+ '.mjs',
62
+ '.svg',
63
+ '.xhtml',
64
+ '.xml',
65
+ ];
66
+
67
+ /** Options for {@link multerFileFilter}. */
68
+ export interface MulterFileFilterOptions {
69
+ /**
70
+ * Accept markup and script types too (`text/html`, `image/svg+xml`, …).
71
+ *
72
+ * Only set this when the stored file is never served from an origin that
73
+ * carries a session — e.g. a separate download host, or a route that always
74
+ * answers with `Content-Disposition: attachment` AND
75
+ * `X-Content-Type-Options: nosniff`.
76
+ */
77
+ allowScriptableTypes?: boolean;
78
+ }
79
+
5
80
  /**
6
81
  * Helper class for inputs
7
82
  * @deprecated use functions directly
@@ -18,14 +93,18 @@ export default class FileHelper {
18
93
  /**
19
94
  * Get function to filter files for multer with a certain mimetype & extname
20
95
  */
21
- public static multerFileFilter(fileTypeRegex = /jpeg|jpg|png/) {
22
- return multerFileFilter(fileTypeRegex);
96
+ public static multerFileFilter(
97
+ accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
98
+ options?: MulterFileFilterOptions,
99
+ ) {
100
+ return multerFileFilter(accept, options);
23
101
  }
24
102
 
25
103
  /**
26
104
  * Get multer options for image upload
27
105
  */
28
106
  public static multerOptionsForImageUpload(options: {
107
+ allowList?: UploadAllowList;
29
108
  destination?: string;
30
109
  fileSize?: number;
31
110
  fileTypeRegex?: RegExp;
@@ -35,24 +114,80 @@ export default class FileHelper {
35
114
  }
36
115
 
37
116
  /**
38
- * Get function to filter files for multer with a certain mimetype & extname
117
+ * Reduce a reported mimetype to the bare type: lowercase, trimmed, without the
118
+ * `; charset=…` parameters a user agent may append.
119
+ */
120
+ function normalizeMimeType(value: string): string {
121
+ return String(value || '')
122
+ .split(';')[0]
123
+ .trim()
124
+ .toLowerCase();
125
+ }
126
+
127
+ /**
128
+ * Get a multer `fileFilter` that accepts only the given mimetypes / extensions.
129
+ *
130
+ * Pass an {@link UploadAllowList} — both the mimetype and the extension must
131
+ * appear in it, each compared as a WHOLE value. The two conditions are
132
+ * independent: either one alone rejects the file, while a pair that is odd yet
133
+ * individually allowed (`report.txt` announced as `application/pdf`) passes.
134
+ * An extension→mimetype MAPPING is deliberately not enforced: user agents
135
+ * genuinely disagree about office and audio types (macOS reports `.csv` as
136
+ * `text/plain`), so a mapping rejects legitimate uploads.
137
+ *
138
+ * A `RegExp` is still accepted for backwards compatibility but is
139
+ * **deprecated**: it is `.test()`ed against both values and therefore matches
140
+ * SUBSTRINGS, which is how `te?xt` ends up accepting `text/html`. Whichever form
141
+ * is used, the types in {@link SCRIPTABLE_UPLOAD_MIME_TYPES} /
142
+ * {@link SCRIPTABLE_UPLOAD_EXTENSIONS} are rejected first unless
143
+ * `options.allowScriptableTypes` is set — that is what closes the hole for
144
+ * expressions that already exist in consumer projects.
145
+ *
146
+ * Rejections are reported as a real `Error`. Passing a bare string (as this
147
+ * helper did before) leaves multer with an "error" that has no `message`, which
148
+ * NestJS's `transformException` cannot map to a 4xx.
39
149
  */
40
- export function multerFileFilter(fileTypeRegex = /jpeg|jpg|png/) {
150
+ export function multerFileFilter(
151
+ accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
152
+ options?: MulterFileFilterOptions,
153
+ ) {
41
154
  return (req, file, cb) => {
42
- const mimetype = fileTypeRegex.test(file.mimetype);
43
- const extName = fileTypeRegex.test(extname(file.originalname).toLowerCase());
155
+ const mimeType = normalizeMimeType(file?.mimetype);
156
+ const extension = extname(String(file?.originalname || '')).toLowerCase();
44
157
 
45
- if (mimetype && extName) {
158
+ if (
159
+ !options?.allowScriptableTypes &&
160
+ (SCRIPTABLE_UPLOAD_MIME_TYPES.includes(mimeType) || SCRIPTABLE_UPLOAD_EXTENSIONS.includes(extension))
161
+ ) {
162
+ return cb(new Error(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
163
+ }
164
+
165
+ const accepted =
166
+ accept instanceof RegExp
167
+ ? accept.test(mimeType) && accept.test(extension)
168
+ : accept.mimeTypes.includes(mimeType) && accept.extensions.includes(extension);
169
+
170
+ if (accepted) {
46
171
  return cb(null, true);
47
172
  }
48
- cb(`Error: File upload only supports the following filetypes - ${fileTypeRegex}`);
173
+ cb(new Error(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
49
174
  };
50
175
  }
51
176
 
177
+ /** Render the accepted types for the rejection message. */
178
+ function describeAccept(accept: RegExp | UploadAllowList): string {
179
+ return accept instanceof RegExp ? String(accept) : accept.extensions.join(', ');
180
+ }
181
+
52
182
  /**
53
183
  * Get multer options for image upload
184
+ *
185
+ * Pass `allowList` for exact matching; `fileTypeRegex` is deprecated (see
186
+ * {@link multerFileFilter}). When neither is set, {@link IMAGE_UPLOAD_ALLOW_LIST}
187
+ * applies.
54
188
  */
55
189
  export function multerOptionsForImageUpload(options: {
190
+ allowList?: UploadAllowList;
56
191
  destination?: string;
57
192
  fileSize?: number;
58
193
  fileTypeRegex?: RegExp;
@@ -60,13 +195,16 @@ export function multerOptionsForImageUpload(options: {
60
195
  // Set config
61
196
  const config = {
62
197
  fileSize: 1024 * 1024, // 1MB
63
- fileTypeRegex: /jpeg|jpg|png/, // Images only
64
198
  ...options,
65
199
  };
66
200
 
201
+ // An explicit regex keeps precedence so existing callers behave as before
202
+ // (minus the scriptable types); otherwise the exact-matching default applies.
203
+ const accept: RegExp | UploadAllowList = config.fileTypeRegex ?? config.allowList ?? IMAGE_UPLOAD_ALLOW_LIST;
204
+
67
205
  return {
68
206
  // File filter
69
- fileFilter: config.fileTypeRegex ? multerFileFilter(config.fileTypeRegex) : undefined,
207
+ fileFilter: multerFileFilter(accept),
70
208
 
71
209
  // Limits
72
210
  limits: {
@@ -74,6 +74,65 @@ export class FileController extends CoreFileController {
74
74
  }
75
75
  ```
76
76
 
77
+ Access can also be restricted per file by overriding `CoreFileService.checkRights()`. When it
78
+ refuses, `getFileStream()` returns `null` and the controller answers **404** — deliberately the same
79
+ answer as an unknown id, so the endpoint cannot be used to probe which files exist. Do not change
80
+ this to a 403 in an override without accepting that trade-off.
81
+
82
+ ### Error responses
83
+
84
+ | Situation | Status | Body |
85
+ | -------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- |
86
+ | Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
87
+ | Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
88
+ | GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
89
+ | GridFS read fails **after** streaming started | — | The connection is closed; a truncated transfer is the only signal left once the status is on the wire |
90
+
91
+ The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
92
+ go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —
93
+ i.e. "the server is down", while every other route keeps answering. On the error path the headers
94
+ describing the file (`Content-Type`, `Content-Disposition`, `Cache-Control`, `ETag`) are removed, so
95
+ the JSON body is not labelled as the image it failed to deliver. The error itself is logged
96
+ server-side even though the client answer stays deliberately generic.
97
+
98
+ To change the status, the body or the logging, override the `protected pipeFileToResponse()` method
99
+ on the controller rather than the exported function of the same name.
100
+
101
+ ### Upload filtering
102
+
103
+ Upload endpoints are project-specific, but the filter they install comes from the framework
104
+ (`multerOptionsForImageUpload()` / `multerFileFilter()` in `common/helpers/file.helper.ts`). Name what
105
+ the endpoint accepts as an `UploadAllowList` — both the mimetype and the extension are compared as
106
+ WHOLE values:
107
+
108
+ ```typescript
109
+ @UseInterceptors(FileInterceptor('file', multerOptionsForImageUpload({
110
+ allowList: {
111
+ extensions: ['.jpeg', '.jpg', '.pdf', '.png'],
112
+ mimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
113
+ },
114
+ })))
115
+ ```
116
+
117
+ The two conditions are **independent**: either one alone rejects the file, while a pair that is odd
118
+ yet individually allowed (`report.txt` announced as `application/pdf`) passes. An extension→mimetype
119
+ MAPPING is deliberately not enforced, because user agents genuinely disagree about office and audio
120
+ types (macOS reports `.csv` as `text/plain`) and a mapping would reject legitimate uploads.
121
+
122
+ The legacy `fileTypeRegex` option still works and keeps precedence, but is **deprecated**: one
123
+ expression is `.test()`ed against both the mimetype and the extension, so every alternative matches
124
+ as a SUBSTRING — an allow-list containing `te?xt` also accepts `text/html`.
125
+
126
+ Types a browser may execute as script (`text/html`, `image/svg+xml`, `application/xhtml+xml`, XML and
127
+ JavaScript types, plus the matching extensions) are rejected **before** the allow-list is consulted,
128
+ on both forms. A stored upload served back from the API origin with one of these content types runs
129
+ in that origin, with the victim's session. Opt out only when the file never reaches an origin that
130
+ carries a session:
131
+
132
+ ```typescript
133
+ multerFileFilter({ extensions: ['.svg'], mimeTypes: ['image/svg+xml'] }, { allowScriptableTypes: true });
134
+ ```
135
+
77
136
  ---
78
137
 
79
138
  ## GraphQL Support
@@ -0,0 +1,164 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import type { Response } from 'express';
3
+ import { PassThrough, Readable } from 'stream';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import type { CoreFileService } from './core-file.service';
7
+ import { CoreFileController, pipeFileToResponse } from './core-file.controller';
8
+
9
+ /**
10
+ * Build a response stub that records what the handler did to it.
11
+ *
12
+ * A real `Response` is not needed here — what is under test is the decision
13
+ * (status vs. destroy), not Express. Writing it out as a stub also makes the
14
+ * "headers already sent" case reachable, which is awkward to force otherwise.
15
+ *
16
+ * What this stub CANNOT show is how Express itself treats headers: `res.json()`
17
+ * only defaults `Content-Type` when none is set, so a stub whose `json()` merely
18
+ * records a body can never reveal a stale one. That property is pinned at the
19
+ * HTTP level in `tests/file.e2e-spec.ts`; here we assert the removal list.
20
+ */
21
+ function responseStub(headersSent = false) {
22
+ const sink = new PassThrough();
23
+ const calls = {
24
+ destroyed: false,
25
+ json: undefined as unknown,
26
+ removed: [] as string[],
27
+ set: {} as Record<string, string>,
28
+ status: 0,
29
+ };
30
+
31
+ const res = Object.assign(sink, {
32
+ destroy: () => {
33
+ calls.destroyed = true;
34
+ return res;
35
+ },
36
+ headersSent,
37
+ json: (body: unknown) => {
38
+ calls.json = body;
39
+ return res;
40
+ },
41
+ removeHeader: (name: string) => {
42
+ calls.removed.push(name);
43
+ },
44
+ setHeader: (name: string, value: string) => {
45
+ calls.set[name] = value;
46
+ return res;
47
+ },
48
+ status: (code: number) => {
49
+ calls.status = code;
50
+ return res;
51
+ },
52
+ }) as unknown as Response;
53
+
54
+ return { calls, res };
55
+ }
56
+
57
+ describe('pipeFileToResponse', () => {
58
+ it('turns a read error into 404 while nothing has been written yet', async () => {
59
+ // A GridFS record and its bytes are two separate writes, so the record can
60
+ // outlive the chunks. GridFS reports that on the stream, asynchronously.
61
+ // A bare `stream.pipe(res)` installs no error handler, so the error goes
62
+ // unhandled, Node destroys the socket mid-response, and any reverse proxy
63
+ // in front reports 502 Bad Gateway — "the server is down", which is the one
64
+ // diagnosis that is wrong while every other route answers normally.
65
+ const stream = new Readable({ read() {} });
66
+ const { calls, res } = responseStub(false);
67
+
68
+ pipeFileToResponse(stream, res);
69
+ stream.emit('error', new Error('FileNotFound: file 0123456789ab was not found'));
70
+ await new Promise((resolve) => setImmediate(resolve));
71
+
72
+ expect(calls.status).toBe(404);
73
+ expect(calls.json).toMatchObject({ statusCode: 404 });
74
+ expect(calls.destroyed).toBe(false);
75
+ });
76
+
77
+ it('drops every header that describes the file, not just the download one', async () => {
78
+ // Each of these would otherwise survive onto the error response:
79
+ // `Content-Disposition` offers a JSON error body as a file to save, a long
80
+ // `Cache-Control` lets the client cache the failure, `ETag` describes bytes
81
+ // that were never sent — and `Content-Type` makes an ofetch/`$fetch` client
82
+ // parse the JSON as an image and lose the message entirely.
83
+ const stream = new Readable({ read() {} });
84
+ const { calls, res } = responseStub(false);
85
+
86
+ pipeFileToResponse(stream, res);
87
+ stream.emit('error', new Error('chunks missing'));
88
+ await new Promise((resolve) => setImmediate(resolve));
89
+
90
+ expect(calls.removed).toEqual(
91
+ expect.arrayContaining(['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag']),
92
+ );
93
+ expect(calls.set['X-Content-Type-Options']).toBe('nosniff');
94
+ });
95
+
96
+ it('closes the connection once bytes are already on the wire', async () => {
97
+ // Past the first byte there is no status left to send. Dropping the
98
+ // connection is the only remaining signal — and it is the correct one: it
99
+ // is what a truncated transfer looks like, so the client does not cache a
100
+ // half file as if it were complete.
101
+ const stream = new Readable({ read() {} });
102
+ const { calls, res } = responseStub(true);
103
+
104
+ pipeFileToResponse(stream, res);
105
+ stream.emit('error', new Error('connection lost mid-stream'));
106
+ await new Promise((resolve) => setImmediate(resolve));
107
+
108
+ expect(calls.destroyed).toBe(true);
109
+ expect(calls.status).toBe(0);
110
+ });
111
+
112
+ it('answers a refused download exactly like an unknown id', async () => {
113
+ // `getFileStream()` returns null when the service's own `checkRights()`
114
+ // refuses — a branch only a CONSUMER project can reach, since the framework's
115
+ // base implementation always returns true. That is precisely why the
116
+ // framework has to pin it: consumers cannot, and if the two answers ever
117
+ // diverge (403 here, 404 there, or different messages) the endpoint becomes
118
+ // an existence oracle for files the caller may not read.
119
+ // Invoked through the prototype rather than a subclass: `CoreFileController`
120
+ // has a protected constructor (it is abstract by design), and a subclass added
121
+ // only to widen that visibility is exactly what `no-useless-constructor`
122
+ // strips — leaving code that no longer compiles.
123
+ const download = (service: Partial<CoreFileService>, res: Response) =>
124
+ (CoreFileController.prototype.getFileById as (this: unknown, id: string, res: Response) => Promise<unknown>).call(
125
+ { fileService: service },
126
+ 'abc',
127
+ res,
128
+ );
129
+
130
+ const file = { contentType: 'image/png', filename: 'secret.png', id: 'abc' };
131
+ const { res } = responseStub(false);
132
+
133
+ const refusedErr = await download(
134
+ { getFileInfo: async () => file, getFileStream: async () => null } as unknown as Partial<CoreFileService>,
135
+ res,
136
+ ).catch((e: unknown) => e);
137
+ const unknownErr = await download(
138
+ { getFileInfo: async () => null, getFileStream: async () => null } as unknown as Partial<CoreFileService>,
139
+ res,
140
+ ).catch((e: unknown) => e);
141
+
142
+ expect(refusedErr).toBeInstanceOf(NotFoundException);
143
+ expect(unknownErr).toBeInstanceOf(NotFoundException);
144
+ // Byte-identical answers: same status, same message.
145
+ expect((refusedErr as NotFoundException).getStatus()).toBe((unknownErr as NotFoundException).getStatus());
146
+ expect((refusedErr as NotFoundException).getResponse()).toEqual((unknownErr as NotFoundException).getResponse());
147
+ });
148
+
149
+ it('destroys the source stream when the client goes away', async () => {
150
+ // `pipe()` only ever unpipes the DESTINATION; it never destroys the source.
151
+ // On a public download route an aborted request would therefore leak the
152
+ // GridFS read stream and its server-side cursor until the cursor timeout.
153
+ const stream = new Readable({ read() {} });
154
+ const { res } = responseStub(false);
155
+
156
+ pipeFileToResponse(stream, res);
157
+ expect(stream.destroyed).toBe(false);
158
+
159
+ res.emit('close');
160
+ await new Promise((resolve) => setImmediate(resolve));
161
+
162
+ expect(stream.destroyed).toBe(true);
163
+ });
164
+ });
@@ -1,10 +1,82 @@
1
- import { BadRequestException, Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
2
- import { Response } from 'express';
1
+ import { BadRequestException, Controller, Get, Logger, NotFoundException, Param, Res } from '@nestjs/common';
2
+ import type { Response } from 'express';
3
+ import type { Readable } from 'stream';
3
4
 
4
5
  import { Roles } from '../../common/decorators/roles.decorator';
5
6
  import { RoleEnum } from '../../common/enums/role.enum';
7
+ import { ErrorCode } from '../error-code/error-codes';
6
8
  import { CoreFileService } from './core-file.service';
7
9
 
10
+ const fileStreamLogger = new Logger('CoreFileController');
11
+
12
+ /**
13
+ * Headers that describe the FILE and must not survive onto an error response.
14
+ *
15
+ * Deliberately not the whole set: CORS headers are already on the response at
16
+ * this point, and removing those would turn a readable 404 into an opaque CORS
17
+ * failure in the browser — hiding the very answer this handler exists to give.
18
+ *
19
+ * `Content-Type` is the one most easily missed. Express only defaults it in
20
+ * `res.json()` when nothing is set yet (`if (!this.get('Content-Type'))`), so the
21
+ * file's own type would otherwise label a JSON body as `image/png` — and an
22
+ * ofetch/`$fetch` client picks its parser from that header and hands the caller a
23
+ * Blob instead of the error message.
24
+ */
25
+ const FILE_DELIVERY_HEADERS = ['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag'];
26
+
27
+ /**
28
+ * Pipe a GridFS download to the response without letting a read error kill the socket.
29
+ *
30
+ * A GridFS record and its chunks are two separate writes, so a file document can
31
+ * outlive its bytes — an interrupted upload, a restored backup, a manual cleanup.
32
+ * GridFS reports that asynchronously, on the stream, and `stream.pipe(res)` alone
33
+ * installs no error handler: the error goes unhandled, Node destroys the socket
34
+ * mid-response, and any reverse proxy in front turns that into **502 Bad Gateway**.
35
+ * That reads as "the server is down" — the one diagnosis that is wrong here, while
36
+ * every other route keeps answering normally.
37
+ *
38
+ * Nothing has been written when GridFS reports a missing file, so the status is
39
+ * still ours to set and the answer becomes an honest 404. Once bytes are on the
40
+ * wire there is no status left to send and closing the connection is all that
41
+ * remains — but at that point it genuinely is a truncated transfer, which is
42
+ * exactly what a dropped connection means to the client.
43
+ *
44
+ * Note on `pipe()` vs `stream.pipeline()`: pipeline would destroy BOTH streams on
45
+ * error, including the response — which is precisely the object still needed to
46
+ * send the 404. So the source is cleaned up explicitly on `res` close instead;
47
+ * `pipe()` only ever unpipes the destination and would otherwise leave the
48
+ * GridFS read stream and its server-side cursor open on every aborted download.
49
+ */
50
+ export function pipeFileToResponse(stream: Readable, res: Response): Response {
51
+ res.on('close', () => {
52
+ if (!stream.destroyed) {
53
+ stream.destroy();
54
+ }
55
+ });
56
+
57
+ stream.on('error', (err: Error) => {
58
+ // The answer to the CLIENT stays deliberately indistinguishable from an
59
+ // unknown id, but the server must not lose the event: a files document
60
+ // without its chunks is data corruption, and an error silently converted
61
+ // into a 404 is one nobody will ever notice.
62
+ fileStreamLogger.error(`GridFS download failed: ${err.message}`, err.stack);
63
+
64
+ if (res.headersSent) {
65
+ res.destroy();
66
+ return;
67
+ }
68
+ for (const header of FILE_DELIVERY_HEADERS) {
69
+ res.removeHeader(header);
70
+ }
71
+ // No global nosniff on this route, and the body is now correctly typed —
72
+ // set it anyway so a mislabelled response can never be sniffed into markup.
73
+ res.setHeader('X-Content-Type-Options', 'nosniff');
74
+ res.status(404).json({ error: 'Not Found', message: ErrorCode.FILE_NOT_FOUND, statusCode: 404 });
75
+ });
76
+
77
+ return stream.pipe(res);
78
+ }
79
+
8
80
  /**
9
81
  * File controller
10
82
  */
@@ -16,6 +88,18 @@ export abstract class CoreFileController {
16
88
  */
17
89
  protected constructor(protected fileService: CoreFileService) {}
18
90
 
91
+ /**
92
+ * Stream a file to the response.
93
+ *
94
+ * Delegates to the module-level {@link pipeFileToResponse} (which stays exported
95
+ * so it can be unit-tested without a Nest context). Override this to change the
96
+ * error status, the body shape or the logging for a project — the free function
97
+ * alone would not be reachable from a subclass.
98
+ */
99
+ protected pipeFileToResponse(stream: Readable, res: Response): Response {
100
+ return pipeFileToResponse(stream, res);
101
+ }
102
+
19
103
  /**
20
104
  * Download file by ID
21
105
  *
@@ -26,17 +110,22 @@ export abstract class CoreFileController {
26
110
  @Roles(RoleEnum.S_EVERYONE)
27
111
  async getFileById(@Param('id') id: string, @Res() res: Response) {
28
112
  if (!id) {
29
- throw new BadRequestException('Missing file ID for download');
113
+ throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
30
114
  }
31
115
 
32
116
  const file = await this.fileService.getFileInfo(id);
33
117
  if (!file) {
34
- throw new NotFoundException('File not found');
118
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
35
119
  }
36
120
  const filestream = await this.fileService.getFileStream(id);
121
+ // `getFileStream` answers null when the service's own rights check refuses.
122
+ // Same answer as an unknown id: never confirm that the file exists.
123
+ if (!filestream) {
124
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
125
+ }
37
126
  res.header('Content-Type', file.contentType || 'application/octet-stream');
38
127
  res.header('Content-Disposition', `attachment; filename=${file.filename}`);
39
- return filestream.pipe(res);
128
+ return this.pipeFileToResponse(filestream, res);
40
129
  }
41
130
 
42
131
  /**
@@ -49,16 +138,19 @@ export abstract class CoreFileController {
49
138
  @Roles(RoleEnum.S_EVERYONE)
50
139
  async getFile(@Param('filename') filename: string, @Res() res: Response) {
51
140
  if (!filename) {
52
- throw new BadRequestException('Missing filename for download');
141
+ throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
53
142
  }
54
143
 
55
144
  const file = await this.fileService.getFileInfoByName(filename);
56
145
  if (!file) {
57
- throw new NotFoundException('File not found');
146
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
58
147
  }
59
148
  const filestream = await this.fileService.getFileStream(file.id);
149
+ if (!filestream) {
150
+ throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
151
+ }
60
152
  res.header('Content-Type', file.contentType || 'application/octet-stream');
61
153
  res.header('Content-Disposition', `attachment; filename=${file.filename}`);
62
- return filestream.pipe(res);
154
+ return this.pipeFileToResponse(filestream, res);
63
155
  }
64
156
  }
@@ -387,6 +387,41 @@ const fileId = await uploadFileToGridFS('mongodb://localhost/mydb', '../assets/i
387
387
  });
388
388
  ```
389
389
 
390
+ `relativePath` is resolved against the **helper module's** directory, not the migration file's — the
391
+ paths in this example and in the migration template are written accordingly.
392
+
393
+ The returned promise settles only after three guarantees hold:
394
+
395
+ | Guarantee | Behaviour |
396
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
+ | The bytes are stored | Chunk completeness is verified before resolving (see `assertGridFsFileComplete()` below). An incomplete upload **rejects** and the incomplete file is removed, rather than returning an id that points at nothing |
398
+ | An unreadable source fails fast | A missing or unreadable file **rejects** with the underlying `ENOENT`. It used to hang forever, because `pipe()` does not forward read-stream errors |
399
+ | The connection is released | The MongoClient it opens is always closed, on the success and the failure path alike. A leaked connection keeps the Node event loop alive and `migrate up` never exits |
400
+
401
+ Since these are hard failures, a seed migration whose asset is missing or only partially stored now
402
+ fails the migration — and with the default `MIGRATE_FAILURE_POLICY=abort` in `docker-entrypoint.sh`,
403
+ the container refuses to start rather than booting with broken data. That is intentional: the point
404
+ of running migrations before the server is to find exactly this.
405
+
406
+ ### assertGridFsFileComplete()
407
+
408
+ Verifies that every chunk of an already-stored GridFS file is present. `uploadFileToGridFS()` calls
409
+ it for you; call it directly to check files written by something else (a restored dump, another
410
+ service, a manual upload):
411
+
412
+ ```typescript
413
+ import { assertGridFsFileComplete, getDb } from '@lenne.tech/nest-server';
414
+
415
+ const db = await getDb('mongodb://localhost/mydb');
416
+ await assertGridFsFileComplete(db, 'images', fileId, 'logo.png'); // throws if incomplete
417
+ ```
418
+
419
+ Throws when the files document is missing entirely, or when fewer chunks are stored than its
420
+ `length` implies. It counts chunk documents rather than reading bytes back, so a chunk that was
421
+ written but truncated is **not** detected — the check targets the failure that actually occurs, a
422
+ connection lost mid-upload. Empty files are valid and pass: GridFS stores a zero-byte file with no
423
+ chunk documents at all.
424
+
390
425
  ### Migration Templates
391
426
 
392
427
  Ready-to-use template for nest-server projects: