@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Logger, NotFoundException } from '@nestjs/common';
|
|
2
|
-
import
|
|
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<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
321
|
-
const right = b
|
|
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
|
-
|
|
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 !== '
|
|
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 =
|
|
681
|
-
*
|
|
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` |
|
|
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
|
|
59
|
-
|
|
|
60
|
-
| Run pending migrations
|
|
61
|
-
| Rollback last migration
|
|
62
|
-
| Delete
|
|
63
|
-
| Cron start/stop/trigger
|
|
64
|
-
| Clear collector buffer
|
|
65
|
-
| Send test mail
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
}
|