@lenne.tech/nest-server 11.32.4 → 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.
Files changed (260) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  7. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  8. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  9. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  10. package/dist/core/common/helpers/file.helper.js +21 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  13. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  14. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  15. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  16. package/dist/core/common/helpers/project-name.helper.js +38 -0
  17. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  18. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  19. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  20. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  21. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  23. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  24. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  26. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  27. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  28. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  29. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  30. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  31. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  32. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  33. package/dist/core/common/services/core-redis.service.js +154 -0
  34. package/dist/core/common/services/core-redis.service.js.map +1 -0
  35. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  36. package/dist/core/common/services/core-s3.service.js +261 -0
  37. package/dist/core/common/services/core-s3.service.js.map +1 -0
  38. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  39. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  41. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  42. package/dist/core/common/services/rate-limit-store.js +235 -0
  43. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  44. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  45. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  46. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  47. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  48. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  49. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  50. package/dist/core/modules/auth/core-auth.module.js +4 -1
  51. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  52. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  53. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  55. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  56. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  58. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  59. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  61. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  62. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  67. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  68. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  69. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  70. package/dist/core/modules/file/core-file.controller.js +52 -15
  71. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  72. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  73. package/dist/core/modules/file/core-file.resolver.js +23 -16
  74. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  75. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  76. package/dist/core/modules/file/core-file.service.js +227 -7
  77. package/dist/core/modules/file/core-file.service.js.map +1 -1
  78. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  79. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  80. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  81. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  82. package/dist/core/modules/file/file-roles.helper.js +53 -0
  83. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  84. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  85. package/dist/core/modules/file/file-storage.helper.js +65 -0
  86. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  87. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  88. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  89. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  90. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  91. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  92. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  93. package/dist/core/modules/file/s3-file.helper.js +81 -0
  94. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  95. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  96. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  97. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  98. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  99. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  100. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  101. package/dist/core/modules/hub/hub-buffer.js +101 -0
  102. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  103. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  104. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  106. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  109. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  112. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  115. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  116. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  117. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  118. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  119. package/dist/core/modules/migrate/migration-runner.js +4 -0
  120. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  121. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  122. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  123. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  124. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  125. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  127. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  128. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  129. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  130. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  131. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  132. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  133. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  134. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  135. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  136. package/dist/core/modules/tus/core-tus.service.js +182 -28
  137. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  138. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  139. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  140. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  141. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  142. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  143. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  144. package/dist/core/modules/tus/tus.module.js +30 -4
  145. package/dist/core/modules/tus/tus.module.js.map +1 -1
  146. package/dist/core.module.js +18 -1
  147. package/dist/core.module.js.map +1 -1
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.js +10 -0
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.js +2 -1
  152. package/dist/main.js.map +1 -1
  153. package/dist/server/modules/file/file.controller.js.map +1 -1
  154. package/dist/server/modules/file/file.resolver.js +1 -12
  155. package/dist/server/modules/file/file.resolver.js.map +1 -1
  156. package/dist/server/modules/file/file.service.d.ts +5 -1
  157. package/dist/server/modules/file/file.service.js +12 -3
  158. package/dist/server/modules/file/file.service.js.map +1 -1
  159. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  160. package/dist/server/modules/user/avatar.controller.js +26 -10
  161. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  162. package/dist/server/modules/user/user.model.js +3 -2
  163. package/dist/server/modules/user/user.model.js.map +1 -1
  164. package/dist/server/modules/user/user.module.js +6 -2
  165. package/dist/server/modules/user/user.module.js.map +1 -1
  166. package/dist/server/modules/user/user.service.d.ts +1 -1
  167. package/dist/server/modules/user/user.service.js +6 -13
  168. package/dist/server/modules/user/user.service.js.map +1 -1
  169. package/dist/tsconfig.build.tsbuildinfo +1 -1
  170. package/docs/REQUEST-LIFECYCLE.md +57 -8
  171. package/docs/security-overrides.md +21 -3
  172. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  173. package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
  174. package/package.json +74 -42
  175. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  176. package/src/core/common/helpers/file.helper.ts +53 -10
  177. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  178. package/src/core/common/helpers/project-name.helper.ts +71 -0
  179. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  180. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  181. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  182. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  183. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  184. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  185. package/src/core/common/services/core-redis.service.ts +249 -0
  186. package/src/core/common/services/core-s3.service.ts +465 -0
  187. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  188. package/src/core/common/services/rate-limit-store.ts +492 -0
  189. package/src/core/modules/ai/README.md +25 -0
  190. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  191. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  192. package/src/core/modules/auth/core-auth.module.ts +7 -1
  193. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  194. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  195. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  196. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  197. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  198. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  199. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
  200. package/src/core/modules/file/README.md +277 -53
  201. package/src/core/modules/file/core-file.controller.ts +168 -12
  202. package/src/core/modules/file/core-file.resolver.ts +46 -12
  203. package/src/core/modules/file/core-file.service.ts +482 -16
  204. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  205. package/src/core/modules/file/file-roles.helper.ts +110 -0
  206. package/src/core/modules/file/file-storage.helper.ts +163 -0
  207. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  208. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  209. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  210. package/src/core/modules/file/s3-file.helper.ts +209 -0
  211. package/src/core/modules/hub/README.md +48 -0
  212. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  213. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  214. package/src/core/modules/hub/hub-buffer.ts +198 -0
  215. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  216. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  217. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  218. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  219. package/src/core/modules/migrate/README.md +33 -6
  220. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  221. package/src/core/modules/migrate/migration-runner.ts +10 -1
  222. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  223. package/src/core/modules/system-setup/README.md +7 -2
  224. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  225. package/src/core/modules/tenant/README.md +21 -1
  226. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  227. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  228. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  229. package/src/core/modules/tus/README.md +35 -2
  230. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  231. package/src/core/modules/tus/core-tus.service.ts +358 -34
  232. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  233. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  234. package/src/core/modules/tus/tus.module.ts +78 -6
  235. package/src/core.module.ts +26 -1
  236. package/src/index.ts +10 -0
  237. package/src/main.ts +7 -1
  238. package/src/server/modules/file/file.controller.ts +7 -3
  239. package/src/server/modules/file/file.resolver.ts +5 -17
  240. package/src/server/modules/file/file.service.ts +42 -3
  241. package/src/server/modules/user/avatar.controller.ts +39 -9
  242. package/src/server/modules/user/user.model.ts +9 -3
  243. package/src/server/modules/user/user.module.ts +12 -3
  244. package/src/server/modules/user/user.service.ts +20 -17
  245. package/src/test/README.md +7 -2
  246. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  247. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  248. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  249. package/src/core/common/services/brevo.service.spec.ts +0 -266
  250. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  251. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  252. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  253. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  254. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  255. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  256. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  257. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  258. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  259. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  260. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -4,14 +4,260 @@ File upload and download functionality with MongoDB GridFS storage.
