@lenne.tech/nest-server 11.33.1 → 11.34.1
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/testing.md +181 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +43 -5
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/helpers/validation-message.helper.d.ts +3 -0
- package/dist/core/common/helpers/validation-message.helper.js +41 -0
- package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
- package/dist/core/common/pipes/map-and-validate.pipe.js +16 -6
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +4 -0
- package/dist/core/common/services/core-s3.service.js +28 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +4 -2
- package/dist/core/modules/file/core-file.service.js +46 -47
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
- package/dist/core/modules/hub/hub-action-messages.js +1 -0
- package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
- package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
- package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
- package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
- package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +2 -1
- package/dist/server/modules/file/file.service.js +2 -2
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.23.x-to-11.24.0.md +1 -1
- package/migration-guides/11.32.x-to-11.33.x.md +2 -0
- package/migration-guides/11.33.x-to-11.34.x.md +394 -0
- package/migration-guides/11.34.0-to-11.34.1.md +132 -0
- package/package.json +3 -1
- package/src/core/common/helpers/gridfs.helper.ts +92 -7
- package/src/core/common/helpers/validation-message.helper.ts +83 -0
- package/src/core/common/pipes/map-and-validate.pipe.ts +25 -7
- package/src/core/common/services/core-s3.service.ts +71 -9
- package/src/core/modules/file/README.md +42 -6
- package/src/core/modules/file/core-file.service.ts +131 -59
- package/src/core/modules/hub/README.md +35 -9
- package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
- package/src/core/modules/hub/hub-action-messages.ts +9 -1
- package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
- package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
- package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
- package/src/server/modules/file/file.service.ts +20 -3
|
@@ -72,7 +72,22 @@ export class GridFSHelper {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Write a file to GridFS from a stream
|
|
75
|
+
* Write a file to GridFS from a stream.
|
|
76
|
+
*
|
|
77
|
+
* **The SOURCE stream needs its own error handler.** `pipe()` does not forward
|
|
78
|
+
* errors, so an error on `stream` used to have no listener at all: Node turns an
|
|
79
|
+
* unhandled `'error'` event into an uncaught exception, which takes the whole
|
|
80
|
+
* process down. That is reachable from ordinary traffic — a client that aborts a
|
|
81
|
+
* GraphQL upload mid-body errors the capacitor stream — and it was
|
|
82
|
+
* driver-conditional: the same abort is a rejected promise under the S3 driver
|
|
83
|
+
* (`streamToBuffer` throws) and under the filesystem driver (`pipeline()`
|
|
84
|
+
* forwards both ends), and a process crash under GridFS, the pre-11.33 default.
|
|
85
|
+
* The migration helper's `uploadFileToGridFS()` already carried this handler; the
|
|
86
|
+
* one on the request path did not.
|
|
87
|
+
*
|
|
88
|
+
* The partial upload is aborted rather than left behind: without it the failed
|
|
89
|
+
* write keeps its chunks in `fs.chunks` with no `fs.files` document naming them,
|
|
90
|
+
* which nothing can ever find or clean up.
|
|
76
91
|
*/
|
|
77
92
|
static writeFileFromStream(
|
|
78
93
|
bucket: GridFSBucket,
|
|
@@ -90,8 +105,37 @@ export class GridFSHelper {
|
|
|
90
105
|
metadata,
|
|
91
106
|
});
|
|
92
107
|
|
|
93
|
-
|
|
108
|
+
// One settle guard for all three paths: aborting a write stream can itself
|
|
109
|
+
// emit, and a second rejection after a resolve would otherwise be silent.
|
|
110
|
+
let settled = false;
|
|
111
|
+
const fail = (error: Error) => {
|
|
112
|
+
if (settled) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
settled = true;
|
|
94
116
|
reject(error);
|
|
117
|
+
};
|
|
118
|
+
const succeed = (fileInfo: GridFSFileInfo) => {
|
|
119
|
+
if (settled) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
settled = true;
|
|
123
|
+
resolve(fileInfo);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
stream.on('error', (error) => {
|
|
127
|
+
// Discard the chunks already written — see the note above.
|
|
128
|
+
Promise.resolve(uploadStream.abort?.()).catch(() => undefined);
|
|
129
|
+
fail(error);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
uploadStream.on('error', (error) => {
|
|
133
|
+
// `pipe()` only unpipes on a destination error; the source would stay open,
|
|
134
|
+
// holding a GridFS read cursor or an upload capacitor for nothing.
|
|
135
|
+
if (!stream.destroyed) {
|
|
136
|
+
stream.destroy();
|
|
137
|
+
}
|
|
138
|
+
fail(error);
|
|
95
139
|
});
|
|
96
140
|
|
|
97
141
|
uploadStream.on('finish', () => {
|
|
@@ -101,12 +145,12 @@ export class GridFSHelper {
|
|
|
101
145
|
.toArray()
|
|
102
146
|
.then((files) => {
|
|
103
147
|
if (files && files.length > 0) {
|
|
104
|
-
|
|
148
|
+
succeed(GridFSHelper.normalizeFileInfo(files[0]));
|
|
105
149
|
} else {
|
|
106
|
-
|
|
150
|
+
fail(new Error('File uploaded but metadata not found'));
|
|
107
151
|
}
|
|
108
152
|
})
|
|
109
|
-
.catch(
|
|
153
|
+
.catch(fail);
|
|
110
154
|
});
|
|
111
155
|
|
|
112
156
|
stream.pipe(uploadStream);
|
|
@@ -162,13 +206,54 @@ export class GridFSHelper {
|
|
|
162
206
|
}
|
|
163
207
|
|
|
164
208
|
/**
|
|
165
|
-
* Find files with filter and options
|
|
209
|
+
* Find files with filter and options.
|
|
210
|
+
*
|
|
211
|
+
* The `contentType` key is rewritten to `metadata.contentType` in BOTH the
|
|
212
|
+
* filter and the sort — the exact mirror image of what
|
|
213
|
+
* {@link GridFSHelper.normalizeFileInfo} does on the way out.
|
|
214
|
+
*
|
|
215
|
+
* Without it, `contentType` was the one field where the three storage drivers
|
|
216
|
+
* were not equivalent: `s3-files` / `filesystem-files` carry it at the root of
|
|
217
|
+
* the document, GridFS keeps it inside `metadata` (the driver dropped the
|
|
218
|
+
* top-level option in mongodb 7). A `findFileInfo()` filtered on `contentType`
|
|
219
|
+
* therefore matched every S3 / filesystem file and NO GridFS file — silently
|
|
220
|
+
* returning nothing at all on the default driver, and silently dropping the
|
|
221
|
+
* pre-switch files on any other. That is exactly the "switching drivers is
|
|
222
|
+
* forward-only, no migration" promise failing for one field.
|
|
166
223
|
*/
|
|
167
224
|
static async findFiles(bucket: GridFSBucket, filter: any = {}, options: any = {}): Promise<GridFSFileInfo[]> {
|
|
168
|
-
const
|
|
225
|
+
const query = GridFSHelper.mapContentTypeKeys(filter);
|
|
226
|
+
const findOptions =
|
|
227
|
+
options && options.sort ? { ...options, sort: GridFSHelper.mapContentTypeKeys(options.sort) } : options;
|
|
228
|
+
const files = await bucket.find(query, findOptions).toArray();
|
|
169
229
|
return files.map((file) => GridFSHelper.normalizeFileInfo(file));
|
|
170
230
|
}
|
|
171
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Rename a top-level `contentType` key to `metadata.contentType`, recursing
|
|
234
|
+
* through the logical operators `generateFilterQuery()` can emit.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately narrow: only that one key is touched, only where it names a
|
|
237
|
+
* field, and an already-qualified `metadata.contentType` is left alone. Nothing
|
|
238
|
+
* else about the query is interpreted.
|
|
239
|
+
*/
|
|
240
|
+
private static mapContentTypeKeys(value: any): any {
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
return value.map((entry) => GridFSHelper.mapContentTypeKeys(entry));
|
|
243
|
+
}
|
|
244
|
+
if (!value || typeof value !== 'object' || value instanceof RegExp || value instanceof Date) {
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
const mapped: Record<string, any> = {};
|
|
248
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
249
|
+
// Only the logical operators hold nested FIELD maps; `$gt`, `$in`, `$regex`
|
|
250
|
+
// and friends hold VALUES, which must be passed through untouched.
|
|
251
|
+
const nested = key === '$and' || key === '$nor' || key === '$or' ? GridFSHelper.mapContentTypeKeys(entry) : entry;
|
|
252
|
+
mapped[key === 'contentType' ? 'metadata.contentType' : key] = nested;
|
|
253
|
+
}
|
|
254
|
+
return mapped;
|
|
255
|
+
}
|
|
256
|
+
|
|
172
257
|
/**
|
|
173
258
|
* Delete a file from GridFS
|
|
174
259
|
*/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ValidationArguments } from 'class-validator';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders a single constraint for use in an error message.
|
|
5
|
+
*
|
|
6
|
+
* Mirror of class-validator's internal `constraintToString`
|
|
7
|
+
* (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
|
|
8
|
+
*
|
|
9
|
+
* See {@link replaceMessageSpecialTokens} for why this lives here and what holds it in sync.
|
|
10
|
+
*/
|
|
11
|
+
export function constraintToString(constraint: unknown): string {
|
|
12
|
+
if (Array.isArray(constraint)) {
|
|
13
|
+
return constraint.join(', ');
|
|
14
|
+
}
|
|
15
|
+
if (typeof constraint === 'symbol') {
|
|
16
|
+
constraint = constraint.description;
|
|
17
|
+
}
|
|
18
|
+
return `${constraint}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a message (string or function) and interpolates the special tokens
|
|
23
|
+
* $constraint1..N, $value, $property and $target.
|
|
24
|
+
*
|
|
25
|
+
* Mirror of class-validator's internal `ValidationUtils.replaceMessageSpecialTokens`
|
|
26
|
+
* (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
|
|
27
|
+
* `MapAndValidatePipe` re-implements the validation executor and therefore has to render messages
|
|
28
|
+
* exactly the way class-validator's own `validate()` would — otherwise the same decorator produces
|
|
29
|
+
* two different strings depending on which of the two ran.
|
|
30
|
+
*
|
|
31
|
+
* WHY IT IS NOT BARREL-EXPORTED
|
|
32
|
+
* Deliberately absent from `src/index.ts`. It exists to reproduce a dependency's internal
|
|
33
|
+
* behaviour, so it has to stay free to follow that dependency; exporting it would owe consumers
|
|
34
|
+
* backward compatibility for a surface we do not control. Vendor-mode consumers resolve their own
|
|
35
|
+
* class-validator, so the copy in `src/core/` can meet a version this repo never installed —
|
|
36
|
+
* `tests/unit/validation-message-mirror.spec.ts` compares it against the INSTALLED implementation
|
|
37
|
+
* over a fixture table so a divergence surfaces as a failing test rather than as two renderers of
|
|
38
|
+
* one contract.
|
|
39
|
+
*
|
|
40
|
+
* SECURITY — `$value` echoes the SUBMITTED value back to the client, and validation errors are
|
|
41
|
+
* never scrubbed: `security.secretFields` is applied by `CheckSecurityInterceptor` on the RESPONSE
|
|
42
|
+
* path, whereas the `BadRequestException` raised from the pipe goes to the exception filter and is
|
|
43
|
+
* forwarded verbatim. The check below is a TYPE guard (boolean | number | string), not a secrecy
|
|
44
|
+
* guard — it happily admits a password or a token. No built-in message uses `$value`, so reaching
|
|
45
|
+
* it is opt-in: never put `$value` in a custom message on a sensitive field.
|
|
46
|
+
*/
|
|
47
|
+
export function replaceMessageSpecialTokens(
|
|
48
|
+
message: ((args: ValidationArguments) => string) | string,
|
|
49
|
+
validationArguments: ValidationArguments,
|
|
50
|
+
): string {
|
|
51
|
+
let messageString = '';
|
|
52
|
+
if (typeof message === 'function') {
|
|
53
|
+
messageString = message(validationArguments);
|
|
54
|
+
} else if (typeof message === 'string') {
|
|
55
|
+
messageString = message;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (messageString && Array.isArray(validationArguments.constraints)) {
|
|
59
|
+
validationArguments.constraints.forEach((constraint, index) => {
|
|
60
|
+
messageString = messageString.replace(
|
|
61
|
+
new RegExp(`\\$constraint${index + 1}`, 'g'),
|
|
62
|
+
constraintToString(constraint),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
messageString &&
|
|
69
|
+
validationArguments.value !== undefined &&
|
|
70
|
+
validationArguments.value !== null &&
|
|
71
|
+
['boolean', 'number', 'string'].includes(typeof validationArguments.value)
|
|
72
|
+
) {
|
|
73
|
+
messageString = messageString.replace(/\$value/g, `${validationArguments.value}`);
|
|
74
|
+
}
|
|
75
|
+
if (messageString) {
|
|
76
|
+
messageString = messageString.replace(/\$property/g, validationArguments.property);
|
|
77
|
+
}
|
|
78
|
+
if (messageString) {
|
|
79
|
+
messageString = messageString.replace(/\$target/g, validationArguments.targetName);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return messageString;
|
|
83
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
maxLength,
|
|
21
21
|
min,
|
|
22
22
|
minLength,
|
|
23
|
+
ValidationArguments,
|
|
23
24
|
ValidationError,
|
|
24
25
|
} from 'class-validator';
|
|
25
26
|
import { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata';
|
|
@@ -27,6 +28,7 @@ import { inspect } from 'util';
|
|
|
27
28
|
|
|
28
29
|
import { getUnifiedFieldKeys, nestedTypeRegistry } from '../decorators/unified-field.decorator';
|
|
29
30
|
import { isBasicType } from '../helpers/input.helper';
|
|
31
|
+
import { replaceMessageSpecialTokens } from '../helpers/validation-message.helper';
|
|
30
32
|
import { ConfigService } from '../services/config.service';
|
|
31
33
|
import { ErrorCode } from '../../modules/error-code/error-codes';
|
|
32
34
|
|
|
@@ -268,18 +270,21 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
|
|
|
268
270
|
isValid = validationResult instanceof Promise ? await validationResult : validationResult;
|
|
269
271
|
}
|
|
270
272
|
|
|
271
|
-
// Get
|
|
273
|
+
// Get message and constraint name if validation failed
|
|
272
274
|
if (!isValid) {
|
|
273
275
|
// Use metadata.name for the constraint key (e.g., "isEmail", "isString")
|
|
274
276
|
const constraintName = metadata.name || 'customValidation';
|
|
275
277
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
278
|
+
// A custom message from ValidationOptions takes precedence over the
|
|
279
|
+
// constraint's default message — same order as class-validator's executor
|
|
280
|
+
let messageTemplate: string | ((args: ValidationArguments) => string) | undefined =
|
|
281
|
+
metadata.message as string | ((args: ValidationArguments) => string) | undefined;
|
|
282
|
+
if (!messageTemplate && typeof constraintInstance.defaultMessage === 'function') {
|
|
283
|
+
messageTemplate = constraintInstance.defaultMessage(validationArgs);
|
|
282
284
|
}
|
|
285
|
+
errorMessage = messageTemplate
|
|
286
|
+
? replaceMessageSpecialTokens(messageTemplate, validationArgs)
|
|
287
|
+
: `${propertyName} failed custom validation`;
|
|
283
288
|
|
|
284
289
|
// Add to constraints with the proper name
|
|
285
290
|
propertyError.constraints[constraintName] = errorMessage;
|
|
@@ -578,6 +583,19 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
|
|
|
578
583
|
|
|
579
584
|
// Add constraint violation if validation failed
|
|
580
585
|
if (!isValid) {
|
|
586
|
+
// A custom message from ValidationOptions takes precedence over the built-in message
|
|
587
|
+
if (metadata.message) {
|
|
588
|
+
errorMessage = replaceMessageSpecialTokens(
|
|
589
|
+
metadata.message as string | ((args: ValidationArguments) => string),
|
|
590
|
+
{
|
|
591
|
+
constraints: metadata.constraints || [],
|
|
592
|
+
object: tempInstance,
|
|
593
|
+
property: propertyName,
|
|
594
|
+
targetName: targetClass.name,
|
|
595
|
+
value: propertyValue,
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
}
|
|
581
599
|
propertyError.constraints[constraintType] = errorMessage;
|
|
582
600
|
}
|
|
583
601
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { PassThrough } from 'stream';
|
|
2
3
|
|
|
3
4
|
import { buildContentDisposition } from '../helpers/content-disposition.helper';
|
|
4
5
|
import { ConfigService } from './config.service';
|
|
@@ -276,15 +277,76 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
276
277
|
length = (body as Buffer).length;
|
|
277
278
|
}
|
|
278
279
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
280
|
+
const send = () =>
|
|
281
|
+
client.send(
|
|
282
|
+
new sdk.PutObjectCommand({
|
|
283
|
+
Body: payload,
|
|
284
|
+
Bucket: config.bucket,
|
|
285
|
+
...(contentType ? { ContentType: contentType } : {}),
|
|
286
|
+
...(length === undefined ? {} : { ContentLength: length }),
|
|
287
|
+
Key: key,
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// A stream handed straight to the SDK is the ONE body shape whose failure is not already
|
|
292
|
+
// ours: the unknown-length branch above reads the stream itself (`for await` throws), and a
|
|
293
|
+
// Buffer cannot fail. See `guardBodyStream` for what goes wrong without this.
|
|
294
|
+
if (this.isStream(payload)) {
|
|
295
|
+
const guard = this.guardBodyStream(payload);
|
|
296
|
+
payload = guard.body;
|
|
297
|
+
try {
|
|
298
|
+
await send();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw guard.sourceError() ?? error;
|
|
301
|
+
}
|
|
302
|
+
// A source that died after the SDK already considered the request done would otherwise be
|
|
303
|
+
// reported as a successful upload of a truncated object.
|
|
304
|
+
const failure = guard.sourceError();
|
|
305
|
+
if (failure) {
|
|
306
|
+
throw failure;
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await send();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Shield the SDK from a REQUEST-BODY stream that can fail, and keep the cause.
|
|
315
|
+
*
|
|
316
|
+
* WHY THIS EXISTS: the AWS SDK pipes the body into its HTTP request without listening on the
|
|
317
|
+
* SOURCE. An error there therefore reached a stream with NO listener, and Node turns an unhandled
|
|
318
|
+
* `'error'` event into an uncaught exception that ends the process. Reachable from ordinary
|
|
319
|
+
* operation — this is the path a tus upload takes when it is migrated into S3 (`body` plus a
|
|
320
|
+
* known `contentLength`), so a staged read that drops mid-migration took the API down with it.
|
|
321
|
+
* It is also a driver asymmetry of the shape this release keeps finding: the filesystem driver
|
|
322
|
+
* routes the same stream through `pipeline()`, which forwards both ends, and GridFS has its own
|
|
323
|
+
* source handler. Only S3 had none.
|
|
324
|
+
*
|
|
325
|
+
* WHY A RELAY RATHER THAN JUST A LISTENER: a listener alone stops the uncaught exception, but the
|
|
326
|
+
* SDK's own request pipeline still observes a body stream that ERRORED and rejects a promise of
|
|
327
|
+
* its own that nothing awaits — an unhandled rejection, which Node also treats as fatal by
|
|
328
|
+
* default. So the SDK must never see a failing stream at all. It gets a `PassThrough` that simply
|
|
329
|
+
* ENDS early instead; short of its declared `Content-Length`, that is an ordinary request failure
|
|
330
|
+
* the SDK reports through the promise we are already awaiting. `sourceError()` then replaces the
|
|
331
|
+
* SDK's `socket hang up` with the actual cause, which is the part an operator needs.
|
|
332
|
+
*
|
|
333
|
+
* A `Promise.race` against the send would be the obvious alternative and is worse: it introduces
|
|
334
|
+
* a second promise for the same failure, and whichever loses the race is a rejection nobody
|
|
335
|
+
* consumes — trading the uncaught exception for exactly the unhandled rejection above.
|
|
336
|
+
*/
|
|
337
|
+
protected guardBodyStream(stream: Readable): { body: Readable; sourceError: () => Error | undefined } {
|
|
338
|
+
const relay = new PassThrough();
|
|
339
|
+
let sourceError: Error | undefined;
|
|
340
|
+
|
|
341
|
+
stream.on('error', (error: Error) => {
|
|
342
|
+
sourceError = error;
|
|
343
|
+
// GRACEFUL end, never `destroy(error)`: propagating the error into the relay would hand the
|
|
344
|
+
// SDK the failing stream this method exists to keep away from it.
|
|
345
|
+
relay.end();
|
|
346
|
+
});
|
|
347
|
+
stream.pipe(relay);
|
|
348
|
+
|
|
349
|
+
return { body: relay, sourceError: () => sourceError };
|
|
288
350
|
}
|
|
289
351
|
|
|
290
352
|
/**
|
|
@@ -93,6 +93,26 @@ Reads consult **every** store, so files written under a previous driver stay rea
|
|
|
93
93
|
migration step and no cut-over moment. New files go to the active driver; `findFileInfo()` returns
|
|
94
94
|
the union, paged once over the merged result.
|
|
95
95
|
|
|
96
|
+
That holds per method and in **both** directions — adopting a driver and switching away from one.
|
|
97
|
+
The metadata lookups are gated on whether a store is USABLE, never on whether it is the active write
|
|
98
|
+
driver, so pinning `file.storage: 'gridfs'` on a deployment that once used S3 keeps the S3 files
|
|
99
|
+
readable rather than 404-ing them.
|
|
100
|
+
|
|
101
|
+
Three consequences worth knowing about the merged read path:
|
|
102
|
+
|
|
103
|
+
- **Ordering.** Each store answers its own correctly ordered page, and the merge re-establishes a
|
|
104
|
+
global order before it pages (`sortMergedFileInfo()`), including dotted sort fields such as
|
|
105
|
+
`metadata.ownerId`. Without a `sort` the merged page is `uploadDate` descending.
|
|
106
|
+
- **`contentType`.** GridFS keeps it inside `metadata`, the other two stores at the document root.
|
|
107
|
+
`GridFSHelper.findFiles()` rewrites the key so a filter or sort on `contentType` behaves the same
|
|
108
|
+
under every driver.
|
|
109
|
+
- **Duplication crosses drivers.** `duplicateByName()` / `duplicateById()` read the source from
|
|
110
|
+
whichever store holds it and write the copy to the ACTIVE driver, so duplicating a file that is
|
|
111
|
+
still in GridFS while `file.storage: 's3'` simply moves it forward.
|
|
112
|
+
|
|
113
|
+
`s3-files` and `filesystem-files` are created on their first WRITE, never on a read — a deployment
|
|
114
|
+
that only ever uses GridFS never grows them.
|
|
115
|
+
|
|
96
116
|
The boot log names the driver in use, so it never has to be inferred from where files stopped
|
|
97
117
|
appearing:
|
|
98
118
|
|
|
@@ -184,6 +204,20 @@ rather than exempt from it. The reference server does both: `src/server/modules/
|
|
|
184
204
|
`src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
|
|
185
205
|
`id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
|
|
186
206
|
|
|
207
|
+
**It covers duplication too (11.34.0+).** `duplicateByName()` / `duplicateById()` authorize a copy as
|
|
208
|
+
a READ of the source (`'filename'` / `'id'`) plus a WRITE of the copy (`'file'`), through the same
|
|
209
|
+
public methods every other caller uses — so forward the context:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
await this.fileService.duplicateById(id, { currentUser });
|
|
213
|
+
// …and give the COPY its own owner, because it does NOT inherit the source's metadata
|
|
214
|
+
await this.fileService.duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } });
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Not copying the source's `metadata` is deliberate: doing so would silently hand the duplicate the
|
|
218
|
+
source's owner. A copy made without metadata is ADMIN-only under the rule above — fail-closed, not
|
|
219
|
+
lost.
|
|
220
|
+
|
|
187
221
|
Three pieces make this work, and all three are needed:
|
|
188
222
|
|
|
189
223
|
1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
|
|
@@ -325,12 +359,14 @@ export class FileController extends CoreFileController {
|
|
|
325
359
|
|
|
326
360
|
### Error responses
|
|
327
361
|
|
|
328
|
-
| Situation | Status | Body
|
|
329
|
-
| -------------------------------------------------------------------------------------- | ------ |
|
|
330
|
-
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND`
|
|
331
|
-
|
|
|
332
|
-
|
|
|
333
|
-
|
|
|
362
|
+
| Situation | Status | Body |
|
|
363
|
+
| -------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
364
|
+
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
|
|
365
|
+
| `deleteFile()` / `deleteFileByName()` / `duplicate*()` on a file that is not there | `404` | `NotFoundException`. Both halves of each id/name pair answer identically — up to 11.33.1 the by-id delete surfaced the driver's `MongoRuntimeError` as a `500` |
|
|
366
|
+
| Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
|
|
367
|
+
| An upload's SOURCE stream fails (client aborts, staged read drops) | `500` | The promise rejects with the cause — under **every** driver. It used to be an uncaught exception (i.e. a process exit) on GridFS, and on the S3 streaming path tus finalization uses |
|
|
368
|
+
| GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
|
|
369
|
+
| 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 |
|
|
334
370
|
|
|
335
371
|
The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
|
|
336
372
|
go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —
|