@lenne.tech/nest-server 11.33.1 → 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.
Files changed (41) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  4. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  5. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  6. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  7. package/dist/core/common/services/core-s3.service.js +28 -1
  8. package/dist/core/common/services/core-s3.service.js.map +1 -1
  9. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  10. package/dist/core/modules/file/core-file.service.js +46 -47
  11. package/dist/core/modules/file/core-file.service.js.map +1 -1
  12. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  13. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  14. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  15. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  16. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  17. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  18. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  19. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  20. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  21. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  22. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  23. package/dist/server/modules/file/file.service.d.ts +2 -1
  24. package/dist/server/modules/file/file.service.js +2 -2
  25. package/dist/server/modules/file/file.service.js.map +1 -1
  26. package/dist/tsconfig.build.tsbuildinfo +1 -1
  27. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  28. package/migration-guides/11.32.x-to-11.33.x.md +2 -0
  29. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  30. package/package.json +3 -1
  31. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  32. package/src/core/common/services/core-s3.service.ts +71 -9
  33. package/src/core/modules/file/README.md +42 -6
  34. package/src/core/modules/file/core-file.service.ts +131 -59
  35. package/src/core/modules/hub/README.md +35 -9
  36. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  37. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  38. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  39. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  40. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  41. package/src/server/modules/file/file.service.ts +20 -3
@@ -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
- | Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
332
- | GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
333
- | 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 |
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** —
@@ -1,8 +1,9 @@
1
1
  import { Logger, NotFoundException } from '@nestjs/common';
2
- import mongoose, { Connection, mongo, Types } from 'mongoose';
2
+ import { Connection, mongo, Types } from 'mongoose';
3
3
  import { Readable } from 'stream';
4
4
 
5
5
  import { FilterArgs } from '../../common/args/filter.args';
6
+ import { accessDeniedException } from '../../common/exceptions/access-denied.exception';
6
7
  import { getObjectIds, getStringIds } from '../../common/helpers/db.helper';
7
8
  import { convertFilterArgsToQuery } from '../../common/helpers/filter.helper';
8
9
  import { GridFSHelper } from '../../common/helpers/gridfs.helper';
