@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
@@ -0,0 +1,465 @@
1
+ import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
2
+
3
+ import { buildContentDisposition } from '../helpers/content-disposition.helper';
4
+ import { ConfigService } from './config.service';
5
+
6
+ import type { IS3Config } from '../interfaces/server-options.interface';
7
+ import type { S3Client } from '@aws-sdk/client-s3';
8
+ import type { Readable } from 'stream';
9
+
10
+ /**
11
+ * Hard cap for a presigned download URL, in seconds.
12
+ *
13
+ * This is the ceiling AWS SigV4 itself enforces (7 days) — a longer `expiresIn` produces a
14
+ * signature S3 rejects outright, so a value above it can never work, only fail later.
15
+ */
16
+ const MAX_PRESIGNED_EXPIRY_SECONDS = 604_800;
17
+
18
+ /**
19
+ * Above this, the TTL is very likely longer than intended.
20
+ *
21
+ * A presigned URL is a SESSION-LESS bearer capability: it is authorized once, at issue time, and
22
+ * from then on anyone holding the string can download the object — no token, no session, no role
23
+ * check, and no way to revoke it short of deleting the object or rotating the credentials that
24
+ * signed it. Such links end up pasted into chats, tickets and proxy logs, so the window in which a
25
+ * leak is exploitable is exactly this number. The cap above only catches the impossible value; the
26
+ * dangerous band lies well below it, which is why it gets its own warning rather than silence.
27
+ */
28
+ const PRESIGNED_EXPIRY_ADVISORY_SECONDS = 900;
29
+
30
+ /**
31
+ * Fully normalized S3 configuration with defaults applied
32
+ */
33
+ export type NormalizedS3Config = Omit<IS3Config, 'enabled' | 'presignedDownloads'> & {
34
+ bucket: string;
35
+ forcePathStyle: boolean;
36
+ /** undefined = presigned downloads disabled */
37
+ presignedDownloads?: { expiresInSeconds: number };
38
+ region: string;
39
+ stagingBucket: string;
40
+ };
41
+
42
+ /**
43
+ * Central S3-compatible object storage service (see IServerOptions.s3).
44
+ *
45
+ * Works with AWS S3, MinIO, RustFS and other S3-compatible services. Used by
46
+ * CoreFileService (file.storage: 's3') and as TUS upload staging. Follows the
47
+ * "presence implies enabled" pattern: without an `s3` config this service is
48
+ * inert and files stay in GridFS / on local disk as before.
49
+ *
50
+ * The `@aws-sdk/client-s3` package is an OPTIONAL peer dependency and is
51
+ * lazy-imported at bootstrap; `@aws-sdk/s3-request-presigner` is only needed
52
+ * when `presignedDownloads` is enabled.
53
+ */
54
+ @Injectable()
55
+ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
56
+ protected readonly logger = new Logger(CoreS3Service.name);
57
+
58
+ /** Normalized config; undefined when S3 is not enabled */
59
+ protected config?: NormalizedS3Config;
60
+
61
+ /** Shared S3 client, created at init */
62
+ protected client?: S3Client;
63
+
64
+ /** Lazy-imported @aws-sdk/client-s3 module (command classes) */
65
+ protected sdk?: typeof import('@aws-sdk/client-s3');
66
+
67
+ /**
68
+ * Lazy-imported @aws-sdk/s3-request-presigner, cached after the first presigned URL.
69
+ *
70
+ * Node's module cache already makes a repeated `import()` cheap, but it is still a promise
71
+ * allocation and a resolver hop on a per-download path — and it obscures that the module is
72
+ * loaded once, not per request.
73
+ */
74
+ protected presigner?: typeof import('@aws-sdk/s3-request-presigner');
75
+
76
+ constructor(protected readonly configService: ConfigService) {
77
+ const raw = this.configService.getFastButReadOnly<IS3Config | undefined>('s3');
78
+ if (!raw || raw.enabled === false) {
79
+ return;
80
+ }
81
+
82
+ // A bucket is the one thing S3 cannot default — region, endpoint and
83
+ // credentials all have fallbacks, "which bucket" does not. Without it the
84
+ // service can neither store nor stage anything, so it must not report itself
85
+ // enabled: `tus.s3Staging` defaults to true and keys off exactly this flag,
86
+ // so a bucket-less `s3` block used to switch tus staging on and then fail at
87
+ // the first upload with `stagingBucket: undefined`. It also kept this flag
88
+ // out of step with `resolveFileStorage()`, which has always required a
89
+ // bucket — the file module chose GridFS while tus chose S3.
90
+ if (!raw.bucket) {
91
+ this.logger.warn('Ignoring the `s3` configuration: no `bucket` is set, so S3 cannot be used.');
92
+ return;
93
+ }
94
+ const presigned = raw.presignedDownloads;
95
+ this.config = {
96
+ accessKeyId: raw.accessKeyId,
97
+ autoCreateBucket: raw.autoCreateBucket ?? false,
98
+ bucket: raw.bucket,
99
+ endpoint: raw.endpoint,
100
+ forcePathStyle: raw.forcePathStyle ?? false,
101
+ presignedDownloads: presigned
102
+ ? {
103
+ expiresInSeconds: this.clampPresignedExpiry(
104
+ (typeof presigned === 'object' ? presigned.expiresInSeconds : undefined) ?? 300,
105
+ ),
106
+ }
107
+ : undefined,
108
+ region: raw.region ?? 'us-east-1',
109
+ secretAccessKey: raw.secretAccessKey,
110
+ stagingBucket: raw.stagingBucket ?? raw.bucket,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Bring `s3.presignedDownloads.expiresInSeconds` into a defensible range, once at boot.
116
+ *
117
+ * Clamped here rather than per request so the effective value is what `getConfig()` reports and
118
+ * the warning is emitted once instead of on every download.
119
+ */
120
+ protected clampPresignedExpiry(seconds: number): number {
121
+ if (!Number.isFinite(seconds) || seconds <= 0) {
122
+ this.logger.warn(
123
+ `s3.presignedDownloads.expiresInSeconds is "${seconds}", which is not a positive number — using 300s.`,
124
+ );
125
+ return 300;
126
+ }
127
+ if (seconds > MAX_PRESIGNED_EXPIRY_SECONDS) {
128
+ this.logger.warn(
129
+ `s3.presignedDownloads.expiresInSeconds is ${seconds}s, above the ${MAX_PRESIGNED_EXPIRY_SECONDS}s AWS SigV4 ` +
130
+ 'maximum — capping it, since a longer signature is rejected by S3 rather than honored.',
131
+ );
132
+ return MAX_PRESIGNED_EXPIRY_SECONDS;
133
+ }
134
+ if (seconds > PRESIGNED_EXPIRY_ADVISORY_SECONDS) {
135
+ this.logger.warn(
136
+ `s3.presignedDownloads.expiresInSeconds is ${seconds}s. A presigned URL is an unrevocable bearer capability ` +
137
+ 'authorized once at issue time — keep it just long enough for the download to start (a few minutes), not ' +
138
+ 'long enough for the link to be forwarded, logged and reused.',
139
+ );
140
+ }
141
+ return seconds;
142
+ }
143
+
144
+ /**
145
+ * Whether S3 is configured and enabled
146
+ */
147
+ get enabled(): boolean {
148
+ return !!this.config;
149
+ }
150
+
151
+ /**
152
+ * Normalized S3 configuration, or undefined when disabled
153
+ */
154
+ getConfig(): NormalizedS3Config | undefined {
155
+ return this.config;
156
+ }
157
+
158
+ /**
159
+ * Lazy-import the AWS SDK and create the shared client.
160
+ * No-op when S3 is not enabled.
161
+ */
162
+ async onModuleInit(): Promise<void> {
163
+ if (!this.config) {
164
+ return;
165
+ }
166
+ try {
167
+ this.sdk = await import('@aws-sdk/client-s3');
168
+ } catch {
169
+ throw new Error(
170
+ 'S3 is configured (ServerOptions.s3) but the optional peer dependency "@aws-sdk/client-s3" is not installed. ' +
171
+ 'Run: pnpm add @aws-sdk/client-s3',
172
+ );
173
+ }
174
+ const { accessKeyId, endpoint, forcePathStyle, region, secretAccessKey } = this.config;
175
+ this.client = new this.sdk.S3Client({
176
+ ...(endpoint ? { endpoint } : {}),
177
+ forcePathStyle,
178
+ region,
179
+ // Fall back to the SDK default credential chain when no keys are configured
180
+ ...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
181
+ });
182
+
183
+ await this.verifyBuckets();
184
+ }
185
+
186
+ /**
187
+ * Check at boot that the configured buckets are reachable.
188
+ *
189
+ * Without this the first upload is the first time anyone finds out, and it surfaces as a bare
190
+ * `500 Internal Server Error` with `NoSuchBucket` buried in the server log — a configuration
191
+ * mistake reported as a server fault, at the worst possible moment.
192
+ *
193
+ * It logs rather than throws: S3 may be provisioned moments after the app, and an object store
194
+ * that is briefly unreachable should not stop a server whose other routes work fine.
195
+ * `autoCreateBucket` creates what is missing instead, which is what a self-hosted MinIO/RustFS
196
+ * or a local dev stack usually wants — production buckets normally come from infrastructure code
197
+ * and their credentials often carry no `CreateBucket` permission, hence the default of `false`.
198
+ */
199
+ protected async verifyBuckets(): Promise<void> {
200
+ const buckets = [...new Set([this.config.bucket, this.config.stagingBucket])];
201
+ for (const bucket of buckets) {
202
+ try {
203
+ if (await this.bucketExists(bucket)) {
204
+ continue;
205
+ }
206
+ if (this.config.autoCreateBucket) {
207
+ await this.ensureBucket(bucket);
208
+ this.logger.log(`Created S3 bucket "${bucket}" (s3.autoCreateBucket is on)`);
209
+ continue;
210
+ }
211
+ this.logger.error(
212
+ `S3 bucket "${bucket}" does not exist. Every upload will fail until it is created. ` +
213
+ 'Create it in your object storage, or set s3.autoCreateBucket: true to have the server create it.',
214
+ );
215
+ } catch (error) {
216
+ this.logger.error(
217
+ `Could not verify S3 bucket "${bucket}": ${error instanceof Error ? error.message : 'Unknown error'}`,
218
+ );
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Whether a bucket exists, without treating "missing" as an error
225
+ */
226
+ protected async bucketExists(bucket: string): Promise<boolean> {
227
+ const { client, sdk } = this.requireInit();
228
+ try {
229
+ await client.send(new sdk.HeadBucketCommand({ Bucket: bucket }));
230
+ return true;
231
+ } catch (error: any) {
232
+ const status = error?.$metadata?.httpStatusCode;
233
+ if (status === 404 || error?.name === 'NotFound' || error?.name === 'NoSuchBucket') {
234
+ return false;
235
+ }
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Shared S3 client.
242
+ * Throws when S3 is not enabled or the module has not been initialized yet.
243
+ */
244
+ getClient(): S3Client {
245
+ if (!this.config) {
246
+ throw new Error('S3 is not configured/enabled (ServerOptions.s3)');
247
+ }
248
+ if (!this.client || !this.sdk) {
249
+ throw new Error('CoreS3Service is not initialized yet (onModuleInit pending)');
250
+ }
251
+ return this.client;
252
+ }
253
+
254
+ /**
255
+ * Upload an object to the main bucket.
256
+ *
257
+ * A stream needs its length: without `Content-Length` the SDK switches to aws-chunked
258
+ * encoding and then fails on a missing `x-amz-decoded-content-length`, so a stream of
259
+ * unknown size is read into memory first. Pass `contentLength` whenever the size is
260
+ * known — that streams the body straight through instead of buffering it.
261
+ */
262
+ async putObject(
263
+ key: string,
264
+ body: Buffer | Readable | string,
265
+ contentType?: string,
266
+ contentLength?: number,
267
+ ): Promise<void> {
268
+ const { client, config, sdk } = this.requireInit();
269
+
270
+ let payload: Buffer | Readable | string = body;
271
+ let length = contentLength;
272
+ if (this.isStream(body) && length === undefined) {
273
+ payload = await this.collect(body);
274
+ length = payload.length;
275
+ } else if (length === undefined && typeof body !== 'string') {
276
+ length = (body as Buffer).length;
277
+ }
278
+
279
+ await client.send(
280
+ new sdk.PutObjectCommand({
281
+ Body: payload,
282
+ Bucket: config.bucket,
283
+ ...(contentType ? { ContentType: contentType } : {}),
284
+ ...(length === undefined ? {} : { ContentLength: length }),
285
+ Key: key,
286
+ }),
287
+ );
288
+ }
289
+
290
+ /**
291
+ * Copy an object into the main bucket WITHOUT the bytes passing through this process.
292
+ *
293
+ * S3 does the transfer internally, so the call costs one request instead of a full GET plus a
294
+ * full PUT held open for the duration. That difference is the whole point: the streaming
295
+ * alternative pins a request handler (and any lock it holds) for as long as the object takes to
296
+ * travel down to the pod and back up again — at the framework's 50 GB upload cap that is
297
+ * unbounded, and even a few GB overruns a typical reverse-proxy read timeout while doubling
298
+ * billable S3 traffic.
299
+ *
300
+ * `sourceBucket` defaults to the main bucket; pass it for a cross-bucket copy (the TUS staging
301
+ * bucket may differ from the file bucket). Both buckets must live behind THIS service's endpoint
302
+ * and credentials — S3 cannot copy from a store it cannot itself reach.
303
+ *
304
+ * **5 GB limit.** A single `CopyObject` cannot move more than 5 GB; larger objects require
305
+ * multipart upload-part-copy. Callers must check the size first — see
306
+ * {@link CoreS3Service.MAX_COPY_OBJECT_BYTES} — and fall back to streaming, loudly.
307
+ */
308
+ async copyObject(sourceKey: string, destinationKey: string, sourceBucket?: string, contentType?: string) {
309
+ const { client, config, sdk } = this.requireInit();
310
+ await client.send(
311
+ new sdk.CopyObjectCommand({
312
+ Bucket: config.bucket,
313
+ // `<bucket>/<key>`, URL-encoded per path segment: an unencoded key containing a space or a
314
+ // `+` is a signature mismatch rather than a missing object, which is far harder to read.
315
+ CopySource: this.copySource(sourceBucket ?? config.bucket, sourceKey),
316
+ Key: destinationKey,
317
+ // Without REPLACE the copy inherits the SOURCE object's content type. A TUS staging object
318
+ // carries none, so every copied file would download as application/octet-stream.
319
+ ...(contentType ? { ContentType: contentType, MetadataDirective: 'REPLACE' as const } : {}),
320
+ }),
321
+ );
322
+ }
323
+
324
+ /**
325
+ * Largest object a single `CopyObject` can move (S3 hard limit).
326
+ * Above this AWS requires multipart upload-part-copy.
327
+ */
328
+ static readonly MAX_COPY_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
329
+
330
+ /**
331
+ * Build the `CopySource` value for {@link CoreS3Service.copyObject}
332
+ */
333
+ protected copySource(bucket: string, key: string): string {
334
+ const encoded = key
335
+ .split('/')
336
+ .map((segment) => encodeURIComponent(segment))
337
+ .join('/');
338
+ return `/${encodeURIComponent(bucket)}/${encoded}`;
339
+ }
340
+
341
+ /**
342
+ * Whether the value is a readable stream rather than a Buffer or string
343
+ */
344
+ protected isStream(body: unknown): body is Readable {
345
+ return !!body && typeof (body as Readable).pipe === 'function';
346
+ }
347
+
348
+ /**
349
+ * Read a stream fully into memory (only for a body whose length is not known upfront)
350
+ */
351
+ protected async collect(stream: Readable): Promise<Buffer> {
352
+ const chunks: Buffer[] = [];
353
+ for await (const chunk of stream) {
354
+ chunks.push(Buffer.from(chunk));
355
+ }
356
+ return Buffer.concat(chunks);
357
+ }
358
+
359
+ /**
360
+ * Get an object stream (plus metadata) from the main bucket.
361
+ * Throws the SDK's NoSuchKey error when the object does not exist.
362
+ */
363
+ async getObject(key: string): Promise<{ body: Readable; contentLength?: number; contentType?: string }> {
364
+ const { client, config, sdk } = this.requireInit();
365
+ const result = await client.send(new sdk.GetObjectCommand({ Bucket: config.bucket, Key: key }));
366
+ return {
367
+ body: result.Body as Readable,
368
+ contentLength: result.ContentLength,
369
+ contentType: result.ContentType,
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Delete an object (no error when it does not exist).
375
+ * `bucket` defaults to the main bucket; pass it to reach the staging bucket.
376
+ */
377
+ async deleteObject(key: string, bucket?: string): Promise<void> {
378
+ const { client, config, sdk } = this.requireInit();
379
+ await client.send(new sdk.DeleteObjectCommand({ Bucket: bucket ?? config.bucket, Key: key }));
380
+ }
381
+
382
+ /**
383
+ * Whether an object exists in the main bucket
384
+ */
385
+ async objectExists(key: string): Promise<boolean> {
386
+ const { client, config, sdk } = this.requireInit();
387
+ try {
388
+ await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
389
+ return true;
390
+ } catch (error: any) {
391
+ if (error?.$metadata?.httpStatusCode === 404 || error?.name === 'NotFound') {
392
+ return false;
393
+ }
394
+ throw error;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Create a presigned download URL for an object in the main bucket.
400
+ * Returns undefined when presigned downloads are not enabled.
401
+ * Requires the optional peer dependency `@aws-sdk/s3-request-presigner`.
402
+ */
403
+ async getPresignedDownloadUrl(key: string, filename?: string): Promise<string | undefined> {
404
+ const { client, config, sdk } = this.requireInit();
405
+ if (!config.presignedDownloads) {
406
+ return undefined;
407
+ }
408
+ if (!this.presigner) {
409
+ try {
410
+ this.presigner = await import('@aws-sdk/s3-request-presigner');
411
+ } catch {
412
+ throw new Error(
413
+ 's3.presignedDownloads is enabled but the optional peer dependency "@aws-sdk/s3-request-presigner" is not installed. ' +
414
+ 'Run: pnpm add @aws-sdk/s3-request-presigner',
415
+ );
416
+ }
417
+ }
418
+ const command = new sdk.GetObjectCommand({
419
+ Bucket: config.bucket,
420
+ Key: key,
421
+ // Same renderer as the streamed branch in CoreFileController. S3 echoes this back verbatim
422
+ // as the response header, so a hand-rolled value here means one file downloads under two
423
+ // different names depending on whether presigned downloads happen to be enabled — which is
424
+ // invisible until someone compares the two. It also has to be a real RFC 6266 value: the
425
+ // quoted `filename` is never percent-decoded (so encoding it saves `Übersicht%20.pdf`
426
+ // literally), and without `filename*` a non-ASCII name is lost outright.
427
+ ...(filename ? { ResponseContentDisposition: buildContentDisposition(filename) } : {}),
428
+ });
429
+ return this.presigner.getSignedUrl(client, command, { expiresIn: config.presignedDownloads.expiresInSeconds });
430
+ }
431
+
432
+ /**
433
+ * Ensure a bucket exists (used by tests and first-boot convenience for
434
+ * self-hosted S3 like MinIO/RustFS — AWS buckets are usually pre-provisioned)
435
+ */
436
+ async ensureBucket(bucket: string): Promise<void> {
437
+ const { client, sdk } = this.requireInit();
438
+ try {
439
+ await client.send(new sdk.HeadBucketCommand({ Bucket: bucket }));
440
+ } catch {
441
+ await client.send(new sdk.CreateBucketCommand({ Bucket: bucket }));
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Destroy the shared client
447
+ */
448
+ async onApplicationShutdown(): Promise<void> {
449
+ this.client?.destroy();
450
+ this.client = undefined;
451
+ }
452
+
453
+ /**
454
+ * Narrowing guard: config + client + sdk are all present after onModuleInit
455
+ */
456
+ protected requireInit(): { client: S3Client; config: NormalizedS3Config; sdk: typeof import('@aws-sdk/client-s3') } {
457
+ if (!this.config) {
458
+ throw new Error('S3 is not configured/enabled (ServerOptions.s3)');
459
+ }
460
+ if (!this.client || !this.sdk) {
461
+ throw new Error('CoreS3Service is not initialized yet (onModuleInit pending)');
462
+ }
463
+ return { client: this.client, config: this.config, sdk: this.sdk };
464
+ }
465
+ }
@@ -0,0 +1,112 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap, OnModuleInit, Optional } from '@nestjs/common';
2
+ import { HttpAdapterHost } from '@nestjs/core';
3
+
4
+ import { IServerOptions } from '../interfaces/server-options.interface';
5
+ import { ConfigService } from './config.service';
6
+
7
+ /** Express setting name — the string Express itself keys the compiled trust function off */
8
+ const TRUST_PROXY = 'trust proxy';
9
+
10
+ /**
11
+ * Applies `ServerOptions.trustProxy` to the underlying Express app, and warns when an IP-keyed
12
+ * rate limiter runs without it.
13
+ *
14
+ * This lives in a CoreModule provider rather than in `main.ts` so a consumer inherits it by
15
+ * upgrading, without editing its bootstrap. It works because `HttpAdapterHost` already holds the
16
+ * adapter by the time providers are instantiated — `NestFactory` calls `container.setHttpAdapter()`
17
+ * before the dependency scan — and `req.ip` is computed per request, so setting it any time before
18
+ * `listen()` is early enough.
19
+ *
20
+ * Registered as a CoreModule provider; consumers never interact with it.
21
+ */
22
+ @Injectable()
23
+ export class CoreTrustProxyInitializer implements OnApplicationBootstrap, OnModuleInit {
24
+ protected readonly logger = new Logger(CoreTrustProxyInitializer.name);
25
+
26
+ constructor(@Optional() protected readonly httpAdapterHost?: HttpAdapterHost) {}
27
+
28
+ /**
29
+ * Apply the configured value, if there is one.
30
+ *
31
+ * Only when configured: module init runs inside `app.init()`, i.e. AFTER `main.ts`, so applying
32
+ * an unset value would silently overwrite a consumer's own `app.set('trust proxy', fn)` — which
33
+ * is the documented escape hatch for the predicate form this option does not accept.
34
+ */
35
+ onModuleInit(): void {
36
+ const configured = this.getConfigured();
37
+ if (configured === undefined) {
38
+ return;
39
+ }
40
+
41
+ const app = this.getExpressApp();
42
+ if (!app) {
43
+ // Fastify and non-HTTP contexts have no equivalent setting; there is nothing to apply and
44
+ // nothing the consumer can do about it, so this stays silent rather than warning per boot.
45
+ return;
46
+ }
47
+
48
+ app.set(TRUST_PROXY, configured);
49
+ this.logger.log(
50
+ `Express "trust proxy" set to ${JSON.stringify(configured)} — request.ip follows X-Forwarded-For accordingly`,
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Warn once at boot when a rate limiter is keyed on an IP the app cannot actually determine.
56
+ *
57
+ * Runs in `onApplicationBootstrap` so every `onModuleInit` — including the limiters' own
58
+ * auto-configuration — has completed, and reads the LIVE Express setting rather than the config
59
+ * so a consumer who set it by hand in `main.ts` is not nagged.
60
+ *
61
+ * The failure this names is silent by construction: nothing in the request path can tell that
62
+ * every caller resolved to the same address, so without this line the first symptom is users
63
+ * being locked out of sign-in with no matching attack in the logs.
64
+ */
65
+ onApplicationBootstrap(): void {
66
+ const limiters = this.enabledIpRateLimiters();
67
+ if (this.getConfigured() !== undefined || !limiters.length) {
68
+ return;
69
+ }
70
+
71
+ const app = this.getExpressApp();
72
+ if (!app || app.get(TRUST_PROXY)) {
73
+ return;
74
+ }
75
+
76
+ this.logger.warn(
77
+ `Rate limiting is enabled (${limiters.join(', ')}) but Express "trust proxy" is not set. ` +
78
+ 'Behind a reverse proxy (Caddy, nginx, a Kubernetes ingress) request.ip is then the PROXY address for every ' +
79
+ 'request, so every client behind a reverse proxy shares one bucket and the limit throttles all of them at ' +
80
+ 'once. Set `trustProxy` in your server options to the number of proxy hops in front of this app ' +
81
+ '(e.g. `trustProxy: 1`), or `trustProxy: false` if nothing proxies it — which also silences this warning.',
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Which IP-keyed limiters are on.
87
+ *
88
+ * Read from config rather than injected, so this provider stays free of import edges to the auth
89
+ * and better-auth modules. Both follow "presence implies enabled". The AI limiter is deliberately
90
+ * absent: it keys on the user id, so `trust proxy` does not affect it.
91
+ */
92
+ protected enabledIpRateLimiters(): string[] {
93
+ return (['auth.rateLimit', 'betterAuth.rateLimit'] as const).filter((path) => {
94
+ const config = ConfigService.getFastButReadOnly<{ enabled?: boolean } | boolean>(path);
95
+ if (config === undefined || config === null || config === false) {
96
+ return false;
97
+ }
98
+ return typeof config === 'boolean' ? config : config.enabled !== false;
99
+ });
100
+ }
101
+
102
+ /** The configured value, or `undefined` when the consumer did not set one */
103
+ protected getConfigured(): IServerOptions['trustProxy'] {
104
+ return ConfigService.getFastButReadOnly<IServerOptions['trustProxy']>('trustProxy');
105
+ }
106
+
107
+ /** The Express application, or `undefined` for a non-Express adapter */
108
+ protected getExpressApp(): undefined | { get: (name: string) => any; set: (name: string, value: any) => void } {
109
+ const instance = this.httpAdapterHost?.httpAdapter?.getInstance?.();
110
+ return typeof instance?.set === 'function' && typeof instance?.get === 'function' ? instance : undefined;
111
+ }
112
+ }