4
4
 
5
5
  ## Endpoints
6
6
 
7
- ### Public Endpoints (via CoreFileController)
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
- **Note:** These endpoints are public (`S_EVERYONE`) by default. Projects can restrict access by extending `CoreFileController`.
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
+ if (options?.checkInputType !== 'id' || options.force) {
145
+ return true;
146
+ }
147
+ if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
148
+ return true;
149
+ }
150
+ const raw = await this.getRawFileInfo(input);
151
+ return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
152
+ }
153
+ }
154
+ ```
155
+
156
+ Three pieces make this work, and all three are needed:
157
+
158
+ 1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
159
+ `CoreFileService.createFile()` passes `serviceOptions.metadata` straight to the storage driver,
160
+ so it works for both GridFS and S3.
161
+ 2. **Read it back with `getRawFileInfo()`** (or `getRawFileInfoByName()`), not `getFileInfo()`. The
162
+ public getter runs `prepareOutput` → `check()`, which strips fields the caller may not see —
163
+ including the very field the decision rests on. `getRawFileInfo()` consults every metadata store
164
+ in the same order `getFileInfo()` uses, so a rule sees the file the download would actually
165
+ serve — under any driver.
166
+ 3. **`options.currentUser` is supplied by the core controller and resolver** for every member.
167
+
168
+ When `checkRights()` refuses, `getFileStream()` returns `null` and the controller answers **404** —
169
+ deliberately the same answer as an unknown id, so the endpoint cannot be used to probe which files
170
+ exist. Do not turn that into a 403 in an override without accepting that trade-off.
171
+
172
+ ### Serving files to a browser `<img>` / `<a download>`
173
+
174
+ A markup-driven request cannot send an `Authorization` header, and a CORS preflight sends no
175
+ credentials at all. So anything stricter than `S_EVERYONE` only works from markup when the session
176
+ travels as a **cookie** on a same-site request.
177
+
178
+ If some files are genuinely public and others are not, the answer is not a role list — it is a
179
+ per-file rule. Record a visibility flag in the metadata at upload time and branch on it in
180
+ `checkRights()`, or expose a separate public route for exactly the files that are meant to be
181
+ public and leave these two gated.
182
+
183
+ **Presigned S3 downloads** (`file.storage: 's3'` with presigned downloads enabled) sidestep the
184
+ `<img>` problem, because the browser fetches the object directly from S3. Understand what you are
185
+ issuing, though: the URL is a **bearer capability** — anyone holding it can fetch the object until
186
+ it expires, with no session. Authorization happens once, when the URL is issued (through the
187
+ `resolveFile()` call that precedes the redirect on `GET /files/id/:id`, and the
188
+ `getFileInfoByName()` call on `GET /files/:filename`; both run the same `checkRights()`). Keep the
189
+ expiry short, and do not enable it for files whose audience is narrower than "anyone who was ever
190
+ given the link".
191
+
192
+ ### Overriding: read this before you re-declare a member
193
+
194
+ `CoreFileController` and `CoreFileResolver` are meant to be extended — but role metadata lives on
195
+ the **function object**, so an override carries its own and thereby **opts out of `file.*Roles`
196
+ entirely**. Two consequences that bite in practice:
197
+
198
+ ```typescript
199
+ // WRONG — the route DISAPPEARS. Nest reads PATH_METADATA off the subclass
200
+ // function, which has none, so it is never registered: 404 for everyone,
201
+ // no error, no warning.
202
+ override async getFileById(@Param('id') id: string, @Res() res: Response) {
203
+ return super.getFileById(id, res);
204
+ }
205
+
206
+ // WRONG — pins the policy and silently ignores `file.downloadRoles` forever.
207
+ // This exact shape kept generated projects public after the framework default
208
+ // had already been closed.
209
+ @Get('id/:id')
210
+ @Roles(RoleEnum.S_EVERYONE)
211
+ override async getFileById(@Param('id') id: string, @Res() res: Response) {
212
+ return super.getFileById(id, res);
213
+ }
214
+
215
+ // RIGHT — don't override at all. Configure it:
216
+ // file: { downloadRoles: [RoleEnum.S_USER] }
217
+ // and put per-file rules in checkRights().
218
+ ```
219
+
220
+ A class-level `@Roles()` on your subclass cannot relax an inherited member either: the inherited
221
+ function carries its own handler-level roles, and the two are unioned rather than overridden.
222
+
223
+ ### If you override `getFileInfo()`: `GET /files/id/:id` no longer calls it (11.33.0)
224
+
225
+ Up to 11.32.x, `GET /files/id/:id` called the public `CoreFileService.getFileInfo()` and then let
226
+ `getFileStream()` work out on its own which store held the bytes. With three stores to consult
227
+ (§ Storage drivers) that resolves the same id up to three times per download, so since 11.33.0 the
228
+ route calls **`resolveFile()`** instead, which answers the metadata and the store in one pass.
229
+
230
+ An override of `getFileInfo()` is therefore **no longer on that route's path**. It is still honoured
231
+ by every other caller it ever had — `deleteFile()`, `duplicateById()` and any project code that
232
+ calls it, such as the `GET /files/info/:id` endpoint projects usually add — which is exactly what
233
+ makes the gap easy to miss: the behaviour disappears on one route while everything else keeps it.
234
+ (The GraphQL `getFileInfo(filename:)` member resolves by NAME and has always gone through
235
+ `getFileInfoByName()`, so it is unaffected either way.)
236
+
237
+ This does **not** weaken authorization. `resolveFile()` runs the same `checkRights()` with the same
238
+ `serviceOptions`, and answers `null` on refusal, which the controller turns into the same 404. Only
239
+ work you added **on top** of the base `getFileInfo()` — a decorated field, a counter, a log line —
240
+ stops happening on the id route.
241
+
242
+ The remedy is to override `resolveFile()` as well, keeping the two consistent:
243
+
244
+ ```typescript
245
+ export class FileService extends CoreFileService {
246
+ override async getFileInfo(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions) {
247
+ return this.decorate(await super.getFileInfo(id, serviceOptions));
248
+ }
249
+
250
+ // Same treatment for the download route. `store` must be passed through
251
+ // untouched — the controller hands it to getFileStream() to pick the store.
252
+ override async resolveFile(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions) {
253
+ const resolved = await super.resolveFile(id, serviceOptions);
254
+ return resolved && { ...resolved, info: this.decorate(resolved.info) };
255
+ }
256
+ }
257
+ ```
258
+
259
+ Per-file **authorization** needs none of this: it belongs in `checkRights()`, which both methods
260
+ call.
15
261
 
16
262
  ### Admin Endpoints (project-specific)
17
263
 
@@ -46,39 +292,6 @@ export class FileController extends CoreFileController {
46
292
  }
47
293
  ```