@@ -195,73 +196,103 @@ export abstract class CoreFileService {
195
196
 
196
197
  /**
197
198
  * Duplicate file by name
199
+ *
200
+ * A duplicate is a READ of the source plus a WRITE of the copy, and it is
201
+ * authorized as exactly that: the source goes through `getFileInfoByName()` +
202
+ * `getFileStreamByName()` (`checkInputType: 'filename'`) and the copy through
203
+ * `createFile()` (`checkInputType: 'file'`) — the same public methods, with the
204
+ * same `checkRights()` hook, that every other caller uses.
205
+ *
206
+ * `serviceOptions` is OPTIONAL and additive. Omitting it keeps the pre-11.34.0
207
+ * behaviour for the framework default `checkRights()` (which returns `true`),
208
+ * and turns what used to be a crash into a clean refusal for a project that
209
+ * overrides it fail-closed: the GridFS branch used to bypass the hook
210
+ * altogether and copy the file unchecked, while the S3 / filesystem branches
211
+ * died inside a storage helper. System-internal callers say `{ force: true }`,
212
+ * the same idiom the rest of this service uses.
213
+ *
214
+ * The copy does NOT inherit the source's `metadata`. Copying it would silently
215
+ * hand the duplicate the source's owner, which is the one thing an ownership
216
+ * rule must not do behind the caller's back — pass `serviceOptions.metadata`
217
+ * to state the copy's own.
218
+ *
219
+ * @returns the file info of the COPY. Up to 11.33.1 the GridFS branch resolved
220
+ * the raw `GridFSBucketWriteStream` instead, so a project reading anything
221
+ * beyond `.id` / `.filename` off it has to adjust.
198
222
  */
199
- async duplicateByName(name: string, newName: string): Promise<any> {
200
- // Route through the storage dispatch like every other read/write. Going straight to GridFS
201
- // meant that with `file.storage: 's3'` the source simply is not there — and the resulting
202
- // FileNotFound arrives on a stream with NO error handler, so it becomes an uncaught
203
- // exception that takes the process down instead of a 404.
204
- const nonGridFsSource = (await this.findS3FileByName(name)) || (await this.findFilesystemFileByName(name));
205
- if (nonGridFsSource) {
206
- const source = await this.getFileStreamByName(name);
207
- return this.createFile({
208
- createReadStream: () => source,
209
- filename: newName,
210
- mimetype: nonGridFsSource.contentType || 'application/octet-stream',
211
- });
223
+ async duplicateByName(name: string, newName: string, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
224
+ const source = await this.getFileInfoByName(name, serviceOptions);
225
+ if (!source) {
226
+ throw new NotFoundException(`File not found with filename ${name}`);
212
227
  }
213
-
214
- return new Promise((resolve, reject) => {
215
- const downloadStream = GridFSHelper.openDownloadStreamByName(this.files, name);
216
- downloadStream.on('error', reject);
217
- const uploadStream = GridFSHelper.openUploadStream(this.files, newName);
218
- uploadStream.on('error', reject);
219
- uploadStream.on('finish', () => resolve(uploadStream));
220
- downloadStream.pipe(uploadStream);
221
- });
228
+ return this.duplicateFile(source, newName, () => this.getFileStreamByName(name, serviceOptions), serviceOptions);
222
229
  }
223
230
 
224
231
  /**
225
232
  * Duplicate file by ID
233
+ *
234
+ * See {@link duplicateByName} for the authorization model and for why
235
+ * `serviceOptions` matters. The copy keeps the source's filename.
236
+ *
237
+ * @returns the id of the copy
226
238
  */
227
- async duplicateById(id: string): Promise<string> {
239
+ async duplicateById(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<string> {
228
240
  const objectId = getObjectIds(id);
229
- const file = await this.getFileInfo(objectId);
230
-
231
- // Same dispatch as duplicateByName: a file stored outside GridFS has no GridFS counterpart.
232
- const nonGridFsSource = (await this.findS3FileById(objectId)) || (await this.findFilesystemFileById(objectId));
233
- if (nonGridFsSource) {
234
- const source = await this.getFileStream(objectId);
235
- const copy = await this.createFile({
236
- createReadStream: () => source,
237
- filename: file.filename,
238
- mimetype: file.contentType || 'application/octet-stream',
239
- });
240
- return copy.id;
241
- }
242
-
243
- return new Promise((resolve, reject) => {
244
- const downloadStream = GridFSHelper.openDownloadStream(this.files, objectId);
245
-
246
- const newFileId = new mongoose.Types.ObjectId();
247
- const uploadStream = GridFSHelper.openUploadStreamWithId(this.files, newFileId, file.filename, {
248
- contentType: file.contentType,
249
- });
250
-
251
- downloadStream.pipe(uploadStream);
241
+ const source = await this.getFileInfo(objectId, serviceOptions);
242
+ if (!source) {
243
+ // Was a `TypeError` on `file.filename`: the source had been re-resolved with
244
+ // an empty context, so a fail-closed rule answered null and the null was then
245
+ // dereferenced. A refusal must read as a refusal, never as a crash.
246
+ throw new NotFoundException(`File not found with id ${getStringIds(objectId)}`);
247
+ }
248
+ const copy = await this.duplicateFile(
249
+ source,
250
+ source.filename,
251
+ () => this.getFileStream(objectId, serviceOptions),
252
+ serviceOptions,
253
+ );
254
+ return copy.id;
255
+ }
252
256
 
253
- uploadStream.on('finish', () => {
254
- resolve(getStringIds(newFileId));
255
- });
257
+ /**
258
+ * Shared write half of the two duplicate methods.
259
+ *
260
+ * Deliberately driver-agnostic: it opens the source through the public read
261
+ * path (whichever store answers) and writes the copy through `createFile()`
262
+ * (whichever store is active). That is what makes a duplicate work ACROSS a
263
+ * driver change — a file still in GridFS is copied into S3 without a migration
264
+ * step — and what keeps the three drivers from drifting apart again.
265
+ */
266
+ protected async duplicateFile(
267
+ source: CoreFileInfo,
268
+ newName: string,
269
+ openSourceStream: () => Promise<Readable>,
270
+ serviceOptions?: FileServiceOptions,
271
+ ): Promise<CoreFileInfo> {
272
+ const stream = await openSourceStream();
273
+ if (!stream) {
274
+ // The read was refused between the two checks (or the bytes vanished).
275
+ // Same answer as an unknown file: never confirm that it exists.
276
+ throw new NotFoundException(`File not found with filename ${source.filename}`);
277
+ }
256
278
 
257
- uploadStream.on('error', (err: { message: any }) => {
258
- reject(new Error(`File duplication failed: ${err.message}`));
259
- });
279
+ const copy = await this.createFile(
280
+ {
281
+ createReadStream: () => stream,
282
+ filename: newName,
283
+ mimetype: source.contentType || 'application/octet-stream',
284
+ },
285
+ serviceOptions,
286
+ );
260
287
 
261
- downloadStream.on('error', (err: { message: any }) => {
262
- reject(new Error(`File download failed: ${err.message}`));
263
- });
264
- });
288
+ if (!copy) {
289
+ // The WRITE half was refused. Unlike a refused read this is not an
290
+ // existence question — the caller has already been shown the source — so it
291
+ // answers with the framework's 401/403 policy rather than a 404.
292
+ stream.destroy?.();
293
+ throw accessDeniedException(serviceOptions?.currentUser);
294
+ }
295
+ return copy;
265
296
  }
266
297
 
267
298
  /**
@@ -317,8 +348,8 @@ export abstract class CoreFileService {
317
348
  const effective: [string, number][] = spec.length ? (spec as [string, number][]) : [['uploadDate', -1]];
318
349
  return [...docs].sort((a, b) => {
319
350
  for (const [field, direction] of effective) {
320
- const left = a?.[field];
321
- const right = b?.[field];
351
+ const left = this.readSortField(a, field);
352
+ const right = this.readSortField(b, field);
322
353
  if (left === right) {
323
354
  continue;
324
355
  }
@@ -336,6 +367,33 @@ export abstract class CoreFileService {
336
367
  });
337
368
  }
338
369
 
370
+ /**
371
+ * Read the value a `sort` key names, resolving DOTTED paths.
372
+ *
373
+ * `SortInput.field` is a free string, and MongoDB reads `metadata.ownerId` as a
374
+ * path into the document — so each store sorted correctly on its own while the
375
+ * merge above compared `doc['metadata.ownerId']`, which is `undefined` for every
376
+ * row. Every comparison then tied, the merged page came out grouped by store,
377
+ * and `skip`/`limit` over it returned the WRONG ROWS rather than the right rows
378
+ * in the wrong order.
379
+ *
380
+ * A path segment is only followed through plain objects. Anything else stops the
381
+ * walk and yields `undefined`, which the caller already sorts last.
382
+ */
383
+ protected readSortField(doc: Record<string, any>, field: string): any {
384
+ if (!field.includes('.')) {
385
+ return doc?.[field];
386
+ }
387
+ let current: any = doc;
388
+ for (const segment of field.split('.')) {
389
+ if (current === null || current === undefined || typeof current !== 'object') {
390
+ return undefined;
391
+ }
392
+ current = current[segment];
393
+ }
394
+ return current;
395
+ }
396
+
339
397
  /**
340
398
  * Get info about file via file ID
341
399
  */
@@ -531,6 +589,17 @@ export abstract class CoreFileService {
531
589
 
532
590
  /**
533
591
  * Delete file
592
+ *
593
+ * An unknown id answers `NotFoundException`, exactly as {@link deleteFileByName}
594
+ * does for an unknown filename. It used to fall through to GridFS and surface
595
+ * the driver's own `MongoRuntimeError: File not found for id …` — a **500** for
596
+ * the very condition its by-name sibling reported as a clean **404**, under all
597
+ * three storage drivers.
598
+ *
599
+ * A REFUSAL still answers `null` rather than throwing, which is the module-wide
600
+ * contract: a refusal must be indistinguishable from a file that is not there.
601
+ * Reaching the lookup below means `checkRights()` already said yes for this
602
+ * exact input, so a `null` here is genuinely a missing file.
534
603
  */
535
604
  async deleteFile(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
536
605
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
@@ -538,6 +607,9 @@ export abstract class CoreFileService {
538
607
  }
539
608
  const objectId = getObjectIds(id);
540
609
  const fileInfo = await this.getFileInfo(objectId, serviceOptions);
610
+ if (!fileInfo) {
611
+ throw new NotFoundException(`File not found with id ${getStringIds(objectId)}`);
612
+ }
541
613
  if (await this.findS3FileById(objectId)) {
542
614
  await S3FileHelper.deleteFile(this.options.s3Service, this.s3Files, objectId);
543
615
  return fileInfo;
@@ -38,7 +38,7 @@ Do NOT set `hub` in the `production` block unless you intend the cockpit to be r
38
38
  | Database | `/hub/db` | dbStats / per-collection collStats |
39
39
  | Models / ERD | `/hub/models` | Mongoose schemas → Mermaid ER diagram |
40
40
  | Migrations | `/hub/migrations` | `MigrationRunner` status + run/rollback |
41
- | Files | `/hub/files` | GridFS listing + delete |
41
+ | Files | `/hub/files` | listing + delete across all three storage drivers |
42
42
  | Config | `/hub/config` | full config, secrets masked |
43
43
  | Auth Migration | `/hub/auth-migration` | Legacy → IAM progress (BetterAuth, optional) |
44
44
  | Routes / Permissions | `/hub/routes` | route + role + `@Restricted` map (Permissions module, optional) |
@@ -50,19 +50,45 @@ Do NOT set `hub` in the `production` block unless you intend the cockpit to be r
50
50
  Each panel has a `*.json` sidecar (the stable data contract) that the client polls. Optional sources
51
51
  degrade to an "unavailable" state instead of erroring.
52
52
 
53
+ ### Files panel
54
+
55
+ The panel reads all three file metadata stores, not just GridFS — the same three
56
+ `CoreFileService.findFileInfo()` consults, because metadata always lives in MongoDB whichever store
57
+ holds the bytes:
58
+
59
+ | Store | Collection | Bytes live in |
60
+ | ------------ | ------------------------------------- | ------------------------------- |
61
+ | `s3` | `s3-files` | the S3 bucket |
62
+ | `filesystem` | `filesystem-files` | `file.storageDir` on local disk |
63
+ | `gridfs` | `<bucket>.files` (default `fs.files`) | MongoDB |
64
+
65
+ Every row carries the `store` it was found in, and `files.json` reports a per-store `count` so an
66
+ empty store reads as **empty** rather than as _"this panel does not cover your driver"_. Until
67
+ 11.34.0 it read `fs.files` alone, so under `file.storage: 's3'` or `'filesystem'` it confidently
68
+ answered **0 files** — the worst answer available, since the Hub is what an operator opens precisely
69
+ when they are unsure whether an upload landed.
70
+
71
+ Two properties worth knowing:
72
+
73
+ - **Delete dispatches to the owning store.** An S3-backed file needs `CoreS3Service` to be
74
+ configured in this process; without it the action REFUSES (`S3 storage is not available.`) rather
75
+ than deleting the metadata document and orphaning the object in the bucket.
76
+ - **Reading never creates a collection.** A GridFS-only deployment does not grow an empty
77
+ `s3-files` / `filesystem-files` from the panel looking at them.
78
+
53
79
  ## Actions (mutating)
54
80
 
55
81
  Enabled by default (`actions: true`). Every mutating request requires the `X-Hub-Request: 1` header
56
82
  (CSRF defense) and destructive ones a server-validated `confirm` keyword:
57
83
 
58
- | Action | Endpoint | Confirm |
59
- | ----------------------- | ------------------------------------------ | ------------ |
60
- | Run pending migrations | `POST /hub/actions/migrations/run` | `RUN` |
61
- | Rollback last migration | `POST /hub/actions/migrations/down` | `DOWN` |
62
- | Delete GridFS file | `DELETE /hub/actions/files/:id` | the filename |
63
- | Cron start/stop/trigger | `POST /hub/actions/cron/:name/:action` | the job name |
64
- | Clear collector buffer | `POST /hub/actions/collectors/:name/clear` | `CLEAR` |
65
- | Send test mail | `POST /hub/actions/email/test` | — |
84
+ | Action | Endpoint | Confirm |
85
+ | ------------------------ | ------------------------------------------ | ------------ |
86
+ | Run pending migrations | `POST /hub/actions/migrations/run` | `RUN` |
87
+ | Rollback last migration | `POST /hub/actions/migrations/down` | `DOWN` |
88
+ | Delete file (any driver) | `DELETE /hub/actions/files/:id` | the filename |
89
+ | Cron start/stop/trigger | `POST /hub/actions/cron/:name/:action` | the job name |
90
+ | Clear collector buffer | `POST /hub/actions/collectors/:name/clear` | `CLEAR` |
91
+ | Send test mail | `POST /hub/actions/email/test` | — |
66
92
 
67
93
  Every action writes an audit line: `[HUB-ACTION] <action> by user <id>`.
68
94
 
@@ -506,9 +506,20 @@ export function getHubClientJs(): string {
506
506
 
507
507
  Hub.panels.files = function (content) {
508
508
  pollInto(content, 'files.json', function (c, d) {
509
- c.appendChild(tiles([{ label: 'Files', value: d.total }, { label: 'Bucket', value: d.bucket }]));
509
+ // One tile per metadata store, so an empty store reads as "empty" rather than as "this panel
510
+ // does not cover your driver" — the ambiguity that made a GridFS-only listing look like the
511
+ // whole truth under file.storage: 's3'.
512
+ var storeTiles = [{ label: 'Files', value: d.total }];
513
+ (d.stores || []).forEach(function (s) {
514
+ storeTiles.push({ cls: s.error ? 'err' : '', label: s.store === 'gridfs' ? 'GridFS (' + d.bucket + ')' : s.store, value: s.error ? '!' : s.count });
515
+ });
516
+ c.appendChild(tiles(storeTiles));
517
+ (d.stores || []).filter(function (s) { return s.error; }).forEach(function (s) {
518
+ c.appendChild(el('p', { class: 'hub-hint', text: s.collection + ': ' + s.error }));
519
+ });
510
520
  c.appendChild(table([
511
521
  { key: 'filename', label: 'Filename' },
522
+ { key: 'store', label: 'Store', render: function (r) { return el('span', { class: 'hub-chip', text: r.store }); } },
512
523
  { key: 'length', label: 'Size', render: function (r) { return fmtBytes(r.length); } },
513
524
  { key: 'contentType', label: 'Type', render: function (r) { return r.contentType || '–'; } },
514
525
  { key: 'uploadDate', label: 'Uploaded', render: function (r) { return r.uploadDate ? relTime(r.uploadDate) : '–'; } },
@@ -21,8 +21,16 @@ export const HubActionMessage = {
21
21
  confirmationFilenameRequired: 'Confirmation filename required.',
22
22
  /** Test mail: the EmailService is not wired into this app. */
23
23
  emailServiceUnavailable: 'EmailService is not available.',
24
- /** Delete file: no GridFS file with the given id. */
24
+ /** Delete file: no file with the given id in any of the three metadata stores. */
25
25
  fileNotFound: 'File not found.',
26
+ /**
27
+ * Delete file: the file's bytes are in S3, but `CoreS3Service` is not configured in this process.
28
+ *
29
+ * Deliberately a REFUSAL rather than a metadata-only delete. Removing the document while the
30
+ * object stays in the bucket produces an orphan nothing can find again — unrecoverable in a way
31
+ * that "try again with S3 configured" is not.
32
+ */
33
+ s3Unavailable: 'S3 storage is not available.',
26
34
  /** Delete file: the id is not a valid ObjectId. */
27
35
  invalidFileId: 'Invalid file id.',
28
36
  /** Migrations action while the migrations panel is disabled. */
@@ -79,9 +79,39 @@ export interface HubMigrationsData {
79
79
  source: 'collection' | 'runner';
80
80
  }
81
81
 
82
+ /**
83
+ * Which metadata store a Hub file row came from.
84
+ *
85
+ * Mirrors `FileStorageDriver`, but is deliberately its OWN type: this one describes where a file
86
+ * was FOUND, which is a property of the row. `file.storage` describes where the next write GOES,
87
+ * which is a property of the config. Under a driver switch the two legitimately disagree for every
88
+ * pre-switch file — the whole reason this panel had to stop assuming one store.
89
+ */
90
+ export type HubFileStore = 'filesystem' | 'gridfs' | 's3';
91
+
92
+ /** Per-store summary for the Files panel, so an empty store reads as "empty", not "not covered". */
93
+ export interface HubFileStoreSummary {
94
+ /** The MongoDB collection holding this store's metadata (`fs.files`, `s3-files`, …). */
95
+ collection: string;
96
+ /** Documents in that collection. `0` for a store this deployment has never written to. */
97
+ count: number;
98
+ /** Present only when this store could not be read; the other stores are still listed. */
99
+ error?: string;
100
+ store: HubFileStore;
101
+ }
102
+
82
103
  export interface HubFilesData {
83
104
  bucket: string;
84
- files: { contentType?: string; filename: string; id: string; length: number; uploadDate?: string }[];
105
+ files: {
106
+ contentType?: string;
107
+ filename: string;
108
+ id: string;
109
+ length: number;
110
+ store: HubFileStore;
111
+ uploadDate?: string;
112
+ }[];
113
+ /** Every store consulted, whether or not it holds anything. */
114
+ stores: HubFileStoreSummary[];
85
115
  total: number;
86
116
  }
87
117
 
@@ -3,6 +3,7 @@ import { Injectable, Logger, Optional } from '@nestjs/common';
3
3
  import { EmailService } from '../../../common/services/email.service';
4
4
  import { RequestContext } from '../../../common/services/request-context.service';
5
5
  import { HubActionMessage } from '../hub-action-messages';
6
+ import { HubFileStore } from '../interfaces/hub-panels.interface';
6
7
  import { CoreHubDbService } from './core-hub-db.service';
7
8
  import { CoreHubEmailService } from './core-hub-email.service';
8
9
  import { CoreHubMailboxService } from './core-hub-mailbox.service';
@@ -70,8 +71,15 @@ export class CoreHubActionsService {
70
71
  return { action, name };
71
72
  }
72
73
 
73
- /** Delete a GridFS file (the confirm keyword must equal its filename). */
74
- async deleteFile(id: string, expectedFilename: string): Promise<{ deleted: { filename: string; id: string } }> {
74
+ /**
75
+ * Delete a file from whichever storage driver holds it (the confirm keyword must equal its
76
+ * filename). The resolved store is echoed back so the `[HUB-ACTION]` audit trail records WHERE
77
+ * the bytes were removed from, not merely that a delete happened.
78
+ */
79
+ async deleteFile(
80
+ id: string,
81
+ expectedFilename: string,
82
+ ): Promise<{ deleted: { filename: string; id: string; store: HubFileStore } }> {
75
83
  this.audit(`delete file ${id}`);
76
84
  return { deleted: await this.dbService.deleteFile(id, expectedFilename) };
77
85
  }