@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.
- package/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +277 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/file.helper.spec.ts +0 -145
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/file/core-file.controller.spec.ts +0 -164
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -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
|
+
}
|