48
294
 
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
295
  ### Error responses
83
296
 
84
297
  | Situation | Status | Body |
@@ -137,12 +350,13 @@ multerFileFilter({ extensions: ['.svg'], mimeTypes: ['image/svg+xml'] }, { allow
137
350
 
138
351
  ## GraphQL Support
139
352
 
140
- File operations are also available via GraphQL through `CoreFileResolver`:
353
+ File operations are also available via GraphQL through `CoreFileResolver` — in a project that
354
+ registers it. See [Access control](#access-control) for which knob gates which member.
141
355
 
142
356
  ```graphql
143
- # Query file by ID
357
+ # Read file info by filename (downloadRoles)
144
358
  query {
145
- file(id: "...") {
359
+ getFileInfo(filename: "...") {
146
360
  id
147
361
  filename
148
362
  contentType
@@ -150,24 +364,23 @@ query {
150
364
  }
151
365
  }
152
366
 
153
- # Query file by filename
154
- query {
155
- fileByFilename(filename: "...") {
367
+ # Upload a file via the GraphQL Upload scalar (uploadRoles)
368
+ mutation {
369
+ uploadFile(file: Upload!) {
156
370
  id
157
371
  filename
158
- contentType
159
372
  }
160
373
  }
161
374
 
162
- # Upload file (via GraphQL Upload scalar)
375
+ # Upload several files (uploadRoles)
163
376
  mutation {
164
- uploadFile(file: Upload!) {
377
+ uploadFiles(files: [Upload!]!) {
165
378
  id
166
379
  filename
167
380
  }
168
381
  }
169
382
 
170
- # Delete file
383
+ # Delete by filename (deleteRoles)
171
384
  mutation {
172
385
  deleteFile(filename: "...") {
173
386
  id
@@ -175,21 +388,32 @@ mutation {
175
388
  }
176
389
  ```
177
390
 
391
+ > Earlier revisions of this file documented `file(id:)` and `fileByFilename(filename:)`. Neither has
392
+ > ever existed on `CoreFileResolver`; the query is `getFileInfo(filename:)`.
393
+
178
394
  ---
179
395
 
180
396
  ## Integration with TUS
181
397
 
182
- Files uploaded via TUS are automatically stored in GridFS and can be accessed through the same endpoints:
398
+ Files uploaded via TUS land in the same GridFS bucket and are read back through the same endpoints
399
+ which means the same `downloadRoles` gate applies:
183
400
 
184
401
  ```bash
185
- # After TUS upload completes, download by ID
402
+ # After the TUS upload completes, download by ID (requires downloadRoles)
186
403
  GET /files/id/<gridfs-file-id>
187
404
 
188
- # Or by filename (if unique)
405
+ # Or by filename, if unique (requires downloadRoles)
189
406
  GET /files/<original-filename>
190
407
  ```
191
408
 
192
- **Recommendation:** Use ID-based downloads for TUS uploads as filenames may not be unique.
409
+ **Recommendation:** use ID-based downloads for TUS uploads, since filenames may not be unique.
410
+
411
+ **Watch the pairing.** TUS uploads are gated separately by `tus.roles` (default `S_USER`). The
412
+ common "user uploads their own file, then views it" flow therefore needs both sides to line up: a
413
+ signed-in user may upload, but with the default `downloadRoles: [ADMIN]` they cannot read the result
414
+ back. Either widen `downloadRoles`, or — better — write an owner into the metadata at upload time
415
+ and authorize per file in `checkRights()`. TUS uploads already carry `metadata.tusUploadId` and the
416
+ original TUS metadata, so there is a natural place to add one.
193
417
 
194
418
  ---
195
419
 
@@ -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.S_EVERYONE)
111
- async getFileById(@Param('id') id: string, @Res() res: Response) {
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 file = await this.fileService.getFileInfo(id);
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
- const filestream = await this.fileService.getFileStream(id);
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
- res.header('Content-Type', file.contentType || 'application/octet-stream');
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.S_EVERYONE)
139
- async getFile(@Param('filename') filename: string, @Res() res: Response) {
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 file = await this.fileService.getFileInfoByName(filename);
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
- const filestream = await this.fileService.getFileStream(file.id);
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
- res.header('Content-Type', file.contentType || 'application/octet-stream');
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
  }