@lenne.tech/nest-server 11.32.4 → 11.33.1
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/dist/config.env.js +6 -0
- package/dist/config.env.js.map +1 -1
- 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 +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- 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 +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- 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 +228 -8
- 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/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- 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 +2 -2
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +3 -14
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +10 -2
- package/dist/server/modules/file/file.service.js +23 -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 +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1584 -0
- package/package.json +74 -42
- package/src/config.env.ts +45 -0
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- 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 +120 -0
- package/src/core/modules/file/README.md +308 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +517 -17
- 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 +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- 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 +14 -5
- package/src/server/modules/file/file.resolver.ts +11 -19
- package/src/server/modules/file/file.service.ts +89 -4
- package/src/server/modules/user/avatar.controller.ts +46 -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/file.helper.spec.ts +0 -145
- 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/file/core-file.controller.spec.ts +0 -164
- 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
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -4,14 +4,291 @@ File upload and download functionality with MongoDB GridFS storage.
|
|
|
4
4
|
|
|
5
5
|
## Endpoints
|
|
6
6
|
|
|
7
|
-
###
|
|
7
|
+
### Download Endpoints (via CoreFileController)
|
|
8
8
|
|
|
9
|
-
| Method | Endpoint | Description |
|
|
10
|
-
| ------ | ------------------ | ------------------------- |
|
|
11
|
-
| GET | `/files/id/:id` | Download file by ID |
|
|
12
|
-
| GET | `/files/:filename` | Download file by filename |
|
|
9
|
+
| Method | Endpoint | Description | Gated by |
|
|
10
|
+
| ------ | ------------------ | ------------------------- | --------------- |
|
|
11
|
+
| GET | `/files/id/:id` | Download file by ID | `downloadRoles` |
|
|
12
|
+
| GET | `/files/:filename` | Download file by filename | `downloadRoles` |
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
### GraphQL members (via CoreFileResolver)
|
|
15
|
+
|
|
16
|
+
| Member | Kind | Gated by |
|
|
17
|
+
| ------------- | -------- | --------------- |
|
|
18
|
+
| `getFileInfo` | Query | `downloadRoles` |
|
|
19
|
+
| `uploadFile` | Mutation | `uploadRoles` |
|
|
20
|
+
| `uploadFiles` | Mutation | `uploadRoles` |
|
|
21
|
+
| `deleteFile` | Mutation | `deleteRoles` |
|
|
22
|
+
|
|
23
|
+
> `CoreFileResolver` is registered by no core module. It only takes effect in a project that
|
|
24
|
+
> registers it (or a subclass) itself — enabling GraphQL alone does not.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
One `file` object configures the module — where the bytes live and who may reach them:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
file: {
|
|
34
|
+
storage: 'gridfs', // 'filesystem' | 'gridfs' | 's3' — omit to derive it
|
|
35
|
+
storageDir: 'uploads/files', // only for 'filesystem'
|
|
36
|
+
downloadRoles: [RoleEnum.ADMIN], // GET /files/id/:id, GET /files/:filename, getFileInfo
|
|
37
|
+
uploadRoles: [RoleEnum.ADMIN], // uploadFile, uploadFiles
|
|
38
|
+
deleteRoles: [RoleEnum.ADMIN], // deleteFile
|
|
39
|
+
},
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`storage` and the role knobs are orthogonal: one picks the driver, the others the access.
|
|
43
|
+
|
|
44
|
+
## Storage drivers
|
|
45
|
+
|
|
46
|
+
Three equivalent options. They differ only in where the bytes end up:
|
|
47
|
+
|
|
48
|
+
| Driver | Bytes | Survives a restart | Shared between replicas | Needs |
|
|
49
|
+
| -------------- | ------------------------- | ------------------------ | ----------------------- | ---------------------------------- |
|
|
50
|
+
| `'s3'` | S3-compatible bucket | yes | **yes** | `s3` config + `@aws-sdk/client-s3` |
|
|
51
|
+
| `'gridfs'` | MongoDB GridFS | yes | yes | nothing beyond the database |
|
|
52
|
+
| `'filesystem'` | local disk (`storageDir`) | only on a mounted volume | **no** | nothing |
|
|
53
|
+
|
|
54
|
+
**Metadata always lives in the database**, whichever driver holds the bytes. Filename, content type,
|
|
55
|
+
length and the custom `metadata` a per-file rule reads have to be queryable — `findFileInfo()` filters
|
|
56
|
+
and pages over them, `checkRights()` reads them per request. A directory listing answers none of
|
|
57
|
+
that, and sidecar files would reinvent an index the database already is. So `'filesystem'` moves the
|
|
58
|
+
bytes off the database, not the bookkeeping.
|
|
59
|
+
|
|
60
|
+
### Choosing the driver
|
|
61
|
+
|
|
62
|
+
**Set it explicitly and it is enforced.** If the chosen store is not available, the boot **fails**:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
file.storage is set to 's3', but that storage is not available.
|
|
66
|
+
Configure `s3` (bucket, credentials/endpoint) and install `@aws-sdk/client-s3`, …
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That is deliberate. The previous behaviour fell back to GridFS whenever S3 was selected but
|
|
70
|
+
unusable — the application kept working, so nothing looked broken, while files landed in a store the
|
|
71
|
+
operator did not believe they were in. Afterwards nobody can tell which file went where.
|
|
72
|
+
|
|
73
|
+
**Leave it unset and it is derived**, most capable first:
|
|
74
|
+
|
|
75
|
+
1. `'s3'` — when `s3.bucket` is configured
|
|
76
|
+
2. `'gridfs'` — when a database is configured
|
|
77
|
+
3. `'filesystem'` — when neither is
|
|
78
|
+
|
|
79
|
+
A configured-but-**unreachable** database is an error in its own right (Mongoose fails the boot), never
|
|
80
|
+
a reason to fall through to the disk. Only a database that is not configured **at all** reaches step 3 —
|
|
81
|
+
which today is theoretical, since `CoreModule` always registers Mongoose.
|
|
82
|
+
|
|
83
|
+
A derived driver is enforced too: `s3.bucket` in the config makes S3 the default, but the bytes still
|
|
84
|
+
go nowhere unless your `FileService` forwards the services to `super()`:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
super(connection, 'fs', { configService, s3Service });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Switching drivers is forward-only
|
|
91
|
+
|
|
92
|
+
Reads consult **every** store, so files written under a previous driver stay readable and there is no
|
|
93
|
+
migration step and no cut-over moment. New files go to the active driver; `findFileInfo()` returns
|
|
94
|
+
the union, paged once over the merged result.
|
|
95
|
+
|
|
96
|
+
The boot log names the driver in use, so it never has to be inferred from where files stopped
|
|
97
|
+
appearing:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
[CoreFileStorage] File storage: s3 (defaulted — s3.bucket is configured)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Access control
|
|
106
|
+
|
|
107
|
+
Two layers, and they answer different questions.
|
|
108
|
+
|
|
109
|
+
### 1. Roles — _may this caller reach the endpoint at all?_
|
|
110
|
+
|
|
111
|
+
All three role knobs take **plain role strings**, so your own project roles work exactly as they
|
|
112
|
+
would in a hand-written `@Roles()`.
|
|
113
|
+
|
|
114
|
+
All three default to `[ADMIN]`. That default is restrictive on purpose: the file store is a **single
|
|
115
|
+
bucket shared by every feature** of the project — GridFS or S3, whichever `file.storage` selects —
|
|
116
|
+
and the ids naming its blobs are not secrets. An id is an ObjectId (4-byte timestamp, 5-byte
|
|
117
|
+
per-process random, 3-byte counter), so one upload of your own discloses the per-process value in
|
|
118
|
+
full and bounds the counter, collapsing the id space to a brute-forceable range. `/files/:filename`
|
|
119
|
+
is weaker still: it resolves the **first** match for a name a caller may be able to guess.
|
|
120
|
+
|
|
121
|
+
Three properties worth knowing:
|
|
122
|
+
|
|
123
|
+
- **ADMIN always keeps access.** Handler and class roles are UNIONed (`mergeRolesMetadata`), and both
|
|
124
|
+
classes carry a class-level `@Roles(ADMIN)`. So `downloadRoles: ['editor']` grants editors _in
|
|
125
|
+
addition to_ admins. These knobs cannot exclude admins.
|
|
126
|
+
- **`[]` is rejected, not honoured.** An all-empty role set reads to the guards as "no roles
|
|
127
|
+
required" and would OPEN the route. An empty array logs a warning and falls back to the default.
|
|
128
|
+
- **Roles are checked against `user.roles`, never `membership.role`.** Both classes carry
|
|
129
|
+
`@SkipTenantCheck()`. No store is reached through Mongoose — GridFS goes through the native driver,
|
|
130
|
+
the S3 and filesystem metadata live in their own `s3-files` / `filesystem-files` collections — so
|
|
131
|
+
`mongooseTenantPlugin` never scopes them. None is tenant-scoped, and a role name alone therefore
|
|
132
|
+
cannot express a per-tenant rule. Use layer 2 for that.
|
|
133
|
+
|
|
134
|
+
### 2. `checkRights()` — _may this caller have THIS file?_
|
|
135
|
+
|
|
136
|
+
Roles cannot express "…but only their own". That is what the service hook is for:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
export class FileService extends CoreFileService {
|
|
140
|
+
protected override async checkRights(
|
|
141
|
+
input: any,
|
|
142
|
+
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
143
|
+
): Promise<boolean> {
|
|
144
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate
|
|
145
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const raw =
|
|
152
|
+
options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
|
|
153
|
+
// Fails closed without a user, and on a file that records no owner.
|
|
154
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
This is not a sketch: it is the rule `src/server/modules/file/file.service.ts` runs, with
|
|
160
|
+
`file: { downloadRoles: [RoleEnum.S_USER] }` in `src/config.env.ts` so the coarse gate actually
|
|
161
|
+
lets it fire. It used to live here and in that file as a **comment**, on the reasoning that the
|
|
162
|
+
`[ADMIN]` default made it unreachable in the reference server anyway — and a commented rule is never
|
|
163
|
+
compiled, never type-checked and never run. That is how a `deleteFileByName()` regression on the
|
|
164
|
+
`filename` branch shipped through a full green suite.
|
|
165
|
+
|
|
166
|
+
**Cover the `filename` branch too, not just `id`.** An id-only rule is enough while bytes are
|
|
167
|
+
streamed, because the filename route resolves an id and checks it again — but not once
|
|
168
|
+
`file.storage: 's3'` with presigned downloads is enabled, where the filename route authorizes on the
|
|
169
|
+
by-name lookup alone and then redirects, and not for `deleteFileByName()`, which authorizes by name
|
|
170
|
+
only.
|
|
171
|
+
|
|
172
|
+
**Never add `if (!options.currentUser) return true`.** It reads as "system-internal call, the guard
|
|
173
|
+
already decided" — but "no user in context" is also exactly what an **anonymous** request looks like.
|
|
174
|
+
While `downloadRoles` is narrower than `S_EVERYONE` the role gate turns those away first, so the
|
|
175
|
+
branch looks harmless; widen the gate, which this very section invites you to do, and it hands every
|
|
176
|
+
file to everyone. The ownership rule evaporates precisely when it starts to matter. The same reason
|
|
177
|
+
makes `!!raw?.metadata?.ownerId` load-bearing: without it, an owner-less file compares
|
|
178
|
+
`String(undefined)` against `String(undefined)` and matches.
|
|
179
|
+
|
|
180
|
+
Callers that really are internal should say so instead of relying on the omission — `{ force: true }`
|
|
181
|
+
where a role decorator already decided (an `@Roles(ADMIN)` admin endpoint), or the real
|
|
182
|
+
`{ currentUser }` where the user is in scope, so that call is **covered** by the ownership rule
|
|
183
|
+
rather than exempt from it. The reference server does both: `src/server/modules/file/` and
|
|
184
|
+
`src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
|
|
185
|
+
`id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
|
|
186
|
+
|
|
187
|
+
Three pieces make this work, and all three are needed:
|
|
188
|
+
|
|
189
|
+
1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
|
|
190
|
+
`CoreFileService.createFile()` passes `serviceOptions.metadata` straight to the storage driver,
|
|
191
|
+
so it works for both GridFS and S3.
|
|
192
|
+
2. **Read it back with `getRawFileInfo()`** (or `getRawFileInfoByName()`), not `getFileInfo()`. The
|
|
193
|
+
public getter runs `prepareOutput` → `check()`, which strips fields the caller may not see —
|
|
194
|
+
including the very field the decision rests on. `getRawFileInfo()` consults every metadata store
|
|
195
|
+
in the same order `getFileInfo()` uses, so a rule sees the file the download would actually
|
|
196
|
+
serve — under any driver.
|
|
197
|
+
3. **`options.currentUser` is supplied by the core controller and resolver** for every member.
|
|
198
|
+
|
|
199
|
+
When `checkRights()` refuses, `getFileStream()` returns `null` and the controller answers **404** —
|
|
200
|
+
deliberately the same answer as an unknown id, so the endpoint cannot be used to probe which files
|
|
201
|
+
exist. Do not turn that into a 403 in an override without accepting that trade-off.
|
|
202
|
+
|
|
203
|
+
### Serving files to a browser `<img>` / `<a download>`
|
|
204
|
+
|
|
205
|
+
A markup-driven request cannot send an `Authorization` header, and a CORS preflight sends no
|
|
206
|
+
credentials at all. So anything stricter than `S_EVERYONE` only works from markup when the session
|
|
207
|
+
travels as a **cookie** on a same-site request.
|
|
208
|
+
|
|
209
|
+
If some files are genuinely public and others are not, the answer is not a role list — it is a
|
|
210
|
+
per-file rule. Record a visibility flag in the metadata at upload time and branch on it in
|
|
211
|
+
`checkRights()`, or expose a separate public route for exactly the files that are meant to be
|
|
212
|
+
public and leave these two gated.
|
|
213
|
+
|
|
214
|
+
**Presigned S3 downloads** (`file.storage: 's3'` with presigned downloads enabled) sidestep the
|
|
215
|
+
`<img>` problem, because the browser fetches the object directly from S3. Understand what you are
|
|
216
|
+
issuing, though: the URL is a **bearer capability** — anyone holding it can fetch the object until
|
|
217
|
+
it expires, with no session. Authorization happens once, when the URL is issued (through the
|
|
218
|
+
`resolveFile()` call that precedes the redirect on `GET /files/id/:id`, and the
|
|
219
|
+
`getFileInfoByName()` call on `GET /files/:filename`; both run the same `checkRights()`). Keep the
|
|
220
|
+
expiry short, and do not enable it for files whose audience is narrower than "anyone who was ever
|
|
221
|
+
given the link".
|
|
222
|
+
|
|
223
|
+
### Overriding: read this before you re-declare a member
|
|
224
|
+
|
|
225
|
+
`CoreFileController` and `CoreFileResolver` are meant to be extended — but role metadata lives on
|
|
226
|
+
the **function object**, so an override carries its own and thereby **opts out of `file.*Roles`
|
|
227
|
+
entirely**. Two consequences that bite in practice:
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// WRONG — the route DISAPPEARS. Nest reads PATH_METADATA off the subclass
|
|
231
|
+
// function, which has none, so it is never registered: 404 for everyone,
|
|
232
|
+
// no error, no warning.
|
|
233
|
+
override async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
234
|
+
return super.getFileById(id, res);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// WRONG — pins the policy and silently ignores `file.downloadRoles` forever.
|
|
238
|
+
// This exact shape kept generated projects public after the framework default
|
|
239
|
+
// had already been closed.
|
|
240
|
+
@Get('id/:id')
|
|
241
|
+
@Roles(RoleEnum.S_EVERYONE)
|
|
242
|
+
override async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
243
|
+
return super.getFileById(id, res);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// RIGHT — don't override at all. Configure it:
|
|
247
|
+
// file: { downloadRoles: [RoleEnum.S_USER] }
|
|
248
|
+
// and put per-file rules in checkRights().
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
A class-level `@Roles()` on your subclass cannot relax an inherited member either: the inherited
|
|
252
|
+
function carries its own handler-level roles, and the two are unioned rather than overridden.
|
|
253
|
+
|
|
254
|
+
### If you override `getFileInfo()`: `GET /files/id/:id` no longer calls it (11.33.0)
|
|
255
|
+
|
|
256
|
+
Up to 11.32.x, `GET /files/id/:id` called the public `CoreFileService.getFileInfo()` and then let
|
|
257
|
+
`getFileStream()` work out on its own which store held the bytes. With three stores to consult
|
|
258
|
+
(§ Storage drivers) that resolves the same id up to three times per download, so since 11.33.0 the
|
|
259
|
+
route calls **`resolveFile()`** instead, which answers the metadata and the store in one pass.
|
|
260
|
+
|
|
261
|
+
An override of `getFileInfo()` is therefore **no longer on that route's path**. It is still honoured
|
|
262
|
+
by every other caller it ever had — `deleteFile()`, `duplicateById()` and any project code that
|
|
263
|
+
calls it, such as the `GET /files/info/:id` endpoint projects usually add — which is exactly what
|
|
264
|
+
makes the gap easy to miss: the behaviour disappears on one route while everything else keeps it.
|
|
265
|
+
(The GraphQL `getFileInfo(filename:)` member resolves by NAME and has always gone through
|
|
266
|
+
`getFileInfoByName()`, so it is unaffected either way.)
|
|
267
|
+
|
|
268
|
+
This does **not** weaken authorization. `resolveFile()` runs the same `checkRights()` with the same
|
|
269
|
+
`serviceOptions`, and answers `null` on refusal, which the controller turns into the same 404. Only
|
|
270
|
+
work you added **on top** of the base `getFileInfo()` — a decorated field, a counter, a log line —
|
|
271
|
+
stops happening on the id route.
|
|
272
|
+
|
|
273
|
+
The remedy is to override `resolveFile()` as well, keeping the two consistent:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
export class FileService extends CoreFileService {
|
|
277
|
+
override async getFileInfo(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions) {
|
|
278
|
+
return this.decorate(await super.getFileInfo(id, serviceOptions));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Same treatment for the download route. `store` must be passed through
|
|
282
|
+
// untouched — the controller hands it to getFileStream() to pick the store.
|
|
283
|
+
override async resolveFile(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions) {
|
|
284
|
+
const resolved = await super.resolveFile(id, serviceOptions);
|
|
285
|
+
return resolved && { ...resolved, info: this.decorate(resolved.info) };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Per-file **authorization** needs none of this: it belongs in `checkRights()`, which both methods
|
|
291
|
+
call.
|
|
15
292
|
|
|
16
293
|
### Admin Endpoints (project-specific)
|
|
17
294
|
|
|
@@ -46,39 +323,6 @@ export class FileController extends CoreFileController {
|
|
|
46
323
|
}
|
|
47
324
|
```
|
|
48
325
|
|
|
49
|
-
### Restrict Download Access
|
|
50
|
-
|
|
51
|
-
To require authentication for downloads, override the inherited methods:
|
|
52
|
-
|
|
53
|
-
```typescript
|
|
54
|
-
@Controller('files')
|
|
55
|
-
@Roles(RoleEnum.ADMIN)
|
|
56
|
-
export class FileController extends CoreFileController {
|
|
57
|
-
constructor(protected override readonly fileService: FileService) {
|
|
58
|
-
super(fileService);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Override to require authentication for ID-based download
|
|
62
|
-
@Get('id/:id')
|
|
63
|
-
@Roles(RoleEnum.S_USER) // Require logged-in user
|
|
64
|
-
override async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
65
|
-
return super.getFileById(id, res);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Override to require authentication for filename-based download
|
|
69
|
-
@Get(':filename')
|
|
70
|
-
@Roles(RoleEnum.S_USER)
|
|
71
|
-
override async getFile(@Param('filename') filename: string, @Res() res: Response) {
|
|
72
|
-
return super.getFile(filename, res);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Access can also be restricted per file by overriding `CoreFileService.checkRights()`. When it
|
|
78
|
-
refuses, `getFileStream()` returns `null` and the controller answers **404** — deliberately the same
|
|
79
|
-
answer as an unknown id, so the endpoint cannot be used to probe which files exist. Do not change
|
|
80
|
-
this to a 403 in an override without accepting that trade-off.
|
|
81
|
-
|
|
82
326
|
### Error responses
|
|
83
327
|
|
|
84
328
|
| Situation | Status | Body |
|
|
@@ -137,12 +381,13 @@ multerFileFilter({ extensions: ['.svg'], mimeTypes: ['image/svg+xml'] }, { allow
|
|
|
137
381
|
|
|
138
382
|
## GraphQL Support
|
|
139
383
|
|
|
140
|
-
File operations are also available via GraphQL through `CoreFileResolver
|
|
384
|
+
File operations are also available via GraphQL through `CoreFileResolver` — in a project that
|
|
385
|
+
registers it. See [Access control](#access-control) for which knob gates which member.
|
|
141
386
|
|
|
142
387
|
```graphql
|
|
143
|
-
#
|
|
388
|
+
# Read file info by filename (downloadRoles)
|
|
144
389
|
query {
|
|
145
|
-
|
|
390
|
+
getFileInfo(filename: "...") {
|
|
146
391
|
id
|
|
147
392
|
filename
|
|
148
393
|
contentType
|
|
@@ -150,24 +395,23 @@ query {
|
|
|
150
395
|
}
|
|
151
396
|
}
|
|
152
397
|
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
398
|
+
# Upload a file via the GraphQL Upload scalar (uploadRoles)
|
|
399
|
+
mutation {
|
|
400
|
+
uploadFile(file: Upload!) {
|
|
156
401
|
id
|
|
157
402
|
filename
|
|
158
|
-
contentType
|
|
159
403
|
}
|
|
160
404
|
}
|
|
161
405
|
|
|
162
|
-
# Upload
|
|
406
|
+
# Upload several files (uploadRoles)
|
|
163
407
|
mutation {
|
|
164
|
-
|
|
408
|
+
uploadFiles(files: [Upload!]!) {
|
|
165
409
|
id
|
|
166
410
|
filename
|
|
167
411
|
}
|
|
168
412
|
}
|
|
169
413
|
|
|
170
|
-
# Delete
|
|
414
|
+
# Delete by filename (deleteRoles)
|
|
171
415
|
mutation {
|
|
172
416
|
deleteFile(filename: "...") {
|
|
173
417
|
id
|
|
@@ -175,21 +419,32 @@ mutation {
|
|
|
175
419
|
}
|
|
176
420
|
```
|
|
177
421
|
|
|
422
|
+
> Earlier revisions of this file documented `file(id:)` and `fileByFilename(filename:)`. Neither has
|
|
423
|
+
> ever existed on `CoreFileResolver`; the query is `getFileInfo(filename:)`.
|
|
424
|
+
|
|
178
425
|
---
|
|
179
426
|
|
|
180
427
|
## Integration with TUS
|
|
181
428
|
|
|
182
|
-
Files uploaded via TUS
|
|
429
|
+
Files uploaded via TUS land in the same GridFS bucket and are read back through the same endpoints —
|
|
430
|
+
which means the same `downloadRoles` gate applies:
|
|
183
431
|
|
|
184
432
|
```bash
|
|
185
|
-
# After TUS upload completes, download by ID
|
|
433
|
+
# After the TUS upload completes, download by ID (requires downloadRoles)
|
|
186
434
|
GET /files/id/<gridfs-file-id>
|
|
187
435
|
|
|
188
|
-
# Or by filename
|
|
436
|
+
# Or by filename, if unique (requires downloadRoles)
|
|
189
437
|
GET /files/<original-filename>
|
|
190
438
|
```
|
|
191
439
|
|
|
192
|
-
**Recommendation:**
|
|
440
|
+
**Recommendation:** use ID-based downloads for TUS uploads, since filenames may not be unique.
|
|
441
|
+
|
|
442
|
+
**Watch the pairing.** TUS uploads are gated separately by `tus.roles` (default `S_USER`). The
|
|
443
|
+
common "user uploads their own file, then views it" flow therefore needs both sides to line up: a
|
|
444
|
+
signed-in user may upload, but with the default `downloadRoles: [ADMIN]` they cannot read the result
|
|
445
|
+
back. Either widen `downloadRoles`, or — better — write an owner into the metadata at upload time
|
|
446
|
+
and authorize per file in `checkRights()`. TUS uploads already carry `metadata.tusUploadId` and the
|
|
447
|
+
original TUS metadata, so there is a natural place to add one.
|
|
193
448
|
|
|
194
449
|
---
|
|
195
450
|
|
|
@@ -2,10 +2,14 @@ import { BadRequestException, Controller, Get, Logger, NotFoundException, Param,
|
|
|
2
2
|
import type { Response } from 'express';
|
|
3
3
|
import type { Readable } from 'stream';
|
|
4
4
|
|
|
5
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
5
6
|
import { Roles } from '../../common/decorators/roles.decorator';
|
|
6
7
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
8
|
+
import { buildContentDisposition } from '../../common/helpers/content-disposition.helper';
|
|
7
9
|
import { ErrorCode } from '../error-code/error-codes';
|
|
10
|
+
import { SkipTenantCheck } from '../tenant/core-tenant.decorators';
|
|
8
11
|
import { CoreFileService } from './core-file.service';
|
|
12
|
+
import type { FileServiceOptions } from './interfaces/file-service-options.interface';
|
|
9
13
|
|
|
10
14
|
const fileStreamLogger = new Logger('CoreFileController');
|
|
11
15
|
|
|
@@ -24,6 +28,16 @@ const fileStreamLogger = new Logger('CoreFileController');
|
|
|
24
28
|
*/
|
|
25
29
|
const FILE_DELIVERY_HEADERS = ['Cache-Control', 'Content-Disposition', 'Content-Type', 'ETag'];
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Re-exported from the leaf it now lives in, so no import path broke.
|
|
33
|
+
*
|
|
34
|
+
* It moved because the S3 presigned-URL branch in `src/core/common/services/` needs the SAME
|
|
35
|
+
* value, and `src/core/common/**` must not import from `src/core/modules/**`. While the two
|
|
36
|
+
* rendered it separately, the same file downloaded under a different name depending on whether
|
|
37
|
+
* `s3.presignedDownloads` was on.
|
|
38
|
+
*/
|
|
39
|
+
export { buildContentDisposition };
|
|
40
|
+
|
|
27
41
|
/**
|
|
28
42
|
* Pipe a GridFS download to the response without letting a read error kill the socket.
|
|
29
43
|
*
|
|
@@ -79,9 +93,24 @@ export function pipeFileToResponse(stream: Readable, res: Response): Response {
|
|
|
79
93
|
|
|
80
94
|
/**
|
|
81
95
|
* File controller
|
|
96
|
+
*
|
|
97
|
+
* TENANT SCOPING: the class carries `@SkipTenantCheck()`, so the roles below are
|
|
98
|
+
* checked against `user.roles` and never against `membership.role`.
|
|
99
|
+
*
|
|
100
|
+
* That is not a convenience — it is required for the gate to mean what it says.
|
|
101
|
+
* GridFS is reached through the NATIVE MongoDB driver, so `mongooseTenantPlugin`
|
|
102
|
+
* never runs on `fs.files`: one bucket holds every tenant's blobs, unscoped.
|
|
103
|
+
* Without this decorator and with `multiTenancy` active, a role string like
|
|
104
|
+
* `'admin'` would be satisfied by any member whose MEMBERSHIP role is `admin` —
|
|
105
|
+
* a workspace admin of tenant A could then read tenant B's files.
|
|
106
|
+
*
|
|
107
|
+
* A genuinely tenant-aware policy therefore cannot be expressed by a role name.
|
|
108
|
+
* Write `tenantId` into the file metadata at upload time and compare it in an
|
|
109
|
+
* overridden `CoreFileService.checkRights()`.
|
|
82
110
|
*/
|
|
83
111
|
@Controller('files')
|
|
84
112
|
@Roles(RoleEnum.ADMIN)
|
|
113
|
+
@SkipTenantCheck()
|
|
85
114
|
export abstract class CoreFileController {
|
|
86
115
|
/**
|
|
87
116
|
* Include services
|
|
@@ -100,31 +129,101 @@ export abstract class CoreFileController {
|
|
|
100
129
|
return pipeFileToResponse(stream, res);
|
|
101
130
|
}
|
|
102
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Presigned S3 URL for a download, or undefined to stream through the API.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately fail-soft. A presigned URL is an OPTIMIZATION — it offloads bytes
|
|
136
|
+
* from the API — so nothing about it may cost the caller their download: a
|
|
137
|
+
* missing `@aws-sdk/s3-request-presigner`, an S3 outage or a project service
|
|
138
|
+
* predating this method must all fall through to the streaming path, which
|
|
139
|
+
* still runs the same rights check and answers a refusal exactly like an
|
|
140
|
+
* unknown id. Turning any of those into a 500 would also leak that the file
|
|
141
|
+
* exists, which the streaming path takes care never to do.
|
|
142
|
+
*/
|
|
143
|
+
protected async resolveDownloadUrl(id: string, serviceOptions?: FileServiceOptions): Promise<string | undefined> {
|
|
144
|
+
try {
|
|
145
|
+
// `serviceOptions` carries the current user into the service's own rights
|
|
146
|
+
// check. Omitting it made this second check run with `currentUser:
|
|
147
|
+
// undefined`: for the ownership rule documented in the README that fails
|
|
148
|
+
// CLOSED, so presigned downloads silently never fired — not even for
|
|
149
|
+
// admins, since `currentUser?.hasRole(...)` was undefined too. For an
|
|
150
|
+
// override that reads a missing user as "system-internal, allow" it was a
|
|
151
|
+
// no-op instead. Neither is what the path that mints a session-less
|
|
152
|
+
// capability should be doing.
|
|
153
|
+
return await this.fileService.getDownloadUrl?.(id, serviceOptions);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
fileStreamLogger.warn(
|
|
156
|
+
`Presigned download URL unavailable, falling back to streaming: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
157
|
+
);
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
103
162
|
/**
|
|
104
163
|
* Download file by ID
|
|
105
164
|
*
|
|
106
165
|
* More reliable than filename-based download as IDs are unique.
|
|
107
166
|
* Recommended for TUS uploads and when filename uniqueness cannot be guaranteed.
|
|
167
|
+
*
|
|
168
|
+
* SECURITY: gated by `file.downloadRoles` (default `[ADMIN]`). The decorator
|
|
169
|
+
* below is the fallback — `CoreModule.forRoot()` rewrites it from config.
|
|
170
|
+
* See `src/core/modules/file/README.md` § Access control for the full model
|
|
171
|
+
* and the 11.32.4 → 11.33.0 migration guide for why the default changed.
|
|
108
172
|
*/
|
|
109
173
|
@Get('id/:id')
|
|
110
|
-
@Roles(RoleEnum.
|
|
111
|
-
async getFileById(
|
|
174
|
+
@Roles(RoleEnum.ADMIN)
|
|
175
|
+
async getFileById(
|
|
176
|
+
@Param('id') id: string,
|
|
177
|
+
@Res() res: Response,
|
|
178
|
+
@CurrentUser() currentUser?: any,
|
|
179
|
+
): Promise<Response> {
|
|
112
180
|
if (!id) {
|
|
113
181
|
throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
|
|
114
182
|
}
|
|
115
183
|
|
|
116
|
-
const
|
|
184
|
+
const serviceOptions = { currentUser };
|
|
185
|
+
// One lookup for both answers — metadata AND which store holds the bytes.
|
|
186
|
+
// getFileInfo() followed by getFileStream() had each probe the three stores
|
|
187
|
+
// from scratch, since neither told the other which one had answered. Same
|
|
188
|
+
// rights check, same 404-on-refusal.
|
|
189
|
+
const resolved = await this.fileService.resolveFile(id, serviceOptions);
|
|
190
|
+
const file = resolved?.info;
|
|
117
191
|
if (!file) {
|
|
118
192
|
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
119
193
|
}
|
|
120
|
-
|
|
194
|
+
// S3-stored file with presigned downloads enabled: let the client fetch the
|
|
195
|
+
// bytes from S3 directly instead of streaming them through the API.
|
|
196
|
+
//
|
|
197
|
+
// AUTHORIZATION on this branch rests entirely on the `resolveFile()` call
|
|
198
|
+
// above, which runs the very same `checkRights()` `getFileInfo()` did and
|
|
199
|
+
// answers null on refusal, so the throw above turns it into a 404 —
|
|
200
|
+
// `getFileStream()` is never reached here. Keep that call before this
|
|
201
|
+
// block. Note also that the issued URL is a bearer capability: anyone
|
|
202
|
+
// holding it can fetch the object until it expires, without a session. Keep
|
|
203
|
+
// the expiry short, and do not enable presigned downloads for files whose
|
|
204
|
+
// audience is narrower than "anyone who was once allowed to see the link".
|
|
205
|
+
// Only S3-stored bytes can be handed over as a presigned URL, and `resolveFile`
|
|
206
|
+
// already established the store — so a GridFS or filesystem download no longer
|
|
207
|
+
// pays a pointless S3 metadata lookup to find that out.
|
|
208
|
+
const url = resolved.store === 's3' ? await this.resolveDownloadUrl(id, serviceOptions) : undefined;
|
|
209
|
+
if (url) {
|
|
210
|
+
this.setNoStore(res);
|
|
211
|
+
// `res.redirect()` is typed `void`, so returning it directly widened this method's inferred
|
|
212
|
+
// return type to `Promise<void | Response>` — a source-invisible BREAKING change for every
|
|
213
|
+
// project that overrides `getFileById`/`getFile` with an explicit `Promise<Response>` and
|
|
214
|
+
// delegates to super. Nest ignores the returned value once `@Res()` is used, so returning
|
|
215
|
+
// `res` is equivalent and keeps the published contract intact. The explicit annotation on
|
|
216
|
+
// both methods pins it, so inference can never silently widen it again.
|
|
217
|
+
res.redirect(302, url);
|
|
218
|
+
return res;
|
|
219
|
+
}
|
|
220
|
+
const filestream = await this.fileService.getFileStream(id, serviceOptions, resolved.store);
|
|
121
221
|
// `getFileStream` answers null when the service's own rights check refuses.
|
|
122
222
|
// Same answer as an unknown id: never confirm that the file exists.
|
|
123
223
|
if (!filestream) {
|
|
124
224
|
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
125
225
|
}
|
|
126
|
-
|
|
127
|
-
res.header('Content-Disposition', `attachment; filename=${file.filename}`);
|
|
226
|
+
this.setFileHeaders(res, file);
|
|
128
227
|
return this.pipeFileToResponse(filestream, res);
|
|
129
228
|
}
|
|
130
229
|
|
|
@@ -133,24 +232,81 @@ export abstract class CoreFileController {
|
|
|
133
232
|
*
|
|
134
233
|
* Note: If multiple files have the same filename, only the first match is returned.
|
|
135
234
|
* For unique file access, use GET /files/id/:id instead.
|
|
235
|
+
*
|
|
236
|
+
* SECURITY: gated by `file.downloadRoles` — see `getFileById()`. Prefer the
|
|
237
|
+
* id route when widening: this one resolves the FIRST match for a name a
|
|
238
|
+
* caller may be able to guess, so it leaks across files that share a name.
|
|
136
239
|
*/
|
|
137
240
|
@Get(':filename')
|
|
138
|
-
@Roles(RoleEnum.
|
|
139
|
-
async getFile(
|
|
241
|
+
@Roles(RoleEnum.ADMIN)
|
|
242
|
+
async getFile(
|
|
243
|
+
@Param('filename') filename: string,
|
|
244
|
+
@Res() res: Response,
|
|
245
|
+
@CurrentUser() currentUser?: any,
|
|
246
|
+
): Promise<Response> {
|
|
140
247
|
if (!filename) {
|
|
141
248
|
throw new BadRequestException(ErrorCode.REQUIRED_FIELD_MISSING);
|
|
142
249
|
}
|
|
143
250
|
|
|
144
|
-
const
|
|
251
|
+
const serviceOptions = { currentUser };
|
|
252
|
+
const file = await this.fileService.getFileInfoByName(filename, serviceOptions);
|
|
145
253
|
if (!file) {
|
|
146
254
|
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
147
255
|
}
|
|
148
|
-
|
|
256
|
+
// See getFileById(): authorization on the presigned branch rests on the
|
|
257
|
+
// getFileInfoByName() call above.
|
|
258
|
+
const url = await this.resolveDownloadUrl(file.id, serviceOptions);
|
|
259
|
+
if (url) {
|
|
260
|
+
this.setNoStore(res);
|
|
261
|
+
res.redirect(302, url);
|
|
262
|
+
return res;
|
|
263
|
+
}
|
|
264
|
+
const filestream = await this.fileService.getFileStream(file.id, serviceOptions);
|
|
149
265
|
if (!filestream) {
|
|
150
266
|
throw new NotFoundException(ErrorCode.FILE_NOT_FOUND);
|
|
151
267
|
}
|
|
152
|
-
|
|
153
|
-
res.header('Content-Disposition', `attachment; filename=${file.filename}`);
|
|
268
|
+
this.setFileHeaders(res, file);
|
|
154
269
|
return this.pipeFileToResponse(filestream, res);
|
|
155
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Set the response headers that describe the file being delivered.
|
|
274
|
+
*
|
|
275
|
+
* `Cache-Control: private, no-store` is the security-relevant one. These
|
|
276
|
+
* routes are authorization-gated, and RFC 9111 lets a shared cache store a
|
|
277
|
+
* response that carries no cache directive. A reverse proxy or CDN with a
|
|
278
|
+
* blanket `/files/*` rule would then be free to hand an authorized response
|
|
279
|
+
* to the next, unauthorized requester — reopening at the proxy layer exactly
|
|
280
|
+
* what the role gate closes at the application layer. The directive costs one
|
|
281
|
+
* header and removes that entire class of misconfiguration.
|
|
282
|
+
*
|
|
283
|
+
* `no-store` also suppresses browser disk caching, which is the conservative
|
|
284
|
+
* choice for a bucket that may hold documents. A project serving public,
|
|
285
|
+
* immutable assets can override this to `public, max-age=…` — GridFS blobs
|
|
286
|
+
* are immutable once written, so a validator built from `_id` + `uploadDate`
|
|
287
|
+
* is sound. Do that only for files that are genuinely public.
|
|
288
|
+
*/
|
|
289
|
+
protected setFileHeaders(res: Response, file: { contentType?: string; filename?: string }): void {
|
|
290
|
+
this.setNoStore(res);
|
|
291
|
+
res.header('Content-Type', file.contentType || 'application/octet-stream');
|
|
292
|
+
// See {@link buildContentDisposition} for why the two filename parameters are
|
|
293
|
+
// rendered differently, and for the injection cases the sanitiser closes.
|
|
294
|
+
res.header('Content-Disposition', buildContentDisposition(file.filename));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Mark a download response as uncacheable by any shared cache.
|
|
299
|
+
*
|
|
300
|
+
* Applies to BOTH delivery paths. On the streaming path it stops a reverse
|
|
301
|
+
* proxy or CDN with a blanket `/files/*` rule from handing an authorized
|
|
302
|
+
* response to the next, unauthorized requester. On the presigned path it
|
|
303
|
+
* matters more, not less: that response's `Location` is a bearer capability
|
|
304
|
+
* that works with no session, from any IP, until it expires — and while a 302
|
|
305
|
+
* is not heuristically cacheable per RFC 9111, `proxy_cache_valid 200 302 …`
|
|
306
|
+
* is the single most-copied nginx caching snippet, and Cloudflare's "Cache
|
|
307
|
+
* Everything" stores it too.
|
|
308
|
+
*/
|
|
309
|
+
protected setNoStore(res: Response): void {
|
|
310
|
+
res.header('Cache-Control', 'private, no-store');
|
|
311
|
+
}
|
|
156
312
|
}
|