@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.
Files changed (55) hide show
  1. package/.claude/rules/testing.md +181 -3
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/config.env.js +6 -0
  4. package/dist/config.env.js.map +1 -1
  5. package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
  6. package/dist/core/common/helpers/gridfs.helper.js +43 -5
  7. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  8. package/dist/core/common/services/core-s3.service.d.ts +4 -0
  9. package/dist/core/common/services/core-s3.service.js +28 -1
  10. package/dist/core/common/services/core-s3.service.js.map +1 -1
  11. package/dist/core/modules/file/core-file.service.d.ts +4 -2
  12. package/dist/core/modules/file/core-file.service.js +47 -48
  13. package/dist/core/modules/file/core-file.service.js.map +1 -1
  14. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
  15. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
  16. package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
  17. package/dist/core/modules/hub/hub-action-messages.js +1 -0
  18. package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
  19. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
  20. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
  21. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
  22. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
  23. package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
  24. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
  25. package/dist/server/modules/file/file.controller.js +2 -2
  26. package/dist/server/modules/file/file.controller.js.map +1 -1
  27. package/dist/server/modules/file/file.resolver.js +2 -2
  28. package/dist/server/modules/file/file.resolver.js.map +1 -1
  29. package/dist/server/modules/file/file.service.d.ts +7 -2
  30. package/dist/server/modules/file/file.service.js +13 -2
  31. package/dist/server/modules/file/file.service.js.map +1 -1
  32. package/dist/server/modules/user/avatar.controller.js +1 -1
  33. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  34. package/dist/tsconfig.build.tsbuildinfo +1 -1
  35. package/docs/REQUEST-LIFECYCLE.md +1 -1
  36. package/migration-guides/11.23.x-to-11.24.0.md +1 -1
  37. package/migration-guides/11.32.x-to-11.33.x.md +39 -4
  38. package/migration-guides/11.33.x-to-11.34.x.md +394 -0
  39. package/package.json +3 -1
  40. package/src/config.env.ts +45 -0
  41. package/src/core/common/helpers/gridfs.helper.ts +92 -7
  42. package/src/core/common/services/core-s3.service.ts +71 -9
  43. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
  44. package/src/core/modules/file/README.md +77 -10
  45. package/src/core/modules/file/core-file.service.ts +170 -64
  46. package/src/core/modules/hub/README.md +35 -9
  47. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
  48. package/src/core/modules/hub/hub-action-messages.ts +9 -1
  49. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
  50. package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
  51. package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
  52. package/src/server/modules/file/file.controller.ts +7 -2
  53. package/src/server/modules/file/file.resolver.ts +6 -2
  54. package/src/server/modules/file/file.service.ts +97 -34
  55. package/src/server/modules/user/avatar.controller.ts +8 -1
@@ -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;
@@ -557,7 +629,13 @@ export abstract class CoreFileService {
557
629
  if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
558
630
  return null;
559
631
  }
560
- const fileInfo = await this.getFileInfoByName(filename);
632
+ // Forward the caller's context, exactly as deleteFile() does for getFileInfo().
633
+ // Without it this method authorizes the caller and then re-resolves the file with
634
+ // an EMPTY context, so an overridden checkRights() is asked two different
635
+ // questions about one request. For an ownership rule that means the lookup denies
636
+ // — and the caller gets `File not found` for a file that exists and that they were
637
+ // just authorized for, which reads as a missing file rather than a refused one.
638
+ const fileInfo = await this.getFileInfoByName(filename, serviceOptions);
561
639
  if (!fileInfo) {
562
640
  throw new NotFoundException(`File not found with filename ${filename}`);
563
641
  }
@@ -665,20 +743,48 @@ export abstract class CoreFileService {
665
743
  * routes. Metadata to compare against must be written at upload time via
666
744
  * `serviceOptions.metadata` and read back with `getRawFileInfo()`.
667
745
  *
746
+ * **A missing `currentUser` must DENY.** This is the one part of the rule that
747
+ * is easy to get backwards: "no user in context" is NOT "system-internal call"
748
+ * — it is also exactly what an ANONYMOUS request looks like. The coarse gate
749
+ * turns those away while `downloadRoles` is narrower than `S_EVERYONE`, so an
750
+ * `if (!options.currentUser) return true` shortcut looks harmless — right up to
751
+ * the moment a project widens the gate, at which point it hands every file to
752
+ * everyone and the ownership rule evaporates precisely when it starts to
753
+ * matter. Genuinely internal callers say so with `force: true`, or pass the
754
+ * user they already have; see `src/server/modules/file` and
755
+ * `src/server/modules/user/avatar.controller.ts`.
756
+ *
757
+ * **Cover the `filename` branch too, not just `id`.** An id-only rule is
758
+ * enough while bytes are streamed, because the filename route resolves an id
759
+ * and checks it again — but NOT once `s3.presignedDownloads` is enabled, where
760
+ * the filename route authorizes on the by-name lookup alone and then redirects,
761
+ * and not for `deleteFileByName()`, which authorizes by name only.
762
+ *
763
+ * The example below is the rule `src/server/modules/file/file.service.ts`
764
+ * actually runs — it is compiled, type-checked and exercised end to end by
765
+ * every file-touching e2e spec, plus the dedicated contract test in
766
+ * `tests/file-ownership.e2e-spec.ts`. Prefer reading it there over copying
767
+ * from here.
768
+ *
668
769
  * @example
669
770
  * ```typescript
670
771
  * protected override async checkRights(
671
772
  * input: any,
672
773
  * options?: FileServiceOptions & { checkInputType: FileInputCheckType },
673
774
  * ): Promise<boolean> {
674
- * if (options?.checkInputType !== 'id' || options.force) {
775
+ * if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
675
776
  * return true;
676
777
  * }
677
- * if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
778
+ * if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
678
779
  * return true;
679
780
  * }
680
- * const raw = await this.getRawFileInfo(input);
681
- * return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
781
+ * const raw = options.checkInputType === 'id'
782
+ * ? await this.getRawFileInfo(input)
783
+ * : await this.getRawFileInfoByName(input);
784
+ * // Fails closed without a user: `String(undefined)` cannot equal a real owner id.
785
+ * // Requiring the owner to be PRESENT is load-bearing too — comparing a missing
786
+ * // ownerId against a missing user id would compare 'undefined' with 'undefined'.
787
+ * return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
682
788
  * }
683
789
  * ```
684
790
  */
@@ -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
  }