@lenne.tech/nest-server 11.33.0 → 11.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/testing.md +181 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/config.env.js +6 -0
- package/dist/config.env.js.map +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/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 +47 -48
- 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.controller.js +2 -2
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +2 -2
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +7 -2
- package/dist/server/modules/file/file.service.js +13 -2
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.js +1 -1
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +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 +39 -4
- package/migration-guides/11.33.x-to-11.34.x.md +394 -0
- package/package.json +3 -1
- package/src/config.env.ts +45 -0
- package/src/core/common/helpers/gridfs.helper.ts +92 -7
- package/src/core/common/services/core-s3.service.ts +71 -9
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
- package/src/core/modules/file/README.md +77 -10
- package/src/core/modules/file/core-file.service.ts +170 -64
- 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.controller.ts +7 -2
- package/src/server/modules/file/file.resolver.ts +6 -2
- package/src/server/modules/file/file.service.ts +97 -34
- package/src/server/modules/user/avatar.controller.ts +8 -1
|
@@ -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
|
*/
|
|
@@ -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
|
/**
|
|
@@ -65,8 +65,18 @@ decision worth making explicitly.
|
|
|
65
65
|
caller have THIS file". For the latter, write an owner or tenant into the metadata at upload time
|
|
66
66
|
(`createFile(file, { metadata: { ownerId } })`) and compare it in `checkRights()` via
|
|
67
67
|
`getRawFileInfo()` / `getRawFileInfoByName()`. The public `getFileInfo()` strips restricted fields
|
|
68
|
-
and is unusable for the decision.
|
|
69
|
-
same rule works under
|
|
68
|
+
and is unusable for the decision. Both raw lookups consult every store — S3 metadata first, then the
|
|
69
|
+
filesystem store, then GridFS — so the same rule works under any `file.storage`.
|
|
70
|
+
|
|
71
|
+
Handle **both** `checkInputType: 'id'` and `'filename'`. An id-only rule is enough while bytes are
|
|
72
|
+
streamed (the filename route resolves an id and re-checks it), but not with presigned S3 downloads,
|
|
73
|
+
and not for `deleteFileByName()`, which authorizes by name only.
|
|
74
|
+
|
|
75
|
+
**Copy from the executed reference, not from prose:** `src/server/modules/file/file.service.ts`
|
|
76
|
+
implements exactly this rule, and `src/config.env.ts` widens `file.downloadRoles` to `[S_USER]` so
|
|
77
|
+
it is actually reached. Note the internal callers there too — `{ force: true }` on the
|
|
78
|
+
`@Roles(ADMIN)` endpoints, and a real `{ currentUser }` in `AvatarController` — because a rule that
|
|
79
|
+
reads a missing user as "internal, allow" fails open the moment the coarse gate is widened.
|
|
70
80
|
|
|
71
81
|
### 5. Decide how the frontend fetches files
|
|
72
82
|
|
|
@@ -82,8 +92,10 @@ route for exactly the public files and leave the core routes gated.
|
|
|
82
92
|
|
|
83
93
|
- [ ] `pnpm run build` succeeds
|
|
84
94
|
- [ ] Anonymous `GET /files/id/<id>` answers **401**
|
|
85
|
-
- [ ] A signed-in non-privileged user answers **403**
|
|
86
|
-
user out)
|
|
95
|
+
- [ ] A signed-in non-privileged user answers **403** when the ROLE GATE is what refuses (not 401 —
|
|
96
|
+
a 401 makes SPA auth layers log the user out). If you widened `downloadRoles` and let
|
|
97
|
+
`checkRights()` decide instead, the expected answer is **404**, byte-identical to an unknown
|
|
98
|
+
id — a 403 there would confirm the file exists
|
|
87
99
|
- [ ] A caller holding a configured role downloads successfully
|
|
88
100
|
- [ ] If you set `file.downloadRoles`, the value actually takes effect — if it does not, something
|
|
89
101
|
in your controller is overriding the member (see step 2)
|
|
@@ -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
|
|
|
@@ -141,18 +161,63 @@ export class FileService extends CoreFileService {
|
|
|
141
161
|
input: any,
|
|
142
162
|
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
143
163
|
): Promise<boolean> {
|
|
144
|
-
|
|
164
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate
|
|
165
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
145
166
|
return true;
|
|
146
167
|
}
|
|
147
|
-
if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
|
|
168
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
148
169
|
return true;
|
|
149
170
|
}
|
|
150
|
-
const raw =
|
|
151
|
-
|
|
171
|
+
const raw =
|
|
172
|
+
options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
|
|
173
|
+
// Fails closed without a user, and on a file that records no owner.
|
|
174
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
152
175
|
}
|
|
153
176
|
}
|
|
154
177
|
```
|
|
155
178
|
|
|
179
|
+
This is not a sketch: it is the rule `src/server/modules/file/file.service.ts` runs, with
|
|
180
|
+
`file: { downloadRoles: [RoleEnum.S_USER] }` in `src/config.env.ts` so the coarse gate actually
|
|
181
|
+
lets it fire. It used to live here and in that file as a **comment**, on the reasoning that the
|
|
182
|
+
`[ADMIN]` default made it unreachable in the reference server anyway — and a commented rule is never
|
|
183
|
+
compiled, never type-checked and never run. That is how a `deleteFileByName()` regression on the
|
|
184
|
+
`filename` branch shipped through a full green suite.
|
|
185
|
+
|
|
186
|
+
**Cover the `filename` branch too, not just `id`.** An id-only rule is enough while bytes are
|
|
187
|
+
streamed, because the filename route resolves an id and checks it again — but not once
|
|
188
|
+
`file.storage: 's3'` with presigned downloads is enabled, where the filename route authorizes on the
|
|
189
|
+
by-name lookup alone and then redirects, and not for `deleteFileByName()`, which authorizes by name
|
|
190
|
+
only.
|
|
191
|
+
|
|
192
|
+
**Never add `if (!options.currentUser) return true`.** It reads as "system-internal call, the guard
|
|
193
|
+
already decided" — but "no user in context" is also exactly what an **anonymous** request looks like.
|
|
194
|
+
While `downloadRoles` is narrower than `S_EVERYONE` the role gate turns those away first, so the
|
|
195
|
+
branch looks harmless; widen the gate, which this very section invites you to do, and it hands every
|
|
196
|
+
file to everyone. The ownership rule evaporates precisely when it starts to matter. The same reason
|
|
197
|
+
makes `!!raw?.metadata?.ownerId` load-bearing: without it, an owner-less file compares
|
|
198
|
+
`String(undefined)` against `String(undefined)` and matches.
|
|
199
|
+
|
|
200
|
+
Callers that really are internal should say so instead of relying on the omission — `{ force: true }`
|
|
201
|
+
where a role decorator already decided (an `@Roles(ADMIN)` admin endpoint), or the real
|
|
202
|
+
`{ currentUser }` where the user is in scope, so that call is **covered** by the ownership rule
|
|
203
|
+
rather than exempt from it. The reference server does both: `src/server/modules/file/` and
|
|
204
|
+
`src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
|
|
205
|
+
`id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
|
|
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
|
+
|
|
156
221
|
Three pieces make this work, and all three are needed:
|
|
157
222
|
|
|
158
223
|
1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
|
|
@@ -294,12 +359,14 @@ export class FileController extends CoreFileController {
|
|
|
294
359
|
|
|
295
360
|
### Error responses
|
|
296
361
|
|
|
297
|
-
| Situation | Status | Body
|
|
298
|
-
| -------------------------------------------------------------------------------------- | ------ |
|
|
299
|
-
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND`
|
|
300
|
-
|
|
|
301
|
-
|
|
|
302
|
-
|
|
|
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 |
|
|
303
370
|
|
|
304
371
|
The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
|
|
305
372
|
go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —
|