@lenne.tech/nest-server 11.41.6 → 11.41.7
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/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/package-management.md +8 -0
- package/.claude/rules/testing.md +12 -9
- package/CLAUDE.md +1 -1
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +8 -0
- package/dist/core/common/services/core-s3.service.js +44 -3
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +10 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.service.js +3 -2
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +1 -0
- package/dist/core/modules/file/s3-file.helper.d.ts +1 -0
- package/dist/core/modules/file/s3-file.helper.js +10 -4
- package/dist/core/modules/file/s3-file.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/docs/security-overrides.md +1 -1
- package/migration-guides/11.41.6-to-11.41.7.md +192 -0
- package/package.json +18 -14
- package/src/core/common/helpers/file.helper.ts +4 -0
- package/src/core/common/helpers/gridfs.helper.ts +1 -1
- package/src/core/common/services/core-s3.service.ts +125 -7
- package/src/core/modules/ai/README.md +8 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +33 -0
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +39 -11
- package/src/core/modules/file/README.md +30 -5
- package/src/core/modules/file/core-file.service.ts +15 -6
- package/src/core/modules/file/interfaces/file-upload.interface.ts +18 -0
- package/src/core/modules/file/s3-file.helper.ts +28 -9
|
@@ -88,6 +88,31 @@ If some files are genuinely public and others are not, do not solve it with a ro
|
|
|
88
88
|
record a visibility flag in the metadata and branch in `checkRights()`, or expose a separate public
|
|
89
89
|
route for exactly the public files and leave the core routes gated.
|
|
90
90
|
|
|
91
|
+
### 6. Switching to the S3 driver — in this order
|
|
92
|
+
|
|
93
|
+
Each step fails on its own if skipped, and only the first one fails loudly:
|
|
94
|
+
|
|
95
|
+
1. **Forward the services to `super()`.** `FileService` must call
|
|
96
|
+
`super(connection, 'fs', { configService, s3Service })`, as the reference implementation does. With
|
|
97
|
+
`super(connection)` the S3 store counts as unavailable and `file.storage: 's3'` fails the boot
|
|
98
|
+
with "that storage is not available".
|
|
99
|
+
2. **Install the S3 packages as `dependencies`, not `devDependencies`:** `@aws-sdk/client-s3` and
|
|
100
|
+
`@aws-sdk/lib-storage` (same version), plus `@aws-sdk/s3-request-presigner` for
|
|
101
|
+
`s3.presignedDownloads`. A production image installed with `--prod` otherwise boots without them.
|
|
102
|
+
Vendor mode has them installed already (the vendored core references their types) — check which
|
|
103
|
+
section they are in.
|
|
104
|
+
3. **Count stored files across all three metadata collections.** `fs.files` is GridFS only; the
|
|
105
|
+
filesystem driver records in `filesystem-files` and S3 in `s3-files`. Project code that
|
|
106
|
+
aggregates sizes or counts directly — a quota, a statistic — must cover all three, or it reads 0
|
|
107
|
+
after the switch and the quota never refuses anything. `findFileInfo()` already merges every
|
|
108
|
+
store.
|
|
109
|
+
4. **Then switch the driver** — `file.storage: 's3'`, or leave it unset and let `s3.bucket` derive
|
|
110
|
+
it. Files written before stay readable: reads consult every store.
|
|
111
|
+
|
|
112
|
+
An `s3` block with a `bucket` turns `CoreS3Service` on whatever `file.storage` says (TUS, where enabled, stages
|
|
113
|
+
uploads in S3 as soon as it is on), so it needs `@aws-sdk/client-s3` at boot. To prepare the block
|
|
114
|
+
without activating it, set `enabled: false`.
|
|
115
|
+
|
|
91
116
|
## Pick your project class first — the whole model is one dial with five settings
|
|
92
117
|
|
|
93
118
|
`file.access` is the per-file rule as a DECLARATION instead of code. Until 11.35.0 the framework shipped
|
|
@@ -201,14 +226,17 @@ personal or medical, a per-file rule is not optional.
|
|
|
201
226
|
|
|
202
227
|
## Common Mistakes
|
|
203
228
|
|
|
204
|
-
| Mistake
|
|
205
|
-
|
|
|
206
|
-
| Overriding `getFileById` to add Swagger decorators
|
|
207
|
-
| Overriding without re-declaring `@Get(...)`
|
|
208
|
-
| `downloadRoles: []`
|
|
209
|
-
| Expecting `metadata` back from `getFileInfo()`
|
|
210
|
-
| `downloadRoles: ['member']` with multiTenancy
|
|
211
|
-
| Signed-in user can upload via TUS but not download
|
|
212
|
-
| Rule narrows only `'id'` / `'filename'`
|
|
213
|
-
| Treating the file id as unguessable
|
|
214
|
-
| Approving the caller's `filterArgs` as "narrowed"
|
|
229
|
+
| Mistake | Symptom | Fix |
|
|
230
|
+
| -------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| Overriding `getFileById` to add Swagger decorators | `file.downloadRoles` silently ignored | Don't override; document at class level |
|
|
232
|
+
| Overriding without re-declaring `@Get(...)` | Route 404s for everyone, no error logged | Don't override |
|
|
233
|
+
| `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
|
|
234
|
+
| Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
|
|
235
|
+
| `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
|
|
236
|
+
| Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
|
|
237
|
+
| Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
|
|
238
|
+
| Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
|
|
239
|
+
| Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
|
|
240
|
+
| `super(connection)` in `FileService` with `file.storage: 's3'` | Boot fails: "that storage is not available" | Forward `{ configService, s3Service }` — step 6 |
|
|
241
|
+
| S3 packages in `devDependencies` | Production image cannot load S3 | Move them to `dependencies` — step 6 |
|
|
242
|
+
| A quota aggregating only `fs.files` | Reads 0 on the S3 / filesystem driver | Aggregate `fs.files`, `filesystem-files` and `s3-files` — step 6 |
|
|
@@ -45,11 +45,11 @@ file: {
|
|
|
45
45
|
|
|
46
46
|
Three equivalent options. They differ only in where the bytes end up:
|
|
47
47
|
|
|
48
|
-
| Driver | Bytes | Survives a restart | Shared between replicas | Needs
|
|
49
|
-
| -------------- | ------------------------- | ------------------------ | ----------------------- |
|
|
50
|
-
| `'s3'` | S3-compatible bucket | yes | **yes** | `s3` config + `@aws-sdk/client-s3` |
|
|
51
|
-
| `'gridfs'` | MongoDB GridFS | yes | yes | nothing beyond the database
|
|
52
|
-
| `'filesystem'` | local disk (`storageDir`) | only on a mounted volume | **no** | nothing
|
|
48
|
+
| Driver | Bytes | Survives a restart | Shared between replicas | Needs |
|
|
49
|
+
| -------------- | ------------------------- | ------------------------ | ----------------------- | ------------------------------------------------------------------------ |
|
|
50
|
+
| `'s3'` | S3-compatible bucket | yes | **yes** | `s3` config + `@aws-sdk/client-s3` (+ `@aws-sdk/lib-storage`, see below) |
|
|
51
|
+
| `'gridfs'` | MongoDB GridFS | yes | yes | nothing beyond the database |
|
|
52
|
+
| `'filesystem'` | local disk (`storageDir`) | only on a mounted volume | **no** | nothing |
|
|
53
53
|
|
|
54
54
|
**Metadata always lives in the database**, whichever driver holds the bytes. Filename, content type,
|
|
55
55
|
length and the custom `metadata` a per-file rule reads have to be queryable — `findFileInfo()` filters
|
|
@@ -57,6 +57,31 @@ and pages over them, `checkRights()` reads them per request. A directory listing
|
|
|
57
57
|
that, and sidecar files would reinvent an index the database already is. So `'filesystem'` moves the
|
|
58
58
|
bytes off the database, not the bookkeeping.
|
|
59
59
|
|
|
60
|
+
### How an upload reaches S3
|
|
61
|
+
|
|
62
|
+
No upload is read into memory on its way to S3. Which request carries it depends on whether its
|
|
63
|
+
length is known:
|
|
64
|
+
|
|
65
|
+
| Length | Request | Memory |
|
|
66
|
+
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------ |
|
|
67
|
+
| known, up to 5 GiB — `size` on the upload, a TUS upload, a buffer | one streamed `PutObject` | constant |
|
|
68
|
+
| known, above 5 GiB | multipart upload via `@aws-sdk/lib-storage` (AWS S3 refuses larger single requests) | at most four parts |
|
|
69
|
+
| unknown — every GraphQL and streamed REST upload | multipart upload via `@aws-sdk/lib-storage` | at most four 5 MiB parts |
|
|
70
|
+
| unknown, and `@aws-sdk/lib-storage` not installed | one `PutObject` of the whole file, read into memory first | **the file's size** |
|
|
71
|
+
|
|
72
|
+
Install the optional peer next to `@aws-sdk/client-s3` (`pnpm add @aws-sdk/lib-storage`, same
|
|
73
|
+
version). Without it uploads still work, but a large one of unknown length costs its own size in
|
|
74
|
+
memory — a multi-GB file then gets the container OOM-killed — and a known length above 5 GiB goes up
|
|
75
|
+
as one request, which AWS S3 refuses (MinIO and RustFS accept it). The boot log says so once when
|
|
76
|
+
the package is missing.
|
|
77
|
+
|
|
78
|
+
Pass `size` on a `FileUploadSource` only when it is the file's EXACT length — a `stat()` of a file on
|
|
79
|
+
disk. Never pass the `Content-Length` of a multipart/form-data request: it counts the whole envelope
|
|
80
|
+
and is always larger than the file, and a wrong length breaks the upload. Leaving `size` out costs
|
|
81
|
+
nothing but the multipart round trips, and for data already in memory it is the better choice: the
|
|
82
|
+
SDK retries the Buffer parts of a multipart upload after a transient S3 error, but not one streamed
|
|
83
|
+
request.
|
|
84
|
+
|
|
60
85
|
### Choosing the driver
|
|
61
86
|
|
|
62
87
|
**Set it explicitly and it is enforced.** If the chosen store is not available, the boot **fails**:
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
FilesystemFileHelper,
|
|
32
32
|
FilesystemFileInfo,
|
|
33
33
|
} from './filesystem-file.helper';
|
|
34
|
-
import { S3_FILES_COLLECTION, S3FileHelper, S3FileInfo
|
|
34
|
+
import { S3_FILES_COLLECTION, S3FileHelper, S3FileInfo } from './s3-file.helper';
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Type for checking input
|
|
@@ -160,7 +160,7 @@ export abstract class CoreFileService {
|
|
|
160
160
|
if (!(await this.checkRights(file, { ...serviceOptions, checkInputType: 'file' }))) {
|
|
161
161
|
return null;
|
|
162
162
|
}
|
|
163
|
-
const { createReadStream, filename, mimetype } = await file;
|
|
163
|
+
const { createReadStream, filename, mimetype, size } = await file;
|
|
164
164
|
const readStream = createReadStream();
|
|
165
165
|
// Resolved ONCE for all three driver branches: under an `'owner'` / `'tenant'` preset this adds
|
|
166
166
|
// the very fields the preset decides on, so an upload through this service is authorizable
|
|
@@ -176,11 +176,20 @@ export abstract class CoreFileService {
|
|
|
176
176
|
return this.prepareOutput(fsFileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
177
177
|
}
|
|
178
178
|
if (this.storageDriver === 's3') {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// The stream is handed to S3 as it is, never read into memory here. With a `size` the object
|
|
180
|
+
// goes up as one streamed PutObject; without one, `CoreS3Service.putObject()` uses a multipart
|
|
181
|
+
// upload, which needs no total up front (and only buffers when its optional peer dependency
|
|
182
|
+
// is missing). Reading the stream into a Buffer at this point — what this branch used to do
|
|
183
|
+
// for every upload — materialised the whole file in one process: a multi-GB upload then
|
|
184
|
+
// threw "Array buffer allocation failed" or got the container OOM-killed at 100 % progress,
|
|
185
|
+
// taking every other in-flight request with it.
|
|
186
|
+
//
|
|
187
|
+
// A GraphQL or streamed REST upload carries no trustworthy length (a multipart request's
|
|
188
|
+
// `Content-Length` counts the whole envelope), so the unknown-length case is the common one,
|
|
189
|
+
// not a fallback.
|
|
182
190
|
const s3FileInfo = await S3FileHelper.writeFile(this.options.s3Service, this.s3Files, {
|
|
183
|
-
|
|
191
|
+
body: readStream,
|
|
192
|
+
contentLength: size, // validated in writeFile(): anything but a positive integer counts as unknown
|
|
184
193
|
contentType: mimetype,
|
|
185
194
|
filename,
|
|
186
195
|
...(metadata ? { metadata } : {}),
|
|
@@ -15,6 +15,24 @@ export interface FileUploadSource {
|
|
|
15
15
|
encoding?: string;
|
|
16
16
|
filename: string;
|
|
17
17
|
mimetype: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* EXACT size of the file in bytes, when the caller knows it — otherwise leave it out.
|
|
21
|
+
*
|
|
22
|
+
* Only the S3 driver reads it: with a size the object goes up as one streamed request (as a
|
|
23
|
+
* multipart upload above 5 GiB, which AWS S3 refuses in one request), without one it goes up as a
|
|
24
|
+
* multipart upload (`@aws-sdk/lib-storage`, an optional peer — without it the
|
|
25
|
+
* file is read into memory). Omitting it is not a fallback: a GraphQL or streamed REST upload never
|
|
26
|
+
* knows its length, and that is the common case.
|
|
27
|
+
*
|
|
28
|
+
* A WRONG size breaks the upload instead of slowing it down. So never pass the `Content-Length` of a
|
|
29
|
+
* multipart/form-data request: it counts the whole envelope (boundaries, part headers, other
|
|
30
|
+
* fields) and is always larger than the file. Pass it only when it is the file's own length — a
|
|
31
|
+
* TUS upload's `upload.size`, a `stat()` of a file on disk. For data already in memory leave it
|
|
32
|
+
* out: the SDK retries the Buffer parts of a multipart upload after a transient S3 error, but not
|
|
33
|
+
* one streamed request. Anything but a positive integer is treated as unknown.
|
|
34
|
+
*/
|
|
35
|
+
size?: number;
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
/**
|
|
@@ -30,6 +30,20 @@ export const S3_FILES_COLLECTION = 's3-files';
|
|
|
30
30
|
*/
|
|
31
31
|
export type S3FileInfo = FileMetadataInfo;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Whether an upload size can be handed to S3 as the object's `Content-Length`.
|
|
35
|
+
*
|
|
36
|
+
* Only a positive safe integer qualifies. Anything else — `undefined`, `0`, a negative number,
|
|
37
|
+
* `NaN`, a fraction — is treated as "length unknown" and goes through the multipart path, which
|
|
38
|
+
* needs no total. The direction matters: a WRONG length is not a slower upload but a broken one,
|
|
39
|
+
* whereas an unknown length only costs the multipart round trips. `0` is included in "unknown" because it is
|
|
40
|
+
* what an unset numeric field defaults to far more often than it is a real empty file — and an
|
|
41
|
+
* empty file stores correctly through the multipart path too.
|
|
42
|
+
*/
|
|
43
|
+
export function isKnownUploadSize(size: unknown): size is number {
|
|
44
|
+
return typeof size === 'number' && Number.isSafeInteger(size) && size > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* Read a stream completely into a buffer
|
|
35
49
|
*/
|
|
@@ -70,21 +84,22 @@ export class S3FileHelper {
|
|
|
70
84
|
const _id = new Types.ObjectId();
|
|
71
85
|
const key = _id.toHexString();
|
|
72
86
|
|
|
73
|
-
// A stream
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
87
|
+
// A stream is never read into a Buffer here. Doing so — which every caller used to do —
|
|
88
|
+
// materialises the WHOLE file in one process: a 4 GB resumable upload (well inside the 50 GB
|
|
89
|
+
// default cap) then either throws "Array buffer allocation failed" or gets the container
|
|
90
|
+
// OOM-killed at 100% progress, taking every other in-flight request with it. With a known
|
|
91
|
+
// length the stream goes up as one PutObject; without one, `putObject()` uploads it in parts.
|
|
78
92
|
const body = options.body ?? options.buffer;
|
|
79
93
|
if (!body) {
|
|
80
94
|
throw new Error('S3FileHelper.writeFile needs either a body stream or a buffer');
|
|
81
95
|
}
|
|
82
|
-
|
|
96
|
+
const contentLength = isKnownUploadSize(options.contentLength) ? options.contentLength : options.buffer?.length;
|
|
97
|
+
await s3Service.putObject(key, body, options.contentType, contentLength);
|
|
83
98
|
|
|
84
99
|
return S3FileHelper.recordFile(s3Service, collection, _id, {
|
|
85
100
|
contentType: options.contentType,
|
|
86
101
|
filename: options.filename,
|
|
87
|
-
length:
|
|
102
|
+
length: contentLength,
|
|
88
103
|
metadata: options.metadata,
|
|
89
104
|
});
|
|
90
105
|
}
|
|
@@ -134,7 +149,11 @@ export class S3FileHelper {
|
|
|
134
149
|
_id: Types.ObjectId,
|
|
135
150
|
options: { contentType?: string; filename: string; length?: number; metadata?: Record<string, any> },
|
|
136
151
|
): Promise<S3FileInfo> {
|
|
137
|
-
|
|
152
|
+
// One HEAD answers both questions: whether the bytes arrived, and — for a stream whose length
|
|
153
|
+
// nobody knew up front — how many there are. The stored object is the authority on its size;
|
|
154
|
+
// a length guessed by the caller (a multipart request's `Content-Length`, say) is not.
|
|
155
|
+
const stat = await s3Service.statObject(_id.toHexString());
|
|
156
|
+
if (!stat) {
|
|
138
157
|
throw new Error('File uploaded but not found in S3');
|
|
139
158
|
}
|
|
140
159
|
|
|
@@ -147,7 +166,7 @@ export class S3FileHelper {
|
|
|
147
166
|
_id,
|
|
148
167
|
contentType: options.contentType,
|
|
149
168
|
filename: options.filename,
|
|
150
|
-
length: options.length,
|
|
169
|
+
length: options.length ?? stat.contentLength,
|
|
151
170
|
metadata: options.metadata,
|
|
152
171
|
// Records WHERE the bytes went, so a reader never has to probe all three
|
|
153
172
|
// stores to find out. Legacy documents lack it and are handled by probing.
|