@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.
- package/.claude/rules/testing.md +181 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/gridfs.helper.d.ts +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +43 -5
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +4 -0
- package/dist/core/common/services/core-s3.service.js +28 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +4 -2
- package/dist/core/modules/file/core-file.service.js +46 -47
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js +12 -1
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-action-messages.d.ts +1 -0
- package/dist/core/modules/hub/hub-action-messages.js +1 -0
- package/dist/core/modules/hub/hub-action-messages.js.map +1 -1
- package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +9 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +2 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -1
- package/dist/core/modules/hub/services/core-hub-db.service.d.ts +10 -2
- package/dist/core/modules/hub/services/core-hub-db.service.js +85 -29
- package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +2 -1
- package/dist/server/modules/file/file.service.js +2 -2
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.23.x-to-11.24.0.md +1 -1
- package/migration-guides/11.32.x-to-11.33.x.md +2 -0
- package/migration-guides/11.33.x-to-11.34.x.md +394 -0
- package/package.json +3 -1
- 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/README.md +42 -6
- package/src/core/modules/file/core-file.service.ts +131 -59
- package/src/core/modules/hub/README.md +35 -9
- package/src/core/modules/hub/helpers/hub-client-js.helper.ts +12 -1
- package/src/core/modules/hub/hub-action-messages.ts +9 -1
- package/src/core/modules/hub/interfaces/hub-panels.interface.ts +31 -1
- package/src/core/modules/hub/services/core-hub-actions.service.ts +10 -2
- package/src/core/modules/hub/services/core-hub-db.service.ts +155 -31
- package/src/server/modules/file/file.service.ts +20 -3
|
@@ -93,6 +93,26 @@ Reads consult **every** store, so files written under a previous driver stay rea
|
|
|
93
93
|
migration step and no cut-over moment. New files go to the active driver; `findFileInfo()` returns
|
|
94
94
|
the union, paged once over the merged result.
|
|
95
95
|
|
|
96
|
+
That holds per method and in **both** directions — adopting a driver and switching away from one.
|
|
97
|
+
The metadata lookups are gated on whether a store is USABLE, never on whether it is the active write
|
|
98
|
+
driver, so pinning `file.storage: 'gridfs'` on a deployment that once used S3 keeps the S3 files
|
|
99
|
+
readable rather than 404-ing them.
|
|
100
|
+
|
|
101
|
+
Three consequences worth knowing about the merged read path:
|
|
102
|
+
|
|
103
|
+
- **Ordering.** Each store answers its own correctly ordered page, and the merge re-establishes a
|
|
104
|
+
global order before it pages (`sortMergedFileInfo()`), including dotted sort fields such as
|
|
105
|
+
`metadata.ownerId`. Without a `sort` the merged page is `uploadDate` descending.
|
|
106
|
+
- **`contentType`.** GridFS keeps it inside `metadata`, the other two stores at the document root.
|
|
107
|
+
`GridFSHelper.findFiles()` rewrites the key so a filter or sort on `contentType` behaves the same
|
|
108
|
+
under every driver.
|
|
109
|
+
- **Duplication crosses drivers.** `duplicateByName()` / `duplicateById()` read the source from
|
|
110
|
+
whichever store holds it and write the copy to the ACTIVE driver, so duplicating a file that is
|
|
111
|
+
still in GridFS while `file.storage: 's3'` simply moves it forward.
|
|
112
|
+
|
|
113
|
+
`s3-files` and `filesystem-files` are created on their first WRITE, never on a read — a deployment
|
|
114
|
+
that only ever uses GridFS never grows them.
|
|
115
|
+
|
|
96
116
|
The boot log names the driver in use, so it never has to be inferred from where files stopped
|
|
97
117
|
appearing:
|
|
98
118
|
|
|
@@ -184,6 +204,20 @@ rather than exempt from it. The reference server does both: `src/server/modules/
|
|
|
184
204
|
`src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
|
|
185
205
|
`id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
|
|
186
206
|
|
|
207
|
+
**It covers duplication too (11.34.0+).** `duplicateByName()` / `duplicateById()` authorize a copy as
|
|
208
|
+
a READ of the source (`'filename'` / `'id'`) plus a WRITE of the copy (`'file'`), through the same
|
|
209
|
+
public methods every other caller uses — so forward the context:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
await this.fileService.duplicateById(id, { currentUser });
|
|
213
|
+
// …and give the COPY its own owner, because it does NOT inherit the source's metadata
|
|
214
|
+
await this.fileService.duplicateById(id, { currentUser, metadata: { ownerId: currentUser.id } });
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Not copying the source's `metadata` is deliberate: doing so would silently hand the duplicate the
|
|
218
|
+
source's owner. A copy made without metadata is ADMIN-only under the rule above — fail-closed, not
|
|
219
|
+
lost.
|
|
220
|
+
|
|
187
221
|
Three pieces make this work, and all three are needed:
|
|
188
222
|
|
|
189
223
|
1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
|
|
@@ -325,12 +359,14 @@ export class FileController extends CoreFileController {
|
|
|
325
359
|
|
|
326
360
|
### Error responses
|
|
327
361
|
|
|
328
|
-
| Situation | Status | Body
|
|
329
|
-
| -------------------------------------------------------------------------------------- | ------ |
|
|
330
|
-
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND`
|
|
331
|
-
|
|
|
332
|
-
|
|
|
333
|
-
|
|
|
362
|
+
| Situation | Status | Body |
|
|
363
|
+
| -------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
364
|
+
| Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
|
|
365
|
+
| `deleteFile()` / `deleteFileByName()` / `duplicate*()` on a file that is not there | `404` | `NotFoundException`. Both halves of each id/name pair answer identically — up to 11.33.1 the by-id delete surfaced the driver's `MongoRuntimeError` as a `500` |
|
|
366
|
+
| Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
|
|
367
|
+
| An upload's SOURCE stream fails (client aborts, staged read drops) | `500` | The promise rejects with the cause — under **every** driver. It used to be an uncaught exception (i.e. a process exit) on GridFS, and on the S3 streaming path tus finalization uses |
|
|
368
|
+
| GridFS read fails **before** any byte was sent (file document exists, chunks are gone) | `404` | `{ "error": "Not Found", "message": "<FILE_NOT_FOUND>", "statusCode": 404 }` |
|
|
369
|
+
| GridFS read fails **after** streaming started | — | The connection is closed; a truncated transfer is the only signal left once the status is on the wire |
|
|
334
370
|
|
|
335
371
|
The mid-stream failure case is handled by `pipeFileToResponse()`. Without it the stream error would
|
|
336
372
|
go unhandled, Node would destroy the socket, and a reverse proxy would report **502 Bad Gateway** —
|
|
@@ -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;
|
|
@@ -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
|
}
|