@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.
- package/FRAMEWORK-API.md +1 -1
- package/bin/migrate.js +13 -3
- package/dist/core/common/helpers/file.helper.d.ts +14 -2
- package/dist/core/common/helpers/file.helper.js +48 -9
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +4 -1
- package/dist/core/modules/file/core-file.controller.js +39 -6
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
- package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +9 -2
- package/migration-guides/11.32.3-to-11.32.4.md +323 -0
- package/package.json +1 -1
- package/src/core/common/helpers/file.helper.spec.ts +145 -0
- package/src/core/common/helpers/file.helper.ts +148 -10
- package/src/core/modules/file/README.md +59 -0
- package/src/core/modules/file/core-file.controller.spec.ts +164 -0
- package/src/core/modules/file/core-file.controller.ts +100 -8
- package/src/core/modules/migrate/README.md +35 -0
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
- 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(
|
|
22
|
-
|
|
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
|
-
*
|
|
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(
|
|
150
|
+
export function multerFileFilter(
|
|
151
|
+
accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
|
|
152
|
+
options?: MulterFileFilterOptions,
|
|
153
|
+
) {
|
|
41
154
|
return (req, file, cb) => {
|
|
42
|
-
const
|
|
43
|
-
const
|
|
155
|
+
const mimeType = normalizeMimeType(file?.mimetype);
|
|
156
|
+
const extension = extname(String(file?.originalname || '')).toLowerCase();
|
|
44
157
|
|
|
45
|
-
if (
|
|
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(`
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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:
|