@lenne.tech/nest-server 11.32.4 → 11.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/dist/config.env.js +6 -0
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +228 -8
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js +2 -2
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +3 -14
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +10 -2
- package/dist/server/modules/file/file.service.js +23 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1584 -0
- package/package.json +74 -42
- package/src/config.env.ts +45 -0
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +120 -0
- package/src/core/modules/file/README.md +308 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +517 -17
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +14 -5
- package/src/server/modules/file/file.resolver.ts +11 -19
- package/src/server/modules/file/file.service.ts +89 -4
- package/src/server/modules/user/avatar.controller.ts +46 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/file.helper.spec.ts +0 -145
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/file/core-file.controller.spec.ts +0 -164
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Types } from 'mongoose';
|
|
2
|
+
import { Readable } from 'stream';
|
|
3
|
+
|
|
4
|
+
import { CoreS3Service } from '../../common/services/core-s3.service';
|
|
5
|
+
import {
|
|
6
|
+
ensureFilenameIndex,
|
|
7
|
+
FileCollection,
|
|
8
|
+
FileMetadataInfo,
|
|
9
|
+
findMetadata,
|
|
10
|
+
findMetadataById,
|
|
11
|
+
findMetadataByName,
|
|
12
|
+
} from './file-metadata.helper';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Name of the MongoDB collection holding the metadata of S3-stored files.
|
|
16
|
+
*
|
|
17
|
+
* S3 itself has no queryable metadata store, so file info (filename, contentType,
|
|
18
|
+
* length, uploadDate) is kept in this collection — the equivalent of GridFS's
|
|
19
|
+
* `fs.files`. The object key in the bucket is the string form of `_id`.
|
|
20
|
+
*/
|
|
21
|
+
export const S3_FILES_COLLECTION = 's3-files';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Metadata of a file stored in S3.
|
|
25
|
+
*
|
|
26
|
+
* Alias of the shared {@link FileMetadataInfo}, kept as a named export because it is
|
|
27
|
+
* part of the published API. It used to be a separate, structurally identical
|
|
28
|
+
* declaration alongside `FilesystemFileInfo` — which is how the two lookup surfaces
|
|
29
|
+
* drifted apart. One declaration now, so they cannot.
|
|
30
|
+
*/
|
|
31
|
+
export type S3FileInfo = FileMetadataInfo;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read a stream completely into a buffer
|
|
35
|
+
*/
|
|
36
|
+
export async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
|
37
|
+
const chunks: Buffer[] = [];
|
|
38
|
+
for await (const chunk of stream) {
|
|
39
|
+
chunks.push(Buffer.from(chunk));
|
|
40
|
+
}
|
|
41
|
+
return Buffer.concat(chunks);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Helper for files stored in S3 with their metadata in MongoDB.
|
|
46
|
+
*
|
|
47
|
+
* Counterpart of GridFSHelper: same operations, S3 as the byte store and
|
|
48
|
+
* `s3-files` as the metadata store.
|
|
49
|
+
*/
|
|
50
|
+
export class S3FileHelper {
|
|
51
|
+
/**
|
|
52
|
+
* Store a file in S3 and record its metadata.
|
|
53
|
+
*
|
|
54
|
+
* The object is verified via HEAD before the metadata document is written, so
|
|
55
|
+
* a file info is never returned for bytes that did not arrive (same guarantee
|
|
56
|
+
* GridFSHelper gives by reading the file document back after the upload).
|
|
57
|
+
*/
|
|
58
|
+
static async writeFile(
|
|
59
|
+
s3Service: CoreS3Service,
|
|
60
|
+
collection: FileCollection,
|
|
61
|
+
options: {
|
|
62
|
+
body?: Readable;
|
|
63
|
+
buffer?: Buffer;
|
|
64
|
+
contentLength?: number;
|
|
65
|
+
contentType?: string;
|
|
66
|
+
filename: string;
|
|
67
|
+
metadata?: Record<string, any>;
|
|
68
|
+
},
|
|
69
|
+
): Promise<S3FileInfo> {
|
|
70
|
+
const _id = new Types.ObjectId();
|
|
71
|
+
const key = _id.toHexString();
|
|
72
|
+
|
|
73
|
+
// A stream with a known length streams straight through. Reading it into a Buffer first —
|
|
74
|
+
// which every caller used to do — materialises the WHOLE file in one process: a 4 GB
|
|
75
|
+
// resumable upload (well inside the 50 GB default cap) then either throws
|
|
76
|
+
// "Array buffer allocation failed" or gets the container OOM-killed at 100% progress,
|
|
77
|
+
// taking every other in-flight request with it.
|
|
78
|
+
const body = options.body ?? options.buffer;
|
|
79
|
+
if (!body) {
|
|
80
|
+
throw new Error('S3FileHelper.writeFile needs either a body stream or a buffer');
|
|
81
|
+
}
|
|
82
|
+
await s3Service.putObject(key, body, options.contentType, options.contentLength ?? options.buffer?.length);
|
|
83
|
+
|
|
84
|
+
return S3FileHelper.recordFile(s3Service, collection, _id, {
|
|
85
|
+
contentType: options.contentType,
|
|
86
|
+
filename: options.filename,
|
|
87
|
+
length: options.contentLength ?? options.buffer?.length,
|
|
88
|
+
metadata: options.metadata,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Adopt an object that ALREADY lives in the same S3 endpoint into file storage.
|
|
94
|
+
*
|
|
95
|
+
* The bytes are moved by S3 itself (`CopyObject`) rather than pulled into this process and
|
|
96
|
+
* pushed back out — see {@link CoreS3Service.copyObject}. Used for the TUS S3→S3 hand-off,
|
|
97
|
+
* where source and destination are two buckets of the same store.
|
|
98
|
+
*
|
|
99
|
+
* The metadata document is written by the same {@link S3FileHelper.recordFile} the streaming
|
|
100
|
+
* path uses, so the two cannot drift into producing differently-shaped file infos.
|
|
101
|
+
*/
|
|
102
|
+
static async copyFile(
|
|
103
|
+
s3Service: CoreS3Service,
|
|
104
|
+
collection: FileCollection,
|
|
105
|
+
options: {
|
|
106
|
+
contentLength?: number;
|
|
107
|
+
contentType?: string;
|
|
108
|
+
filename: string;
|
|
109
|
+
metadata?: Record<string, any>;
|
|
110
|
+
sourceBucket?: string;
|
|
111
|
+
sourceKey: string;
|
|
112
|
+
},
|
|
113
|
+
): Promise<S3FileInfo> {
|
|
114
|
+
const _id = new Types.ObjectId();
|
|
115
|
+
await s3Service.copyObject(options.sourceKey, _id.toHexString(), options.sourceBucket, options.contentType);
|
|
116
|
+
|
|
117
|
+
return S3FileHelper.recordFile(s3Service, collection, _id, {
|
|
118
|
+
contentType: options.contentType,
|
|
119
|
+
filename: options.filename,
|
|
120
|
+
length: options.contentLength,
|
|
121
|
+
metadata: options.metadata,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Verify the object arrived and record its metadata.
|
|
127
|
+
*
|
|
128
|
+
* The HEAD before the insert is what keeps a file info from ever describing bytes that are not
|
|
129
|
+
* there (the guarantee GridFSHelper gets by reading the file document back after the upload).
|
|
130
|
+
*/
|
|
131
|
+
protected static async recordFile(
|
|
132
|
+
s3Service: CoreS3Service,
|
|
133
|
+
collection: FileCollection,
|
|
134
|
+
_id: Types.ObjectId,
|
|
135
|
+
options: { contentType?: string; filename: string; length?: number; metadata?: Record<string, any> },
|
|
136
|
+
): Promise<S3FileInfo> {
|
|
137
|
+
if (!(await s3Service.objectExists(_id.toHexString()))) {
|
|
138
|
+
throw new Error('File uploaded but not found in S3');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// On the WRITE path, not the read path: createIndex creates the collection, so
|
|
142
|
+
// ensuring it when reading gave a GridFS-only deployment an empty `s3-files`.
|
|
143
|
+
// Here the collection is about to exist anyway. Never throws.
|
|
144
|
+
await ensureFilenameIndex(collection);
|
|
145
|
+
|
|
146
|
+
const fileInfo: S3FileInfo = {
|
|
147
|
+
_id,
|
|
148
|
+
contentType: options.contentType,
|
|
149
|
+
filename: options.filename,
|
|
150
|
+
length: options.length,
|
|
151
|
+
metadata: options.metadata,
|
|
152
|
+
// Records WHERE the bytes went, so a reader never has to probe all three
|
|
153
|
+
// stores to find out. Legacy documents lack it and are handled by probing.
|
|
154
|
+
storage: 's3',
|
|
155
|
+
uploadDate: new Date(),
|
|
156
|
+
};
|
|
157
|
+
await collection.insertOne(fileInfo);
|
|
158
|
+
return fileInfo;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find file metadata by ID
|
|
163
|
+
*/
|
|
164
|
+
static async findFileById(collection: FileCollection, id: string | Types.ObjectId): Promise<null | S3FileInfo> {
|
|
165
|
+
return findMetadataById(collection, id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Find file metadata by filename
|
|
170
|
+
*/
|
|
171
|
+
static async findFileByName(collection: FileCollection, filename: string): Promise<null | S3FileInfo> {
|
|
172
|
+
return findMetadataByName(collection, filename);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Find files with filter and options
|
|
177
|
+
*/
|
|
178
|
+
static async findFiles(collection: FileCollection, filter: any = {}, options: any = {}): Promise<S3FileInfo[]> {
|
|
179
|
+
return findMetadata(collection, filter, options);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Delete a file from S3 and remove its metadata
|
|
184
|
+
*/
|
|
185
|
+
static async deleteFile(
|
|
186
|
+
s3Service: CoreS3Service,
|
|
187
|
+
collection: FileCollection,
|
|
188
|
+
id: string | Types.ObjectId,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
|
|
191
|
+
await s3Service.deleteObject(objectId.toHexString());
|
|
192
|
+
await collection.deleteOne({ _id: objectId });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the download stream of a file
|
|
197
|
+
*/
|
|
198
|
+
static async getStream(s3Service: CoreS3Service, id: string | Types.ObjectId): Promise<Readable> {
|
|
199
|
+
const objectId = typeof id === 'string' ? new Types.ObjectId(id) : id;
|
|
200
|
+
return (await s3Service.getObject(objectId.toHexString())).body;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the content of a file as buffer
|
|
205
|
+
*/
|
|
206
|
+
static async getBuffer(s3Service: CoreS3Service, id: string | Types.ObjectId): Promise<Buffer> {
|
|
207
|
+
return streamToBuffer(await S3FileHelper.getStream(s3Service, id));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -146,6 +146,54 @@ Three in-memory ring buffers (fixed capacity, no timers, per-app-instance — pa
|
|
|
146
146
|
- **Queries** — MongoDB driver command monitoring; records value-free query SHAPES (N+1 templates), never values.
|
|
147
147
|
Enabling it opts the driver into `monitorCommands` from `core.module.ts`.
|
|
148
148
|
|
|
149
|
+
The mailbox (`hub.mailbox`) uses the same buffer.
|
|
150
|
+
|
|
151
|
+
### Multi-replica
|
|
152
|
+
|
|
153
|
+
**Without Redis** each buffer is process-local, so a pod only ever shows the logs, traces,
|
|
154
|
+
queries and mails it produced itself. Behind a load balancer that makes the panels look
|
|
155
|
+
lossy — every poll may hit a different pod and show a different slice. Route `/hub` to a
|
|
156
|
+
single replica (sticky routing) if you need a coherent view.
|
|
157
|
+
|
|
158
|
+
**With Redis** (`ServerOptions.redis`) all four collectors mirror into shared, capped Redis
|
|
159
|
+
lists (`<keyPrefix>:hub:{logs,traces,queries,mailbox}`) and read from there, so every pod
|
|
160
|
+
shows the merged cluster-wide view and "clear" clears it everywhere. Sequence numbers come
|
|
161
|
+
from a shared counter, so the panels' cursor-based polling keeps working across replicas.
|
|
162
|
+
|
|
163
|
+
Writes are fire-and-forget — the collectors sit in hot paths and their API stays synchronous.
|
|
164
|
+
If Redis is unreachable a read silently falls back to the local buffer (a diagnostics panel
|
|
165
|
+
must not be the thing that breaks), so a partial view during an outage is expected.
|
|
166
|
+
|
|
167
|
+
#### Cost: Redis write volume scales with your traffic
|
|
168
|
+
|
|
169
|
+
**Know this before enabling `redis` together with `hub.collectors`.** Every mirrored entry costs a
|
|
170
|
+
3-command `MULTI` (`INCR` the shared sequence counter, `LPUSH` the JSON-serialized entry, `LTRIM`
|
|
171
|
+
back to the buffer capacity). There is **no sampling** — each retained entry is one `MULTI`:
|
|
172
|
+
|
|
173
|
+
| Collector | One mirror write per | Rough volume |
|
|
174
|
+
| --------- | ------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
175
|
+
| Logs | log line at a captured level (`collectors.logs.levels`) | 3 Redis commands × your log rate |
|
|
176
|
+
| Traces | HTTP request outside the excluded path prefixes | 3 Redis commands × your request rate |
|
|
177
|
+
| Queries | monitored MongoDB command (opt-in) | 3 Redis commands × your query rate — usually the highest of the four |
|
|
178
|
+
| Mailbox | outgoing mail | negligible |
|
|
179
|
+
|
|
180
|
+
At 500 req/s with request tracing on, that is ~1 500 extra Redis commands per second before the log
|
|
181
|
+
lines are counted. The buffers stay capped in memory (`LTRIM` holds them at the configured
|
|
182
|
+
capacity), so this is a **throughput** cost on the Redis instance, not a growth problem — but it is
|
|
183
|
+
a cost that lands on the same Redis your rate-limit counters, cron leases and subscriptions use.
|
|
184
|
+
|
|
185
|
+
Two ways to keep it out of the way, both deliberate choices rather than defaults:
|
|
186
|
+
|
|
187
|
+
- **Give the Hub a dedicated Redis database or instance.** `redis.db` picks a numbered database on
|
|
188
|
+
the same server (cheap, still one instance's throughput); a separate instance isolates it fully.
|
|
189
|
+
Either way set `redis.keyPrefix` so environments cannot collide.
|
|
190
|
+
- **Accept the volume knowingly**, and keep `collectors.queries` off (it is opt-in for exactly this
|
|
191
|
+
reason) unless you are actively profiling. Turning individual collectors off via
|
|
192
|
+
`hub.collectors` removes their mirror writes entirely.
|
|
193
|
+
|
|
194
|
+
A single-replica deployment gains nothing from mirroring — leave `redis` unset, or the Hub
|
|
195
|
+
collectors off, and every buffer stays process-local at zero Redis cost.
|
|
196
|
+
|
|
149
197
|
## Overrides
|
|
150
198
|
|
|
151
199
|
```typescript
|
|
@@ -97,8 +97,8 @@ export class CoreHubController {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
@Get('diagnostics.json')
|
|
100
|
-
diagnosticsJson(@Res() res: Response): void {
|
|
101
|
-
this.sendJson(res, this.hubService.getDiagnostics(this.collectorBuffers()));
|
|
100
|
+
async diagnosticsJson(@Res() res: Response): Promise<void> {
|
|
101
|
+
this.sendJson(res, this.hubService.getDiagnostics(await this.collectorBuffers()));
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
@Get('files.json')
|
|
@@ -117,17 +117,17 @@ export class CoreHubController {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
@Get('mailbox.json')
|
|
120
|
-
mailboxJson(@Query('since') since: string | undefined, @Res() res: Response): void {
|
|
120
|
+
async mailboxJson(@Query('since') since: string | undefined, @Res() res: Response): Promise<void> {
|
|
121
121
|
if (!this.mailboxService) {
|
|
122
122
|
this.sendJson(res, { available: false, hint: 'The mailbox is not enabled.' });
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
|
-
this.sendJson(res, this.mailboxService.getMailbox(since !== undefined ? Number(since) : undefined));
|
|
125
|
+
this.sendJson(res, await this.mailboxService.getMailbox(since !== undefined ? Number(since) : undefined));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
@Get('mailbox/:seq/html')
|
|
129
|
-
mailboxHtml(@Param('seq') seq: string, @Res() res: Response): void {
|
|
130
|
-
const html = this.mailboxService?.getMailHtml(Number(seq));
|
|
129
|
+
async mailboxHtml(@Param('seq') seq: string, @Res() res: Response): Promise<void> {
|
|
130
|
+
const html = await this.mailboxService?.getMailHtml(Number(seq));
|
|
131
131
|
res.set({
|
|
132
132
|
'Cache-Control': 'no-store',
|
|
133
133
|
'Content-Security-Policy': "default-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src data:",
|
|
@@ -143,12 +143,12 @@ export class CoreHubController {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
@Get('logs.json')
|
|
146
|
-
logsJson(@Query('since') since: string | undefined, @Res() res: Response): void {
|
|
146
|
+
async logsJson(@Query('since') since: string | undefined, @Res() res: Response): Promise<void> {
|
|
147
147
|
if (!this.logBuffer.enabled) {
|
|
148
148
|
this.sendJson(res, { available: false, hint: 'The logs collector is disabled.' });
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
|
-
this.sendJson(res, this.logBuffer.getData(since !== undefined ? Number(since) : undefined));
|
|
151
|
+
this.sendJson(res, await this.logBuffer.getData(since !== undefined ? Number(since) : undefined));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
@Get('models.json')
|
|
@@ -167,7 +167,7 @@ export class CoreHubController {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
@Get('queries.json')
|
|
170
|
-
queriesJson(@Res() res: Response): void {
|
|
170
|
+
async queriesJson(@Res() res: Response): Promise<void> {
|
|
171
171
|
if (!this.queryProfiler.enabled) {
|
|
172
172
|
this.sendJson(res, {
|
|
173
173
|
available: false,
|
|
@@ -175,16 +175,16 @@ export class CoreHubController {
|
|
|
175
175
|
});
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
|
-
this.sendJson(res, this.queryProfiler.getData());
|
|
178
|
+
this.sendJson(res, await this.queryProfiler.getData());
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
@Get('traces.json')
|
|
182
|
-
tracesJson(@Query('since') since: string | undefined, @Res() res: Response): void {
|
|
182
|
+
async tracesJson(@Query('since') since: string | undefined, @Res() res: Response): Promise<void> {
|
|
183
183
|
if (!this.traceBuffer.enabled) {
|
|
184
184
|
this.sendJson(res, { available: false, hint: 'The traces collector is disabled.' });
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
-
this.sendJson(res, this.traceBuffer.getData(since !== undefined ? Number(since) : undefined));
|
|
187
|
+
this.sendJson(res, await this.traceBuffer.getData(since !== undefined ? Number(since) : undefined));
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
@Get('routes.json')
|
|
@@ -261,10 +261,12 @@ export class CoreHubController {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/** Buffer fill levels for the diagnostics panel. */
|
|
264
|
-
protected collectorBuffers(): Record<string, { capacity: number; enabled: boolean; size: number }
|
|
265
|
-
const logs =
|
|
266
|
-
|
|
267
|
-
|
|
264
|
+
protected async collectorBuffers(): Promise<Record<string, { capacity: number; enabled: boolean; size: number }>> {
|
|
265
|
+
const [logs, traces, queries] = await Promise.all([
|
|
266
|
+
this.logBuffer.getData(),
|
|
267
|
+
this.traceBuffer.getData(),
|
|
268
|
+
this.queryProfiler.getData(),
|
|
269
|
+
]);
|
|
268
270
|
return {
|
|
269
271
|
logs: { capacity: 0, enabled: this.logBuffer.enabled, size: logs.records.length },
|
|
270
272
|
queries: { capacity: 0, enabled: this.queryProfiler.enabled, size: queries.recent.length },
|
|
@@ -6,12 +6,29 @@
|
|
|
6
6
|
* keys IN PLACE, which would corrupt the running server config. Cloning here is the guarantee.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Key-name heuristics that mark a value as secret. Matched case-insensitively against each key.
|
|
11
|
+
*
|
|
12
|
+
* `access[-_]?key` also covers `accessKeyId`: the trailing `(^|[^a-z])key([^a-z]|$)` alternative
|
|
13
|
+
* does NOT, because the `i` flag makes `[^a-z]` reject an uppercase letter, so `…sKeyI…` fails it.
|
|
14
|
+
* `s3.secretAccessKey` was already caught by `secret`; the ID was not.
|
|
15
|
+
*/
|
|
10
16
|
const SECRET_KEY_PATTERN =
|
|
11
|
-
/secret|passwd|password|passphrase|credential|api[-_]?key|access[-_]?token|refresh[-_]?token|private[-_]?key|encryption|\btoken\b|\bpass\b|(^|[^a-z])key([^a-z]|$)/i;
|
|
17
|
+
/secret|passwd|password|passphrase|credential|api[-_]?key|access[-_]?key|access[-_]?token|refresh[-_]?token|private[-_]?key|encryption|\btoken\b|\bpass\b|(^|[^a-z])key([^a-z]|$)/i;
|
|
12
18
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Value-shape heuristic: a connection URI carrying `scheme://user:password@host`.
|
|
21
|
+
*
|
|
22
|
+
* The user part is `*`, not `+`, so the PASSWORD-ONLY form is matched too:
|
|
23
|
+
* `redis://:password@host` is the canonical Redis URL (Redis < 6 has no username, and
|
|
24
|
+
* `redis-cli -u`, Heroku Redis and ElastiCache AUTH all emit it). Requiring a non-empty
|
|
25
|
+
* user printed that shape verbatim in the Hub config panel. `redis.url` became a
|
|
26
|
+
* first-class option in 11.33.0, so the shape is newly reachable here.
|
|
27
|
+
*
|
|
28
|
+
* Still safe against false positives: the password group cannot contain `/`, and the `@`
|
|
29
|
+
* must follow it immediately — so `http://host:8080/x@y` does not match.
|
|
30
|
+
*/
|
|
31
|
+
const URI_CREDENTIAL_PATTERN = /^([a-z][a-z0-9+.-]*:\/\/[^/@:\s]*:)([^@/\s]+)(@)/i;
|
|
15
32
|
|
|
16
33
|
const MASK = '***';
|
|
17
34
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
2
|
+
import { HubBufferEntry, HubRingBuffer } from './hub-ring-buffer';
|
|
3
|
+
|
|
4
|
+
/** A buffer read, shaped like the `cursor` / `dropped` fields every Hub panel payload carries. */
|
|
5
|
+
export interface HubBufferData<T> {
|
|
6
|
+
/** Highest assigned seq (`-1` when nothing was ever added) — the client's next cursor. */
|
|
7
|
+
cursor: number;
|
|
8
|
+
/** Seq of the oldest retained entry (`-1` when empty) — clients detect eviction gaps against this. */
|
|
9
|
+
dropped: number;
|
|
10
|
+
entries: T[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long a mirrored buffer survives without being written to, in seconds.
|
|
15
|
+
*
|
|
16
|
+
* The list is size-bounded by LTRIM but was never time-bounded, so its contents outlived the
|
|
17
|
+
* process, the deployment and every Redis snapshot taken in between. That matters because of WHAT
|
|
18
|
+
* these buffers hold: log lines, full request traces and — in mailbox capture mode — whole
|
|
19
|
+
* outgoing emails, including verification and password-reset links that are still valid.
|
|
20
|
+
* Diagnostics are worth keeping for a shift, not forever.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_SHARED_TTL_SECONDS = 24 * 60 * 60;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Entries fetched per poll before the range is widened.
|
|
26
|
+
*
|
|
27
|
+
* A Hub panel polls every 5s and normally sees single-digit deltas, while the trace buffer holds
|
|
28
|
+
* up to 1000 entries — reading and parsing the whole list on every poll of every open tab was
|
|
29
|
+
* paying 1000× for that delta. The widen-and-retry loop below keeps the read exact when a client
|
|
30
|
+
* really is further behind.
|
|
31
|
+
*/
|
|
32
|
+
const SHARED_READ_CHUNK = 64;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A collector buffer with an OPTIONAL Redis backing — the single boundary at which the Hub's
|
|
36
|
+
* diagnostic collectors (logs, traces, queries, mailbox) become multi-replica aware.
|
|
37
|
+
*
|
|
38
|
+
* Without Redis this is exactly the process-local {@link HubRingBuffer} and nothing changes.
|
|
39
|
+
* With Redis (`ServerOptions.redis`) every append is ALSO mirrored into a capped Redis list
|
|
40
|
+
* (`LPUSH` + `LTRIM` to the same capacity) and reads come from that list, so every pod shows
|
|
41
|
+
* the merged view instead of only its own slice.
|
|
42
|
+
*
|
|
43
|
+
* **Sequence numbers stay meaningful across replicas.** The mirror keeps a companion counter:
|
|
44
|
+
* the write is `MULTI: INCR seq / LPUSH / LTRIM` and the read is `MULTI: GET seq / LRANGE`,
|
|
45
|
+
* both atomic, so the newest list element is always `seq = total - 1` and the counter can
|
|
46
|
+
* never disagree with the list. `clear()` deletes only the list — the counter keeps advancing,
|
|
47
|
+
* so existing client cursors stay valid, matching `HubRingBuffer.clear()`.
|
|
48
|
+
*
|
|
49
|
+
* **Writes are fire-and-forget.** The collectors sit in hot paths (every log line, every
|
|
50
|
+
* request) and their API towards callers must stay synchronous. A Redis failure is logged at
|
|
51
|
+
* debug level; reads then fall back to the local buffer rather than failing the panel.
|
|
52
|
+
*/
|
|
53
|
+
export class HubBuffer<T extends HubBufferEntry> {
|
|
54
|
+
protected readonly listKey: string;
|
|
55
|
+
protected readonly local: HubRingBuffer<T>;
|
|
56
|
+
protected readonly redis?: CoreRedisService;
|
|
57
|
+
protected readonly seqKey: string;
|
|
58
|
+
|
|
59
|
+
/** Retention of the mirrored list, refreshed on every append. */
|
|
60
|
+
protected readonly ttlSeconds: number;
|
|
61
|
+
|
|
62
|
+
constructor(capacity: number, name: string, redis?: CoreRedisService, ttlSeconds = DEFAULT_SHARED_TTL_SECONDS) {
|
|
63
|
+
this.local = new HubRingBuffer<T>(capacity);
|
|
64
|
+
this.redis = redis?.enabled ? redis : undefined;
|
|
65
|
+
this.listKey = this.redis?.key('hub', name) ?? '';
|
|
66
|
+
this.seqKey = this.redis?.key('hub', name, 'seq') ?? '';
|
|
67
|
+
this.ttlSeconds = ttlSeconds;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Append an entry. Returns the local entry (with its process-local seq) synchronously. */
|
|
71
|
+
add(entry: Omit<T, 'seq' | 'timestamp'> & { timestamp?: number }): T {
|
|
72
|
+
const full = this.local.add(entry);
|
|
73
|
+
this.mirror('append', (client) =>
|
|
74
|
+
client
|
|
75
|
+
.multi()
|
|
76
|
+
.incr(this.seqKey)
|
|
77
|
+
.lpush(this.listKey, JSON.stringify(full))
|
|
78
|
+
.ltrim(this.listKey, 0, this.local.capacity - 1)
|
|
79
|
+
// Refreshed on every append, so an actively used buffer never expires and an abandoned one
|
|
80
|
+
// (a scaled-down replica, a torn-down environment) stops being a durable copy of captured
|
|
81
|
+
// logs, traces and emails. Only the LIST expires — the seq counter must survive so client
|
|
82
|
+
// cursors stay valid, exactly as they do across clear().
|
|
83
|
+
.expire(this.listKey, this.ttlSeconds)
|
|
84
|
+
.exec(),
|
|
85
|
+
);
|
|
86
|
+
return full;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drop all retained entries — on every replica when Redis-backed. */
|
|
90
|
+
clear(): void {
|
|
91
|
+
this.local.clear();
|
|
92
|
+
this.mirror('clear', (client) => client.del(this.listKey));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run a fire-and-forget mirror write. Swallows BOTH failure modes: `getClient()` throws
|
|
97
|
+
* synchronously before `CoreRedisService.onModuleInit` has run (log lines during bootstrap
|
|
98
|
+
* reach the collector earlier than that), and the command itself rejects on an outage.
|
|
99
|
+
* Neither may escape into a hot logging or request path.
|
|
100
|
+
*/
|
|
101
|
+
protected mirror(label: string, run: (client: ReturnType<CoreRedisService['getClient']>) => Promise<unknown>): void {
|
|
102
|
+
if (!this.redis) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const failed = (err: Error): void =>
|
|
106
|
+
// console, NOT Logger: the Hub logs collector captures Logger output INTO a HubBuffer,
|
|
107
|
+
// so reporting a mirror failure through Logger re-enters this very method. With a
|
|
108
|
+
// synchronous throw from getClient() — exactly what happens for log lines emitted
|
|
109
|
+
// before CoreRedisService has connected — that recurses on one stack until it
|
|
110
|
+
// overflows, turning a Redis hiccup into a crashed process.
|
|
111
|
+
console.debug(`Hub buffer ${label} failed (${this.listKey}): ${err.message}`);
|
|
112
|
+
try {
|
|
113
|
+
run(this.redis.getClient()).catch(failed);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
failed(err as Error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Entries oldest→newest, optionally only those newer than `since`. */
|
|
120
|
+
async read(since?: number, limit?: number): Promise<HubBufferData<T>> {
|
|
121
|
+
const shared = this.redis ? await this.readShared(since, limit) : undefined;
|
|
122
|
+
if (shared) {
|
|
123
|
+
return shared;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
cursor: this.local.lastSeq,
|
|
127
|
+
dropped: this.local.firstRetainedSeq,
|
|
128
|
+
entries: since === undefined ? this.local.recent(limit) : this.local.since(since, limit),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read the shared list, or undefined when Redis is unreachable (caller falls back to local).
|
|
134
|
+
*
|
|
135
|
+
* The list is newest-first, so the entry with seq `s` sits at index `total - 1 - s` and the
|
|
136
|
+
* entries a client with cursor `since` still needs are exactly the first `total - 1 - since`.
|
|
137
|
+
* `total` is only known once the read has happened, so the range starts at a poll-sized chunk
|
|
138
|
+
* and is widened only when that turns out not to have covered the client's cursor — which keeps
|
|
139
|
+
* the common poll O(delta) instead of O(capacity) without ever returning a short answer.
|
|
140
|
+
*/
|
|
141
|
+
protected async readShared(since?: number, limit?: number): Promise<HubBufferData<T> | undefined> {
|
|
142
|
+
try {
|
|
143
|
+
const client = this.redis!.getClient();
|
|
144
|
+
const capacity = this.local.capacity;
|
|
145
|
+
const cap = Math.min(limit ?? capacity, capacity);
|
|
146
|
+
// Without a cursor the caller wants the newest window, which LTRIM already bounds by
|
|
147
|
+
// capacity — so the full range is exact rather than a guess.
|
|
148
|
+
let want = Math.max(1, since === undefined ? cap : Math.min(cap, SHARED_READ_CHUNK));
|
|
149
|
+
|
|
150
|
+
for (let attempt = 0; ; attempt++) {
|
|
151
|
+
const result = await client
|
|
152
|
+
.multi()
|
|
153
|
+
.get(this.seqKey)
|
|
154
|
+
.lrange(this.listKey, 0, want - 1)
|
|
155
|
+
.exec();
|
|
156
|
+
const total = Number(result?.[0]?.[1] ?? 0);
|
|
157
|
+
const raw = (result?.[1]?.[1] ?? []) as string[];
|
|
158
|
+
const needed = since === undefined ? cap : Math.max(0, Math.min(cap, total - 1 - since));
|
|
159
|
+
|
|
160
|
+
// Enough in hand, or the list simply ended before the bound, or we have widened often
|
|
161
|
+
// enough — three more round trips is already pathological, and answering with what we
|
|
162
|
+
// have beats spinning while the panel waits.
|
|
163
|
+
if (raw.length >= needed || raw.length < want || attempt >= 2) {
|
|
164
|
+
return this.shape(total, raw, needed);
|
|
165
|
+
}
|
|
166
|
+
want = Math.min(Math.max(needed, want * 4), capacity);
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// console, NOT Logger — same re-entrancy as in mirror() above
|
|
170
|
+
console.debug(`Hub buffer read failed (${this.listKey}), using the local buffer: ${(err as Error).message}`);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Turn a newest-first slice into the oldest→newest payload, stamping the SHARED seq over the
|
|
177
|
+
* per-replica one that was serialized.
|
|
178
|
+
*
|
|
179
|
+
* `dropped` is derived from what the slice covers, so for a polling client it reports "nothing
|
|
180
|
+
* you had was evicted" — and when entries really were evicted (the client is further behind than
|
|
181
|
+
* the buffer is deep) the slice ends early and the true gap surfaces.
|
|
182
|
+
*/
|
|
183
|
+
protected shape(total: number, raw: string[], needed: number): HubBufferData<T> {
|
|
184
|
+
const entries: T[] = [];
|
|
185
|
+
for (let i = Math.min(raw.length, needed) - 1; i >= 0; i--) {
|
|
186
|
+
try {
|
|
187
|
+
entries.push({ ...JSON.parse(raw[i]), seq: total - 1 - i } as T);
|
|
188
|
+
} catch {
|
|
189
|
+
// A single unparseable element must not blank the whole panel.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
cursor: total - 1,
|
|
194
|
+
dropped: raw.length ? total - raw.length : -1,
|
|
195
|
+
entries,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Inject, Injectable } from '@nestjs/common';
|
|
1
|
+
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
2
2
|
|
|
3
3
|
import { redactSensitiveText } from '../../../common/helpers/logging.helper';
|
|
4
|
+
import { CoreRedisService } from '../../../common/services/core-redis.service';
|
|
4
5
|
import { HUB_CONFIG } from '../hub.constants';
|
|
5
|
-
import {
|
|
6
|
+
import { HubBuffer } from '../hub-buffer';
|
|
6
7
|
import { HubMailboxData, HubMailboxEntry } from '../interfaces/hub-panels.interface';
|
|
7
8
|
import { IHubCapturedMailInput, IHubEmailCapture, ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
8
9
|
|
|
@@ -24,14 +25,17 @@ const TRUNCATION_MARKER = '… [truncated]';
|
|
|
24
25
|
*/
|
|
25
26
|
@Injectable()
|
|
26
27
|
export class CoreHubMailboxService implements IHubEmailCapture {
|
|
27
|
-
protected readonly buffer:
|
|
28
|
+
protected readonly buffer: HubBuffer<StoredMail>;
|
|
28
29
|
protected readonly mode: 'capture' | 'copy';
|
|
29
30
|
|
|
30
|
-
constructor(
|
|
31
|
+
constructor(
|
|
32
|
+
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
33
|
+
@Optional() protected readonly redis?: CoreRedisService,
|
|
34
|
+
) {
|
|
31
35
|
const mailbox =
|
|
32
36
|
config.mailbox === false ? { capacity: 100, maxMailSize: 262144, mode: 'capture' as const } : config.mailbox;
|
|
33
37
|
this.mode = mailbox.mode;
|
|
34
|
-
this.buffer = new
|
|
38
|
+
this.buffer = new HubBuffer<StoredMail>(mailbox.capacity, 'mailbox', redis);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** Record a mail. Returns true when the caller should SKIP the transport (capture mode). */
|
|
@@ -40,7 +44,13 @@ export class CoreHubMailboxService implements IHubEmailCapture {
|
|
|
40
44
|
return this.mode === 'capture';
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Convenience for tests/actions: capture and return the assigned seq.
|
|
49
|
+
*
|
|
50
|
+
* The returned seq is the PROCESS-LOCAL one. When the mailbox is Redis-backed, the seq the
|
|
51
|
+
* panels (and {@link CoreHubMailboxService.getMailHtml}) use comes from the shared counter and
|
|
52
|
+
* will differ — read it from `getMailbox()` instead of assuming this value.
|
|
53
|
+
*/
|
|
44
54
|
captureAndGetSeq(mail: IHubCapturedMailInput): number {
|
|
45
55
|
return this.store(mail).seq;
|
|
46
56
|
}
|
|
@@ -51,8 +61,8 @@ export class CoreHubMailboxService implements IHubEmailCapture {
|
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
/** Stored HTML (or text fallback) for a mail, for the sandboxed preview iframe. */
|
|
54
|
-
getMailHtml(seq: number): string | undefined {
|
|
55
|
-
const mail = this.buffer.
|
|
64
|
+
async getMailHtml(seq: number): Promise<string | undefined> {
|
|
65
|
+
const mail = (await this.buffer.read()).entries.find((m) => m.seq === seq);
|
|
56
66
|
if (!mail) {
|
|
57
67
|
return undefined;
|
|
58
68
|
}
|
|
@@ -60,11 +70,11 @@ export class CoreHubMailboxService implements IHubEmailCapture {
|
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** Mailbox listing (metadata only), oldest→newest, optionally since a cursor. */
|
|
63
|
-
getMailbox(since?: number): HubMailboxData {
|
|
64
|
-
const
|
|
73
|
+
async getMailbox(since?: number): Promise<HubMailboxData> {
|
|
74
|
+
const { cursor, dropped, entries } = await this.buffer.read(since);
|
|
65
75
|
return {
|
|
66
|
-
cursor
|
|
67
|
-
dropped
|
|
76
|
+
cursor,
|
|
77
|
+
dropped,
|
|
68
78
|
mails: entries.map(toEntry),
|
|
69
79
|
mode: this.mode,
|
|
70
80
|
};
|