@lenne.tech/nest-server 11.32.3 → 11.33.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/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/bin/migrate.js +13 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +21 -2
- package/dist/core/common/helpers/file.helper.js +69 -13
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +15 -3
- package/dist/core/modules/file/core-file.controller.js +90 -20
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +3 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +55 -8
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +30 -5
- package/migration-guides/11.32.3-to-11.32.4.md +335 -0
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +201 -20
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +324 -41
- package/src/core/modules/file/core-file.controller.ts +267 -19
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +68 -6
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +144 -10
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mongo, Types } from 'mongoose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The metadata shape every non-GridFS storage driver writes, and the queries against it.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS FILE EXISTS: `S3FileHelper` and `FilesystemFileHelper` grew the same
|
|
7
|
+
* six-field document, the same `FileCollection` alias and the same four lookups
|
|
8
|
+
* independently. Two consequences, and the second one is the reason this is not
|
|
9
|
+
* merely tidiness:
|
|
10
|
+
*
|
|
11
|
+
* 1. The two drifted. `getRawFileInfoByName()` consulted S3 and GridFS but not the
|
|
12
|
+
* filesystem, so a by-name authorization rule saw a different set of files than
|
|
13
|
+
* the download served.
|
|
14
|
+
* 2. Because the documents are structurally identical, NOTHING in a fetched file
|
|
15
|
+
* info said which store it came from — so every caller that needed to know had
|
|
16
|
+
* to go and ask all three stores again. A single download resolved the same id
|
|
17
|
+
* up to three times.
|
|
18
|
+
*
|
|
19
|
+
* (2) is solved by `CoreFileService.resolveFile()`, which remembers WHICH PROBE
|
|
20
|
+
* ANSWERED and hands that down instead of letting the next caller re-probe. Not by
|
|
21
|
+
* the `storage` marker below — see its own doc for why the collection, not the
|
|
22
|
+
* field, is authoritative when reading.
|
|
23
|
+
*
|
|
24
|
+
* DELIBERATELY IMPORT-FREE apart from mongoose types, so it stays a leaf. See
|
|
25
|
+
* `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Metadata collection handle (no Mongoose schema — these collections are driver-managed) */
|
|
29
|
+
export type FileCollection = mongo.Collection<any>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which store holds a file's bytes, as recorded on the metadata document.
|
|
33
|
+
*
|
|
34
|
+
* GridFS is absent on purpose: its documents live in `fs.files` and are written by
|
|
35
|
+
* the driver, which will never carry this field. "No marker and found in fs.files"
|
|
36
|
+
* IS the GridFS case.
|
|
37
|
+
*/
|
|
38
|
+
export type FileStorageMarker = 'filesystem' | 's3';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Metadata of a file whose bytes live outside GridFS.
|
|
42
|
+
*
|
|
43
|
+
* The first six fields are exactly a GridFS `fs.files` document, which is what lets
|
|
44
|
+
* `prepareOutput()` map all three stores onto `CoreFileInfo` unchanged.
|
|
45
|
+
*/
|
|
46
|
+
export interface FileMetadataInfo {
|
|
47
|
+
_id: Types.ObjectId;
|
|
48
|
+
contentType?: string;
|
|
49
|
+
filename: string;
|
|
50
|
+
length: number;
|
|
51
|
+
metadata?: Record<string, any>;
|
|
52
|
+
/**
|
|
53
|
+
* Which driver wrote these bytes.
|
|
54
|
+
*
|
|
55
|
+
* FOR OPERATORS AND DIAGNOSTICS, not for dispatch. Code must derive the store from
|
|
56
|
+
* the COLLECTION a document was found in, never from this field: the two can only
|
|
57
|
+
* ever disagree through corruption or a hand-edit, and trusting the field there
|
|
58
|
+
* would send a read to the wrong store. It earns its place by making the store
|
|
59
|
+
* answerable in a plain query (`db['s3-files'].countDocuments({ storage: 's3' })`)
|
|
60
|
+
* and by surviving an export, where the collection name does not.
|
|
61
|
+
*
|
|
62
|
+
* Absent on documents written before 11.33.0, and on every GridFS document.
|
|
63
|
+
*/
|
|
64
|
+
storage?: FileStorageMarker;
|
|
65
|
+
uploadDate: Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Find one metadata document by id */
|
|
69
|
+
export async function findMetadataById(
|
|
70
|
+
collection: FileCollection,
|
|
71
|
+
id: string | Types.ObjectId,
|
|
72
|
+
): Promise<FileMetadataInfo | null> {
|
|
73
|
+
return (await collection.findOne({ _id: new Types.ObjectId(id) })) as FileMetadataInfo | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find one metadata document by filename.
|
|
78
|
+
*
|
|
79
|
+
* Resolves the FIRST match — filenames are not unique in any of these stores, and
|
|
80
|
+
* are client-supplied on both the multer and the tus path. Prefer the id lookup
|
|
81
|
+
* wherever the caller has an id.
|
|
82
|
+
*/
|
|
83
|
+
export async function findMetadataByName(
|
|
84
|
+
collection: FileCollection,
|
|
85
|
+
filename: string,
|
|
86
|
+
): Promise<FileMetadataInfo | null> {
|
|
87
|
+
return (await collection.findOne({ filename })) as FileMetadataInfo | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Find metadata documents by filter */
|
|
91
|
+
export async function findMetadata(
|
|
92
|
+
collection: FileCollection,
|
|
93
|
+
filter: any = {},
|
|
94
|
+
options: any = {},
|
|
95
|
+
): Promise<FileMetadataInfo[]> {
|
|
96
|
+
return (await collection.find(filter, options).toArray()) as FileMetadataInfo[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Namespaces whose `filename` index has already been ensured in this process.
|
|
101
|
+
*
|
|
102
|
+
* Keyed by `<db>.<collection>` rather than by object identity: the service builds a
|
|
103
|
+
* fresh collection handle per instance, and two instances in one process (the tests
|
|
104
|
+
* do exactly that) would otherwise each pay the round trip.
|
|
105
|
+
*/
|
|
106
|
+
const filenameIndexEnsured = new Set<string>();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ensure the `filename` index, ON THE WRITE PATH ONLY.
|
|
110
|
+
*
|
|
111
|
+
* These collections have no Mongoose schema, so nothing creates their indexes
|
|
112
|
+
* implicitly the way the driver does for GridFS's `fs.files`. Without one,
|
|
113
|
+
* `GET /files/:filename` is a collection scan that grows with the file count.
|
|
114
|
+
*
|
|
115
|
+
* WHY THIS IS NOT CALLED WHEN READING: `createIndex` CREATES the collection. Calling
|
|
116
|
+
* it on the read path made a deployment that only ever uses GridFS grow an empty
|
|
117
|
+
* `s3-files` and an empty `filesystem-files` — collections it has no reason to have,
|
|
118
|
+
* which then show up in backups and in the Hub's DB panel as if the driver were in
|
|
119
|
+
* use. A store that is never written to now stays absent, and a store that HAS been
|
|
120
|
+
* written to necessarily went through here, so the index exists exactly where there
|
|
121
|
+
* is data to index.
|
|
122
|
+
*
|
|
123
|
+
* NEVER THROWS. A missing index makes later reads slow, not wrong, and must not turn
|
|
124
|
+
* an upload into a 500 — so a failure only un-marks the namespace, and the next write
|
|
125
|
+
* tries again rather than pinning the failure for the process lifetime.
|
|
126
|
+
*
|
|
127
|
+
* @returns whether the index is now known to exist
|
|
128
|
+
*/
|
|
129
|
+
export async function ensureFilenameIndex(collection: FileCollection): Promise<boolean> {
|
|
130
|
+
const namespace = `${collection.dbName}.${collection.collectionName}`;
|
|
131
|
+
if (filenameIndexEnsured.has(namespace)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
filenameIndexEnsured.add(namespace);
|
|
135
|
+
try {
|
|
136
|
+
await collection.createIndex({ filename: 1 });
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
filenameIndexEnsured.delete(namespace);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
4
|
+
import { IFileConfig } from '../../common/interfaces/server-options.interface';
|
|
5
|
+
import { CoreFileController } from './core-file.controller';
|
|
6
|
+
import { CoreFileResolver } from './core-file.resolver';
|
|
7
|
+
|
|
8
|
+
const logger = new Logger('CoreFileRoles');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Roles applied when `file` is not configured at all.
|
|
12
|
+
*
|
|
13
|
+
* Restrictive on purpose: one GridFS bucket is shared by every feature of the
|
|
14
|
+
* consuming project, and the ObjectIds naming its blobs are not secrets.
|
|
15
|
+
*/
|
|
16
|
+
export type FileRoleKey = 'deleteRoles' | 'downloadRoles' | 'uploadRoles';
|
|
17
|
+
|
|
18
|
+
export const FILE_ROLE_DEFAULTS: Record<FileRoleKey, string[]> = {
|
|
19
|
+
deleteRoles: [RoleEnum.ADMIN],
|
|
20
|
+
downloadRoles: [RoleEnum.ADMIN],
|
|
21
|
+
uploadRoles: [RoleEnum.ADMIN],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Which member is governed by which knob.
|
|
26
|
+
*
|
|
27
|
+
* `getFileInfo` rides with `downloadRoles` rather than getting its own knob:
|
|
28
|
+
* it answers filename, size and content type for a blob, which is the metadata
|
|
29
|
+
* half of a download. Splitting it would let a project accidentally publish the
|
|
30
|
+
* bucket's contents list while believing downloads were still closed.
|
|
31
|
+
*/
|
|
32
|
+
const ROLE_TARGETS: { key: FileRoleKey; member: string; owner: () => unknown }[] = [
|
|
33
|
+
{
|
|
34
|
+
key: 'downloadRoles',
|
|
35
|
+
member: 'CoreFileController.getFileById',
|
|
36
|
+
owner: () => CoreFileController.prototype.getFileById,
|
|
37
|
+
},
|
|
38
|
+
{ key: 'downloadRoles', member: 'CoreFileController.getFile', owner: () => CoreFileController.prototype.getFile },
|
|
39
|
+
{ key: 'downloadRoles', member: 'CoreFileResolver.getFileInfo', owner: () => CoreFileResolver.prototype.getFileInfo },
|
|
40
|
+
{ key: 'uploadRoles', member: 'CoreFileResolver.uploadFile', owner: () => CoreFileResolver.prototype.uploadFile },
|
|
41
|
+
{ key: 'uploadRoles', member: 'CoreFileResolver.uploadFiles', owner: () => CoreFileResolver.prototype.uploadFiles },
|
|
42
|
+
{ key: 'deleteRoles', member: 'CoreFileResolver.deleteFile', owner: () => CoreFileResolver.prototype.deleteFile },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve one knob to the role list that will actually be applied.
|
|
47
|
+
*
|
|
48
|
+
* An empty array is treated as "not configured". It cannot mean "nobody": the
|
|
49
|
+
* guards read an all-empty role set as "no roles required" and return true, so
|
|
50
|
+
* honouring it literally would OPEN the route instead of closing it — the exact
|
|
51
|
+
* opposite of what someone writing `[]` intends.
|
|
52
|
+
*/
|
|
53
|
+
function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
|
|
54
|
+
const configured = config?.[key];
|
|
55
|
+
|
|
56
|
+
if (configured === undefined) {
|
|
57
|
+
return FILE_ROLE_DEFAULTS[key];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!Array.isArray(configured) || configured.length === 0 || configured.some((role) => typeof role !== 'string')) {
|
|
61
|
+
logger.warn(
|
|
62
|
+
`Ignoring file.${key}: expected a non-empty array of role strings, got ${JSON.stringify(configured)}. ` +
|
|
63
|
+
`Falling back to ${JSON.stringify(FILE_ROLE_DEFAULTS[key])}.`,
|
|
64
|
+
);
|
|
65
|
+
return FILE_ROLE_DEFAULTS[key];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return configured;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply the configured file roles to the core file endpoints.
|
|
73
|
+
*
|
|
74
|
+
* Uses `Reflect.defineMetadata` rather than `@Roles()` for the same reason
|
|
75
|
+
* `CorePermissionsModule` does: the value is only known at runtime, from the
|
|
76
|
+
* configuration. `RolesGuard` / `BetterAuthRolesGuard` read exactly this key.
|
|
77
|
+
*
|
|
78
|
+
* TWO PROPERTIES A READER SHOULD KNOW ABOUT:
|
|
79
|
+
*
|
|
80
|
+
* 1. **ADMIN always keeps access.** Both classes carry a class-level
|
|
81
|
+
* `@Roles(RoleEnum.ADMIN)`, and the guards UNION handler and class metadata
|
|
82
|
+
* (`mergeRolesMetadata`) rather than letting one override the other. So
|
|
83
|
+
* `downloadRoles: ['editor']` grants editors *in addition to* admins. That is
|
|
84
|
+
* intended — it keeps a misconfiguration from locking the owner out — but it
|
|
85
|
+
* does mean these knobs cannot be used to exclude admins.
|
|
86
|
+
*
|
|
87
|
+
* 2. **A subclass that OVERRIDES a member opts out of the configuration.**
|
|
88
|
+
* Decorator metadata lives on the function object; an override is a different
|
|
89
|
+
* function, so what this writes onto the base method no longer applies.
|
|
90
|
+
* Consumers who want config-driven roles must inherit the member rather than
|
|
91
|
+
* re-declaring it. This is precisely the trap that kept `nest-server-starter`
|
|
92
|
+
* serving public downloads after the core default had already been closed.
|
|
93
|
+
*/
|
|
94
|
+
export function applyFileRoles(config?: IFileConfig): void {
|
|
95
|
+
const resolved = new Map<FileRoleKey, string[]>();
|
|
96
|
+
|
|
97
|
+
for (const { key, member, owner } of ROLE_TARGETS) {
|
|
98
|
+
if (!resolved.has(key)) {
|
|
99
|
+
resolved.set(key, resolveRoles(key, config));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const target = owner();
|
|
103
|
+
if (typeof target !== 'function') {
|
|
104
|
+
logger.warn(`Cannot apply file.${key}: ${member} is not a function — skipping.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Reflect.defineMetadata('roles', resolved.get(key), target);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { IServerOptions } from '../../common/interfaces/server-options.interface';
|
|
4
|
+
|
|
5
|
+
const logger = new Logger('CoreFileStorage');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The three storage drivers. All equivalent in what they offer through
|
|
9
|
+
* `CoreFileService`; they differ only in where the bytes end up.
|
|
10
|
+
*
|
|
11
|
+
* - `'s3'` — an S3-compatible bucket. The only one that survives horizontal scaling.
|
|
12
|
+
* - `'gridfs'` — MongoDB GridFS. No extra infrastructure, bytes share the database.
|
|
13
|
+
* - `'filesystem'` — the local disk. Pod-local: not shared between replicas, lost on restart
|
|
14
|
+
* unless the path is a mounted volume.
|
|
15
|
+
*/
|
|
16
|
+
export type FileStorageDriver = 'filesystem' | 'gridfs' | 's3';
|
|
17
|
+
|
|
18
|
+
export const FILE_STORAGE_DRIVERS: FileStorageDriver[] = ['filesystem', 'gridfs', 's3'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How a driver was arrived at — for the boot log and for error messages, so a
|
|
22
|
+
* surprising choice can be traced back to the setting that produced it.
|
|
23
|
+
*/
|
|
24
|
+
export interface FileStorageResolution {
|
|
25
|
+
driver: FileStorageDriver;
|
|
26
|
+
explicit: boolean;
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether the S3 configuration names a concrete bucket to write to.
|
|
32
|
+
*
|
|
33
|
+
* A bucket is the one thing S3 cannot default: region, endpoint and credentials
|
|
34
|
+
* all have fallbacks (the AWS default credential chain resolves an IAM role,
|
|
35
|
+
* an instance profile or `AWS_*` environment variables without anything in the
|
|
36
|
+
* config), but "which bucket" has to be stated. So the presence of a bucket is
|
|
37
|
+
* what makes an S3 configuration usable — and therefore what makes S3 eligible
|
|
38
|
+
* as the automatic default.
|
|
39
|
+
*/
|
|
40
|
+
export function hasUsableS3Config(config?: Partial<IServerOptions>): boolean {
|
|
41
|
+
const s3 = config?.s3;
|
|
42
|
+
if (!s3 || typeof s3 !== 'object' || (s3 as { enabled?: boolean }).enabled === false) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return !!(s3 as { bucket?: string }).bucket;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a database connection is configured.
|
|
50
|
+
*
|
|
51
|
+
* Note "configured", not "reachable": an unreachable but configured database is
|
|
52
|
+
* an ERROR, never a reason to quietly store files somewhere else. Mongoose fails
|
|
53
|
+
* the boot on its own in that case, which is the behaviour we want — silently
|
|
54
|
+
* degrading to the local disk would scatter a project's files across two stores
|
|
55
|
+
* during an outage and leave no trace of which file went where.
|
|
56
|
+
*/
|
|
57
|
+
export function hasDatabaseConfig(config?: Partial<IServerOptions>): boolean {
|
|
58
|
+
return !!config?.mongoose?.uri;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decide which storage driver to use.
|
|
63
|
+
*
|
|
64
|
+
* An EXPLICIT `file.storage` always wins and is never second-guessed here —
|
|
65
|
+
* whether the chosen store is actually reachable is asserted separately, at
|
|
66
|
+
* boot, by `assertFileStorageAvailable()`. That split matters: this function
|
|
67
|
+
* answers "what did the project ask for", not "did it work".
|
|
68
|
+
*
|
|
69
|
+
* Without an explicit value the default is derived, most capable first:
|
|
70
|
+
*
|
|
71
|
+
* 1. `s3` — when the S3 config names a bucket
|
|
72
|
+
* 2. `gridfs` — when a database is configured
|
|
73
|
+
* 3. `filesystem` — when neither is (today a theoretical case: `CoreModule`
|
|
74
|
+
* always registers Mongoose, so a project without a database
|
|
75
|
+
* does not boot at all)
|
|
76
|
+
*
|
|
77
|
+
* @param config the merged server configuration
|
|
78
|
+
*/
|
|
79
|
+
export function resolveFileStorage(config?: Partial<IServerOptions>): FileStorageResolution {
|
|
80
|
+
const configured = config?.file?.storage;
|
|
81
|
+
|
|
82
|
+
if (configured !== undefined) {
|
|
83
|
+
if (!FILE_STORAGE_DRIVERS.includes(configured as FileStorageDriver)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Invalid file.storage: ${JSON.stringify(configured)}. ` +
|
|
86
|
+
`Expected one of ${FILE_STORAGE_DRIVERS.map((d) => `'${d}'`).join(', ')}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
driver: configured as FileStorageDriver,
|
|
91
|
+
explicit: true,
|
|
92
|
+
reason: `file.storage is set to '${configured}'`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (hasUsableS3Config(config)) {
|
|
97
|
+
return { driver: 's3', explicit: false, reason: 's3.bucket is configured' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (hasDatabaseConfig(config)) {
|
|
101
|
+
return { driver: 'gridfs', explicit: false, reason: 'a database is configured and no S3 bucket is' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
driver: 'filesystem',
|
|
106
|
+
explicit: false,
|
|
107
|
+
reason: 'neither an S3 bucket nor a database is configured',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fail the boot when the resolved driver cannot actually be used.
|
|
113
|
+
*
|
|
114
|
+
* This is the deliberate opposite of the previous behaviour, which silently fell
|
|
115
|
+
* back to GridFS whenever S3 was selected but unavailable. That failure mode is
|
|
116
|
+
* the worst kind: the application keeps working, so nothing looks broken, while
|
|
117
|
+
* files land in a store the operator does not believe they are in — and no
|
|
118
|
+
* migration path exists afterwards, because nobody knows which file went where.
|
|
119
|
+
*
|
|
120
|
+
* A misconfiguration should stop the process at boot, where it is cheap.
|
|
121
|
+
*
|
|
122
|
+
* Both an explicit and a derived choice are enforced — only the message differs.
|
|
123
|
+
* A derived one is not automatically safe: `s3.bucket` in the config makes S3
|
|
124
|
+
* the default, but the bytes still go nowhere unless the project's own
|
|
125
|
+
* `FileService` forwards `s3Service` to `super()`.
|
|
126
|
+
*
|
|
127
|
+
* @param resolution result of `resolveFileStorage()`
|
|
128
|
+
* @param available whether the driver's backing service reports itself usable
|
|
129
|
+
*/
|
|
130
|
+
export function assertFileStorageAvailable(resolution: FileStorageResolution, available: boolean): void {
|
|
131
|
+
if (available) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!resolution.explicit) {
|
|
136
|
+
// A derived driver can still be unusable — the commonest case being an `s3`
|
|
137
|
+
// block that names a bucket while the project's own FileService never
|
|
138
|
+
// forwards `s3Service` to `super()`, so the driver has nothing to write
|
|
139
|
+
// through. Failing here is the point: the alternative is files silently
|
|
140
|
+
// landing in GridFS while the operator reads the config and believes S3.
|
|
141
|
+
throw new Error(
|
|
142
|
+
`File storage '${resolution.driver}' was selected automatically (${resolution.reason}) but is not available. ` +
|
|
143
|
+
'Either make it available (for S3: forward `{ configService, s3Service }` to `super()` in your FileService ' +
|
|
144
|
+
'and install `@aws-sdk/client-s3`), or pin a different driver with `file.storage`.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hint =
|
|
149
|
+
resolution.driver === 's3'
|
|
150
|
+
? 'Configure `s3` (bucket, credentials/endpoint) and install `@aws-sdk/client-s3`, or choose a different `file.storage`.'
|
|
151
|
+
: `Check the configuration for the '${resolution.driver}' driver, or choose a different \`file.storage\`.`;
|
|
152
|
+
|
|
153
|
+
throw new Error(`file.storage is set to '${resolution.driver}', but that storage is not available. ${hint}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Log the resolved driver once, so the store in use is visible in the boot log
|
|
158
|
+
* rather than having to be inferred from where files stop appearing.
|
|
159
|
+
*/
|
|
160
|
+
export function logFileStorage(resolution: FileStorageResolution): void {
|
|
161
|
+
const how = resolution.explicit ? 'configured' : 'defaulted';
|
|
162
|
+
logger.log(`File storage: ${resolution.driver} (${how} — ${resolution.reason})`);
|
|
163
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'fs';
|
|
2
|
+
import { mkdir, rm, stat } from 'fs/promises';
|
|
3
|
+
import { Types } from 'mongoose';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { pipeline } from 'stream/promises';
|
|
6
|
+
import { Readable } from 'stream';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ensureFilenameIndex,
|
|
10
|
+
FileCollection,
|
|
11
|
+
FileMetadataInfo,
|
|
12
|
+
findMetadata,
|
|
13
|
+
findMetadataById,
|
|
14
|
+
findMetadataByName,
|
|
15
|
+
} from './file-metadata.helper';
|
|
16
|
+
import { streamToBuffer } from './s3-file.helper';
|
|
17
|
+
|
|
18
|
+
/** Metadata collection for files stored on the local filesystem */
|
|
19
|
+
export const FILESYSTEM_FILES_COLLECTION = 'filesystem-files';
|
|
20
|
+
|
|
21
|
+
/** Default directory for the filesystem storage driver */
|
|
22
|
+
export const DEFAULT_FILESYSTEM_DIR = 'uploads/files';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Metadata of a file stored on the local filesystem.
|
|
26
|
+
*
|
|
27
|
+
* Alias of the shared {@link FileMetadataInfo} — same document as `S3FileInfo` and,
|
|
28
|
+
* in its first six fields, as a GridFS `fs.files` document, which is what lets
|
|
29
|
+
* `prepareOutput()` map all three onto `CoreFileInfo` unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export type FilesystemFileInfo = FileMetadataInfo;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Helper for files stored on the local filesystem with their metadata in MongoDB.
|
|
35
|
+
*
|
|
36
|
+
* Counterpart of `GridFSHelper` and `S3FileHelper`: same operations, the local
|
|
37
|
+
* disk as the byte store and `filesystem-files` as the metadata store.
|
|
38
|
+
*
|
|
39
|
+
* WHY THE METADATA STILL LIVES IN MONGO: filename, content type, length and the
|
|
40
|
+
* custom `metadata` a per-file authorization rule reads have to be queryable —
|
|
41
|
+
* `findFileInfo()` filters and pages over them, and `checkRights()` reads them
|
|
42
|
+
* per request. A directory listing answers none of that, and sidecar files would
|
|
43
|
+
* reinvent an index that the database already is. So this driver moves the
|
|
44
|
+
* BYTES off the database, not the bookkeeping.
|
|
45
|
+
*
|
|
46
|
+
* OPERATIONAL CONSEQUENCE: the directory is pod-local. Two replicas do not share
|
|
47
|
+
* it, and a container restart discards it unless the path is a mounted volume.
|
|
48
|
+
* That makes this the right driver for a single-instance deployment or a real
|
|
49
|
+
* volume, and the wrong one for a horizontally scaled service — use S3 there.
|
|
50
|
+
*/
|
|
51
|
+
export class FilesystemFileHelper {
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the absolute path of a stored file.
|
|
54
|
+
*
|
|
55
|
+
* The id is an ObjectId, so its hex form cannot contain a path separator or
|
|
56
|
+
* `..` — the join can never escape the base directory. Callers must not pass
|
|
57
|
+
* a user-supplied filename here.
|
|
58
|
+
*/
|
|
59
|
+
static filePath(directory: string, id: string | Types.ObjectId): string {
|
|
60
|
+
const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
|
|
61
|
+
return path.join(path.resolve(directory), objectId.toHexString());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Store a file on disk and record its metadata.
|
|
66
|
+
*
|
|
67
|
+
* The written size is verified via `stat()` before the metadata document is
|
|
68
|
+
* written, so a file info is never returned for bytes that did not fully
|
|
69
|
+
* arrive — the same guarantee `GridFSHelper` gives by reading the file
|
|
70
|
+
* document back, and `S3FileHelper` by issuing a HEAD.
|
|
71
|
+
*
|
|
72
|
+
* On any failure the partial file is removed before the error propagates,
|
|
73
|
+
* otherwise a failed upload would leave an orphan the metadata never names.
|
|
74
|
+
*/
|
|
75
|
+
static async writeFile(
|
|
76
|
+
directory: string,
|
|
77
|
+
collection: FileCollection,
|
|
78
|
+
options: {
|
|
79
|
+
body?: Readable;
|
|
80
|
+
buffer?: Buffer;
|
|
81
|
+
contentType?: string;
|
|
82
|
+
filename: string;
|
|
83
|
+
metadata?: Record<string, any>;
|
|
84
|
+
},
|
|
85
|
+
): Promise<FilesystemFileInfo> {
|
|
86
|
+
const _id = new Types.ObjectId();
|
|
87
|
+
const target = FilesystemFileHelper.filePath(directory, _id);
|
|
88
|
+
|
|
89
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (options.buffer) {
|
|
93
|
+
await pipeline(Readable.from(options.buffer), createWriteStream(target));
|
|
94
|
+
} else if (options.body) {
|
|
95
|
+
await pipeline(options.body, createWriteStream(target));
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error('FilesystemFileHelper.writeFile requires either `buffer` or `body`');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stats = await stat(target);
|
|
101
|
+
if (!stats.size && (options.buffer?.length || 0) > 0) {
|
|
102
|
+
throw new Error(`File ${_id.toHexString()} was written empty`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// On the WRITE path, not the read path: createIndex creates the collection, so
|
|
106
|
+
// ensuring it when reading gave a GridFS-only deployment an empty
|
|
107
|
+
// `filesystem-files`. Here the collection is about to exist anyway. Never throws.
|
|
108
|
+
await ensureFilenameIndex(collection);
|
|
109
|
+
|
|
110
|
+
const fileInfo: FilesystemFileInfo = {
|
|
111
|
+
_id,
|
|
112
|
+
contentType: options.contentType,
|
|
113
|
+
filename: options.filename,
|
|
114
|
+
length: stats.size,
|
|
115
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
116
|
+
// Records WHERE the bytes went, so a reader never has to probe all three
|
|
117
|
+
// stores to find out. Legacy documents lack it and are handled by probing.
|
|
118
|
+
storage: 'filesystem',
|
|
119
|
+
uploadDate: new Date(),
|
|
120
|
+
};
|
|
121
|
+
await collection.insertOne(fileInfo as any);
|
|
122
|
+
return fileInfo;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await rm(target, { force: true }).catch(() => undefined);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Find file metadata by ID
|
|
131
|
+
*/
|
|
132
|
+
static async findFileById(
|
|
133
|
+
collection: FileCollection,
|
|
134
|
+
id: string | Types.ObjectId,
|
|
135
|
+
): Promise<FilesystemFileInfo | null> {
|
|
136
|
+
return findMetadataById(collection, id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find file metadata by filename
|
|
141
|
+
*/
|
|
142
|
+
static async findFileByName(collection: FileCollection, filename: string): Promise<FilesystemFileInfo | null> {
|
|
143
|
+
return findMetadataByName(collection, filename);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Find files with filter and options
|
|
148
|
+
*/
|
|
149
|
+
static async findFiles(
|
|
150
|
+
collection: FileCollection,
|
|
151
|
+
filter: any = {},
|
|
152
|
+
options: any = {},
|
|
153
|
+
): Promise<FilesystemFileInfo[]> {
|
|
154
|
+
return findMetadata(collection, filter, options);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Delete a file from disk and remove its metadata.
|
|
159
|
+
*
|
|
160
|
+
* The metadata goes last: a missing file with a metadata row answers 404
|
|
161
|
+
* through the normal "file document without bytes" path, while a stored file
|
|
162
|
+
* with no metadata row is invisible to every lookup and can never be cleaned
|
|
163
|
+
* up through the API.
|
|
164
|
+
*/
|
|
165
|
+
static async deleteFile(directory: string, collection: FileCollection, id: string | Types.ObjectId): Promise<void> {
|
|
166
|
+
const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
|
|
167
|
+
await rm(FilesystemFileHelper.filePath(directory, objectId), { force: true });
|
|
168
|
+
await collection.deleteOne({ _id: objectId });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the download stream of a file
|
|
173
|
+
*/
|
|
174
|
+
static getStream(directory: string, id: string | Types.ObjectId): Readable {
|
|
175
|
+
return createReadStream(FilesystemFileHelper.filePath(directory, id));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get the content of a file as a buffer
|
|
180
|
+
*/
|
|
181
|
+
static async getBuffer(directory: string, id: string | Types.ObjectId): Promise<Buffer> {
|
|
182
|
+
return streamToBuffer(FilesystemFileHelper.getStream(directory, id));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -4,5 +4,19 @@
|
|
|
4
4
|
export interface FileServiceOptions {
|
|
5
5
|
currentUser?: { hasRole: (roles: string[]) => boolean; id: any };
|
|
6
6
|
force?: boolean;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Custom GridFS metadata to store alongside the file (create operations only).
|
|
10
|
+
*
|
|
11
|
+
* This is what a per-file authorization rule is meant to be built on: write
|
|
12
|
+
* an owner or tenant here at upload time, then compare against it in an
|
|
13
|
+
* overridden `checkRights()` (use `getRawFileInfo()` to read it back — the
|
|
14
|
+
* public `getFileInfo()` strips restricted fields).
|
|
15
|
+
*
|
|
16
|
+
* `contentType` is reserved: `GridFSHelper.writeFileFromStream` stores the
|
|
17
|
+
* file's content type under that key and will overwrite whatever is passed.
|
|
18
|
+
*/
|
|
19
|
+
metadata?: Record<string, any>;
|
|
20
|
+
|
|
7
21
|
roles?: string | string[];
|
|
8
22
|
}
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { WriteStream } from 'fs-capacitor';
|
|
2
2
|
import { Readable } from 'stream';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* What a file store actually needs from an upload.
|
|
6
|
+
*
|
|
7
|
+
* `FileUpload` additionally carries the graphql-upload `capacitor`, which no
|
|
8
|
+
* store touches — so requiring the full type would exclude every upload that did
|
|
9
|
+
* not arrive over GraphQL (a multer REST upload being the case in point, see
|
|
10
|
+
* `multerFileToUpload()`). `FileUpload` satisfies this structurally, so both
|
|
11
|
+
* paths share a single service signature.
|
|
12
|
+
*/
|
|
13
|
+
export interface FileUploadSource {
|
|
14
|
+
createReadStream: (options?: unknown) => Readable;
|
|
15
|
+
encoding?: string;
|
|
16
|
+
filename: string;
|
|
17
|
+
mimetype: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
4
20
|
/**
|
|
5
21
|
* Interface for file uploads
|
|
6
22
|
*/
|
|
7
|
-
export interface FileUpload {
|
|
23
|
+
export interface FileUpload extends FileUploadSource {
|
|
8
24
|
/**
|
|
9
25
|
* A private implementation detail that shouldn’t be used outside
|
|
10
26
|
*/
|