@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,139 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
5
|
+
|
|
6
|
+
/** Release callback handed to the current lock holder when someone else wants the resource */
|
|
7
|
+
type RequestRelease = () => Promise<void> | void;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* TUS lock that is exclusive across REPLICAS, not just within one process.
|
|
11
|
+
*
|
|
12
|
+
* `@tus/server` defaults to an in-memory locker, which is exactly right for one process and
|
|
13
|
+
* useless behind a load balancer: two replicas each hold their own lock for the same upload id
|
|
14
|
+
* and both accept a PATCH, so two byte ranges are written to one upload and the file ends up
|
|
15
|
+
* interleaved. Resumable uploads are long-lived and clients retry aggressively, so a request
|
|
16
|
+
* pair landing on different replicas is the normal case, not a rare one.
|
|
17
|
+
*
|
|
18
|
+
* The lock is a Redis key with a TTL held only while a request is being served, refreshed by a
|
|
19
|
+
* heartbeat so a slow but live PATCH keeps it, and released in `unlock()`. A holder that dies
|
|
20
|
+
* mid-request stops refreshing and the key expires — the upload becomes writable again instead
|
|
21
|
+
* of being stuck until someone intervenes.
|
|
22
|
+
*/
|
|
23
|
+
export class TusRedisLock {
|
|
24
|
+
protected heartbeat?: NodeJS.Timeout;
|
|
25
|
+
|
|
26
|
+
/** Random per-acquisition value, so only the actual holder can release or refresh the lock */
|
|
27
|
+
protected token?: string;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
protected readonly redisService: CoreRedisService,
|
|
31
|
+
protected readonly key: string,
|
|
32
|
+
protected readonly ttlMs: number,
|
|
33
|
+
protected readonly acquireTimeoutMs: number,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wait for exclusive access to the upload, or throw once the timeout is reached.
|
|
38
|
+
*
|
|
39
|
+
* `cancelReq` is the TUS mechanism for cooperative handover: when a newer request wants an
|
|
40
|
+
* upload we already hold, the holder is asked to let go rather than made to wait it out.
|
|
41
|
+
*/
|
|
42
|
+
async lock(signal: AbortSignal, cancelReq: RequestRelease): Promise<void> {
|
|
43
|
+
const deadline = Date.now() + this.acquireTimeoutMs;
|
|
44
|
+
// randomUUID, not Math.random: this token is the ONLY thing separating "holds the lock" from
|
|
45
|
+
// "does not" in the compare-and-delete below and in the heartbeat's PEXPIRE. A collision lets
|
|
46
|
+
// one holder refresh or release another's lock, and two replicas then interleave byte ranges
|
|
47
|
+
// into one upload — the exact corruption this locker exists to prevent.
|
|
48
|
+
const token = `${process.pid}-${randomUUID()}`;
|
|
49
|
+
|
|
50
|
+
for (;;) {
|
|
51
|
+
const acquired = await this.redisService.getClient().set(this.key, token, 'PX', this.ttlMs, 'NX');
|
|
52
|
+
|
|
53
|
+
if (acquired === 'OK') {
|
|
54
|
+
this.token = token;
|
|
55
|
+
this.startHeartbeat();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Tell whoever holds it that someone else is waiting. Signalling on every attempt is
|
|
60
|
+
// deliberate: the holder may be on another replica and only learns of us through this.
|
|
61
|
+
await cancelReq();
|
|
62
|
+
|
|
63
|
+
if (signal.aborted || Date.now() > deadline) {
|
|
64
|
+
throw new Error(`Could not acquire the upload lock for "${this.key}" within ${this.acquireTimeoutMs}ms`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Release the lock — only if we still hold it */
|
|
72
|
+
async unlock(): Promise<void> {
|
|
73
|
+
this.stopHeartbeat();
|
|
74
|
+
const token = this.token;
|
|
75
|
+
this.token = undefined;
|
|
76
|
+
if (!token) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Compare-and-delete: after our TTL expired the key may already belong to someone else,
|
|
81
|
+
// and deleting it blindly would unlock an upload another replica is actively writing.
|
|
82
|
+
const script = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0`;
|
|
83
|
+
try {
|
|
84
|
+
await this.redisService.getClient().eval(script, 1, this.key, token);
|
|
85
|
+
} catch {
|
|
86
|
+
// The key expires on its own; a failed release costs at most one TTL of waiting.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Keep the lock alive while the request is still being served */
|
|
91
|
+
protected startHeartbeat(): void {
|
|
92
|
+
this.heartbeat = setInterval(
|
|
93
|
+
() => {
|
|
94
|
+
const token = this.token;
|
|
95
|
+
if (!token) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const script = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0`;
|
|
99
|
+
this.redisService
|
|
100
|
+
.getClient()
|
|
101
|
+
.eval(script, 1, this.key, token, this.ttlMs)
|
|
102
|
+
.catch(() => undefined);
|
|
103
|
+
},
|
|
104
|
+
Math.max(1000, Math.floor(this.ttlMs / 3)),
|
|
105
|
+
);
|
|
106
|
+
this.heartbeat.unref?.();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
protected stopHeartbeat(): void {
|
|
110
|
+
if (this.heartbeat) {
|
|
111
|
+
clearInterval(this.heartbeat);
|
|
112
|
+
this.heartbeat = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Locker handed to `@tus/server` when Redis is configured (see {@link TusRedisLock}).
|
|
119
|
+
*/
|
|
120
|
+
export class TusRedisLocker {
|
|
121
|
+
protected readonly logger = new Logger(TusRedisLocker.name);
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
protected readonly redisService: CoreRedisService,
|
|
125
|
+
/** How long a lock survives without a heartbeat — a dead holder's upload frees itself */
|
|
126
|
+
protected readonly ttlMs = 30_000,
|
|
127
|
+
/** How long a request waits for a busy upload before giving up with 500 */
|
|
128
|
+
protected readonly acquireTimeoutMs = 10_000,
|
|
129
|
+
) {}
|
|
130
|
+
|
|
131
|
+
newLock(id: string): TusRedisLock {
|
|
132
|
+
return new TusRedisLock(
|
|
133
|
+
this.redisService,
|
|
134
|
+
this.redisService.key('tus-lock', id),
|
|
135
|
+
this.ttlMs,
|
|
136
|
+
this.acquireTimeoutMs,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -3,10 +3,13 @@ import { getConnectionToken } from '@nestjs/mongoose';
|
|
|
3
3
|
import { Connection } from 'mongoose';
|
|
4
4
|
|
|
5
5
|
import { ITusConfig } from '../../common/interfaces/server-options.interface';
|
|
6
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
7
|
+
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
8
|
+
import { CoreS3Service } from '../../common/services/core-s3.service';
|
|
6
9
|
import { CoreTusController } from './core-tus.controller';
|
|
7
10
|
import { CoreTusService } from './core-tus.service';
|
|
8
11
|
import { TUS_CONFIG } from './tus.constants';
|
|
9
|
-
import { normalizeTusConfig } from './interfaces/tus-config.interface';
|
|
12
|
+
import { DEFAULT_TUS_CONFIG, normalizeTusConfig } from './interfaces/tus-config.interface';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* @deprecated Import from `./tus.constants` instead. Re-exported only so existing deep imports keep
|
|
@@ -111,6 +114,42 @@ export class TusModule implements OnModuleInit {
|
|
|
111
114
|
return this.customController || CoreTusController;
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Write the configured roles onto the tus handlers.
|
|
119
|
+
*
|
|
120
|
+
* An empty array is rejected rather than honoured: the guards read an
|
|
121
|
+
* all-empty role set as "no roles required" and return true, so `roles: []`
|
|
122
|
+
* would OPEN the endpoints instead of closing them.
|
|
123
|
+
*/
|
|
124
|
+
private static applyRoles(controller: Type<CoreTusController>, roles?: string[]): void {
|
|
125
|
+
if (
|
|
126
|
+
roles !== undefined &&
|
|
127
|
+
(!Array.isArray(roles) || roles.length === 0 || roles.some((r) => typeof r !== 'string'))
|
|
128
|
+
) {
|
|
129
|
+
this.logger.warn(
|
|
130
|
+
`Ignoring tus.roles: expected a non-empty array of role strings, got ${JSON.stringify(roles)}. ` +
|
|
131
|
+
`Falling back to ${JSON.stringify(DEFAULT_TUS_CONFIG.roles)}.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const effective =
|
|
136
|
+
Array.isArray(roles) && roles.length > 0 && roles.every((r) => typeof r === 'string')
|
|
137
|
+
? roles
|
|
138
|
+
: DEFAULT_TUS_CONFIG.roles;
|
|
139
|
+
|
|
140
|
+
Reflect.defineMetadata('roles', effective, controller);
|
|
141
|
+
// NOTE: handleTusOptions / handleTusOptionsWithId are deliberately absent.
|
|
142
|
+
// They answer the CORS preflight, which a browser sends WITHOUT credentials,
|
|
143
|
+
// so gating them would break every browser upload. They expose capabilities
|
|
144
|
+
// only, never upload data — see CoreTusController.
|
|
145
|
+
for (const member of ['handleTus', 'handleTusWithId']) {
|
|
146
|
+
const handler = (controller.prototype as Record<string, unknown>)[member];
|
|
147
|
+
if (typeof handler === 'function') {
|
|
148
|
+
Reflect.defineMetadata('roles', effective, handler);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
114
153
|
/**
|
|
115
154
|
* Creates a dynamic module for TUS uploads
|
|
116
155
|
*
|
|
@@ -156,6 +195,25 @@ export class TusModule implements OnModuleInit {
|
|
|
156
195
|
// Enable TUS
|
|
157
196
|
this.tusEnabled = true;
|
|
158
197
|
|
|
198
|
+
// Apply the configured roles to the controller that will actually be
|
|
199
|
+
// registered. Same mechanism as CorePermissionsModule: the value is only
|
|
200
|
+
// known at runtime, and the guards read exactly this metadata key.
|
|
201
|
+
//
|
|
202
|
+
// A custom controller is covered whether it inherits the handlers or
|
|
203
|
+
// re-declares them. Re-declaring @All()/@Roles() does NOT opt out: this
|
|
204
|
+
// writes onto `controller.prototype[member]`, which for an override resolves
|
|
205
|
+
// to the SUBCLASS's own function, and forRoot() runs after decorator
|
|
206
|
+
// evaluation — so config wins either way.
|
|
207
|
+
//
|
|
208
|
+
// That differs from the file module on purpose. `applyFileRoles()` targets
|
|
209
|
+
// the BASE class by name, so a subclass override there genuinely does keep
|
|
210
|
+
// its own metadata. Do not reason from one to the other.
|
|
211
|
+
//
|
|
212
|
+
// The real opt-outs here are: give the handler a different name (this only
|
|
213
|
+
// touches the members it knows), or register the controller outside
|
|
214
|
+
// TusModule entirely.
|
|
215
|
+
this.applyRoles(this.getControllerClass(), config.roles);
|
|
216
|
+
|
|
159
217
|
return {
|
|
160
218
|
controllers: [this.getControllerClass()],
|
|
161
219
|
exports: [TUS_CONFIG, CoreTusService],
|
|
@@ -166,13 +224,27 @@ export class TusModule implements OnModuleInit {
|
|
|
166
224
|
useValue: config,
|
|
167
225
|
},
|
|
168
226
|
{
|
|
169
|
-
inject: [
|
|
227
|
+
inject: [
|
|
228
|
+
getConnectionToken(),
|
|
229
|
+
TUS_CONFIG,
|
|
230
|
+
ConfigService,
|
|
231
|
+
{ optional: true, token: CoreS3Service },
|
|
232
|
+
{ optional: true, token: CoreRedisService },
|
|
233
|
+
],
|
|
170
234
|
provide: CoreTusService,
|
|
171
|
-
useFactory: async (
|
|
172
|
-
|
|
235
|
+
useFactory: async (
|
|
236
|
+
connection: Connection,
|
|
237
|
+
tusConfig: ITusConfig,
|
|
238
|
+
configService: ConfigService,
|
|
239
|
+
s3Service?: CoreS3Service,
|
|
240
|
+
redisService?: CoreRedisService,
|
|
241
|
+
) => {
|
|
242
|
+
const service = new CoreTusService(connection, { configService, redisService, s3Service });
|
|
173
243
|
service.configure(tusConfig);
|
|
174
|
-
//
|
|
175
|
-
|
|
244
|
+
// NestJS DOES call onModuleInit on a factory-provided instance — its hook iterates
|
|
245
|
+
// every non-alias provider, however it was constructed. Calling it here as well ran
|
|
246
|
+
// init TWICE per boot: two TUS servers, two S3 stores, and two hourly expiration
|
|
247
|
+
// intervals of which onModuleDestroy clears only the second.
|
|
176
248
|
return service;
|
|
177
249
|
},
|
|
178
250
|
},
|
package/src/core.module.ts
CHANGED
|
@@ -33,6 +33,10 @@ import { mongoosePasswordPlugin } from './core/common/plugins/mongoose-password.
|
|
|
33
33
|
import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-guard.plugin';
|
|
34
34
|
import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
|
|
35
35
|
import { ConfigService } from './core/common/services/config.service';
|
|
36
|
+
import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
|
|
37
|
+
import { CoreRedisService } from './core/common/services/core-redis.service';
|
|
38
|
+
import { CoreS3Service } from './core/common/services/core-s3.service';
|
|
39
|
+
import { CoreTrustProxyInitializer } from './core/common/services/core-trust-proxy.initializer';
|
|
36
40
|
import { EmailService } from './core/common/services/email.service';
|
|
37
41
|
import { MailjetService } from './core/common/services/mailjet.service';
|
|
38
42
|
import { ModelDocService } from './core/common/services/model-doc.service';
|
|
@@ -42,6 +46,7 @@ import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better
|
|
|
42
46
|
import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
|
|
43
47
|
import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
|
|
44
48
|
import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
|
|
49
|
+
import { applyFileRoles } from './core/modules/file/file-roles.helper';
|
|
45
50
|
import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
|
|
46
51
|
import { CoreHubModule } from './core/modules/hub/core-hub.module';
|
|
47
52
|
import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
|
|
@@ -259,6 +264,15 @@ export class CoreModule implements NestModule {
|
|
|
259
264
|
return connection;
|
|
260
265
|
};
|
|
261
266
|
|
|
267
|
+
// Apply the configured roles to the core file endpoints.
|
|
268
|
+
//
|
|
269
|
+
// The file module ships abstract classes only — there is no CoreFileModule
|
|
270
|
+
// with its own forRoot() — so this is the one place that runs early enough
|
|
271
|
+
// to rewrite the role metadata before Nest builds its route table.
|
|
272
|
+
// Consumers that OVERRIDE a member re-declare the metadata on their own
|
|
273
|
+
// function and opt out of this; see applyFileRoles() for why.
|
|
274
|
+
applyFileRoles(config.file);
|
|
275
|
+
|
|
262
276
|
// Check secrets
|
|
263
277
|
const jwtConfig = config.jwt;
|
|
264
278
|
if (jwtConfig?.secret && jwtConfig.secret && jwtConfig.refresh && jwtConfig.refresh.secret === jwtConfig.secret) {
|
|
@@ -274,6 +288,10 @@ export class CoreModule implements NestModule {
|
|
|
274
288
|
},
|
|
275
289
|
|
|
276
290
|
// Core Services
|
|
291
|
+
CoreCronJobsInitializer,
|
|
292
|
+
CoreRedisService,
|
|
293
|
+
CoreS3Service,
|
|
294
|
+
CoreTrustProxyInitializer,
|
|
277
295
|
EmailService,
|
|
278
296
|
TemplateService,
|
|
279
297
|
MailjetService,
|
|
@@ -530,7 +548,14 @@ export class CoreModule implements NestModule {
|
|
|
530
548
|
}
|
|
531
549
|
|
|
532
550
|
// Set exports
|
|
533
|
-
const exports: any[] = [
|
|
551
|
+
const exports: any[] = [
|
|
552
|
+
ConfigService,
|
|
553
|
+
CoreRedisService,
|
|
554
|
+
CoreS3Service,
|
|
555
|
+
EmailService,
|
|
556
|
+
TemplateService,
|
|
557
|
+
MailjetService,
|
|
558
|
+
];
|
|
534
559
|
if (!process.env.VITEST && isGraphQlEnabled) {
|
|
535
560
|
exports.push(ComplexityPlugin);
|
|
536
561
|
}
|
package/src/index.ts
CHANGED
|
@@ -30,8 +30,10 @@ export * from './core/common/exceptions/access-denied.exception';
|
|
|
30
30
|
export * from './core/common/filters/http-exception-log.filter';
|
|
31
31
|
export * from './core/common/helpers/common.helper';
|
|
32
32
|
export * from './core/common/helpers/config.helper';
|
|
33
|
+
export * from './core/common/helpers/content-disposition.helper';
|
|
33
34
|
export * from './core/common/helpers/context.helper';
|
|
34
35
|
export * from './core/common/helpers/cookies.helper';
|
|
36
|
+
export * from './core/common/helpers/graceful-shutdown.helper';
|
|
35
37
|
export * from './core/common/helpers/db.helper';
|
|
36
38
|
export * from './core/common/helpers/decorator.helper';
|
|
37
39
|
export * from './core/common/helpers/file.helper';
|
|
@@ -86,7 +88,12 @@ export * from './core/common/scalars/date.scalar';
|
|
|
86
88
|
export * from './core/common/scalars/json.scalar';
|
|
87
89
|
export * from './core/common/services/brevo.service';
|
|
88
90
|
export * from './core/common/services/config.service';
|
|
91
|
+
export * from './core/common/services/core-cron-jobs.registry';
|
|
89
92
|
export * from './core/common/services/core-cron-jobs.service';
|
|
93
|
+
export * from './core/common/services/core-redis.service';
|
|
94
|
+
export * from './core/common/services/core-redis-pubsub';
|
|
95
|
+
export * from './core/common/services/core-s3.service';
|
|
96
|
+
export * from './core/common/services/rate-limit-store';
|
|
90
97
|
export * from './core/common/services/crud.service';
|
|
91
98
|
export * from './core/common/services/email.service';
|
|
92
99
|
export * from './core/common/services/mailjet.service';
|
|
@@ -169,6 +176,9 @@ export * from './core/modules/file/core-file-info.model';
|
|
|
169
176
|
export * from './core/modules/file/core-file.controller';
|
|
170
177
|
export * from './core/modules/file/core-file.resolver';
|
|
171
178
|
export * from './core/modules/file/core-file.service';
|
|
179
|
+
export * from './core/modules/file/file-roles.helper';
|
|
180
|
+
export * from './core/modules/file/file-storage.helper';
|
|
181
|
+
export * from './core/modules/file/filesystem-file.helper';
|
|
172
182
|
export * from './core/modules/file/interfaces/file-service-options.interface';
|
|
173
183
|
export * from './core/modules/file/interfaces/file-upload.interface';
|
|
174
184
|
|
package/src/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import envConfig from './config.env';
|
|
|
9
9
|
import { FilterArgs } from './core/common/args/filter.args';
|
|
10
10
|
import { buildCorsConfig, isCookiesEnabled, isCorsDisabled } from './core/common/helpers/cookies.helper';
|
|
11
11
|
import { HttpExceptionLogFilter } from './core/common/filters/http-exception-log.filter';
|
|
12
|
+
import { installGracefulShutdown } from './core/common/helpers/graceful-shutdown.helper';
|
|
12
13
|
import { handleFatalBootstrapError, installProcessDiagnostics } from './core/common/helpers/process-diagnostics.helper';
|
|
13
14
|
import { CorePersistenceModel } from './core/common/models/core-persistence.model';
|
|
14
15
|
import { CoreAuthModel } from './core/modules/auth/core-auth.model';
|
|
@@ -121,7 +122,12 @@ async function bootstrap() {
|
|
|
121
122
|
// and `docker stop` waits out its full grace period before SIGKILL — dropping in-flight requests
|
|
122
123
|
// and skipping every onModuleDestroy(). enableShutdownHooks() is what closes the app and drains
|
|
123
124
|
// the loop; installProcessDiagnostics() then correctly defers to it instead of re-raising.
|
|
124
|
-
|
|
125
|
+
//
|
|
126
|
+
// installGracefulShutdown() IS enableShutdownHooks() when no `shutdownDelayMs` is configured.
|
|
127
|
+
// With one, it additionally keeps the instance fully healthy for that long before closing —
|
|
128
|
+
// which a NestJS lifecycle hook cannot do, because every one of them already runs inside
|
|
129
|
+
// close(), after the modules are destroyed. See the helper for the ordering.
|
|
130
|
+
installGracefulShutdown(server);
|
|
125
131
|
|
|
126
132
|
// Start server on configured port
|
|
127
133
|
await server.listen(envConfig.port, envConfig.hostname);
|
|
@@ -20,9 +20,13 @@ import { FileService } from './file.service';
|
|
|
20
20
|
/**
|
|
21
21
|
* File controller
|
|
22
22
|
*
|
|
23
|
-
* Extends CoreFileController
|
|
24
|
-
* - GET /files/id/:id
|
|
25
|
-
* - GET /files/:filename
|
|
23
|
+
* Extends CoreFileController and INHERITS its download endpoints unchanged:
|
|
24
|
+
* - GET /files/id/:id - Download file by ID
|
|
25
|
+
* - GET /files/:filename - Download file by filename
|
|
26
|
+
*
|
|
27
|
+
* Both are gated by `file.downloadRoles` (default ADMIN). They are deliberately
|
|
28
|
+
* not overridden here: role metadata lives on the function object, so an
|
|
29
|
+
* override would carry its own and silently opt the route out of that config.
|
|
26
30
|
*
|
|
27
31
|
* Adds admin-only endpoints:
|
|
28
32
|
* - POST /files/upload - Upload file (admin)
|
|
@@ -68,7 +72,11 @@ export class FileController extends CoreFileController {
|
|
|
68
72
|
@Get('info/:id')
|
|
69
73
|
@Roles(RoleEnum.ADMIN)
|
|
70
74
|
async getFileInfo(@Param('id') id: string) {
|
|
71
|
-
|
|
75
|
+
// `force`: this route is @Roles(ADMIN) — the guard has already decided, and an overridden
|
|
76
|
+
// checkRights() must not be asked to re-derive that from an absent user. "No currentUser"
|
|
77
|
+
// is also what an anonymous request looks like, so a rule that reads it as "internal call"
|
|
78
|
+
// fails open; saying `force: true` states the intent instead of hiding it in an omission.
|
|
79
|
+
return await this.fileService.getFileInfo(id, { force: true });
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/**
|
|
@@ -81,6 +89,7 @@ export class FileController extends CoreFileController {
|
|
|
81
89
|
throw new BadRequestException('Missing ID');
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
|
|
92
|
+
// `force`: @Roles(ADMIN) above is the whole gate for this endpoint — see getFileInfo().
|
|
93
|
+
return await this.fileService.deleteFile(id, { force: true });
|
|
85
94
|
}
|
|
86
95
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
|
2
|
-
import { createWriteStream } from 'fs';
|
|
3
|
-
import fs = require('fs');
|
|
4
2
|
import GraphQLUpload = require('graphql-upload/GraphQLUpload.js');
|
|
5
3
|
|
|
6
4
|
import { Roles } from '../../../core/common/decorators/roles.decorator';
|
|
@@ -30,7 +28,10 @@ export class FileResolver {
|
|
|
30
28
|
@Query(() => FileInfo, { nullable: true })
|
|
31
29
|
@Roles(RoleEnum.ADMIN)
|
|
32
30
|
async getFileInfo(@Args({ name: 'filename', type: () => String }) filename: string) {
|
|
33
|
-
|
|
31
|
+
// `force`: @Roles(ADMIN) is the whole gate for this admin API. Omitting options instead
|
|
32
|
+
// would leave an overridden checkRights() to guess "internal call" from an absent user —
|
|
33
|
+
// indistinguishable from an anonymous request, and therefore the wrong thing to allow on.
|
|
34
|
+
return await this.fileService.getFileInfoByName(filename, { force: true });
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// ===========================================================================
|
|
@@ -43,7 +44,8 @@ export class FileResolver {
|
|
|
43
44
|
@Mutation(() => FileInfo)
|
|
44
45
|
@Roles(RoleEnum.ADMIN)
|
|
45
46
|
async deleteFile(@Args({ name: 'filename', type: () => String }) filename: string) {
|
|
46
|
-
|
|
47
|
+
// `force`: @Roles(ADMIN) is the whole gate here — see getFileInfo().
|
|
48
|
+
return await this.fileService.deleteFileByName(filename, { force: true });
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -61,21 +63,11 @@ export class FileResolver {
|
|
|
61
63
|
@Mutation(() => Boolean)
|
|
62
64
|
@Roles(RoleEnum.ADMIN)
|
|
63
65
|
async uploadFiles(@Args({ name: 'files', type: () => [GraphQLUpload] }) files: FileUpload[]) {
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
promises.push(
|
|
70
|
-
new Promise((resolve, reject) =>
|
|
71
|
-
createReadStream()
|
|
72
|
-
.pipe(createWriteStream(`./uploads/${filename}`))
|
|
73
|
-
.on('finish', () => resolve(true))
|
|
74
|
-
.on('error', (error) => reject(error)),
|
|
75
|
-
),
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
await Promise.allSettled(promises);
|
|
66
|
+
// Store in the central file storage (GridFS/S3), never on the pod's own disk:
|
|
67
|
+
// `./uploads` is relative to the process working directory, so with more than
|
|
68
|
+
// one replica the file lands wherever the request happened to be routed and is
|
|
69
|
+
// unreadable everywhere else — and gone after a restart.
|
|
70
|
+
await this.fileService.createFiles(files);
|
|
79
71
|
return true;
|
|
80
72
|
}
|
|
81
73
|
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
1
|
+
import { Injectable, Optional } from '@nestjs/common';
|
|
2
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
3
|
import { Connection } from 'mongoose';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { RoleEnum } from '../../../core/common/enums/role.enum';
|
|
6
|
+
import { ConfigService } from '../../../core/common/services/config.service';
|
|
7
|
+
import { CoreS3Service } from '../../../core/common/services/core-s3.service';
|
|
8
|
+
import { CoreFileService, FileInputCheckType } from '../../../core/modules/file/core-file.service';
|
|
9
|
+
import { FileServiceOptions } from '../../../core/modules/file/interfaces/file-service-options.interface';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* File service
|
|
9
13
|
*/
|
|
10
14
|
@Injectable()
|
|
11
15
|
export class FileService extends CoreFileService {
|
|
12
|
-
constructor(
|
|
13
|
-
|
|
16
|
+
constructor(
|
|
17
|
+
@InjectConnection() protected override readonly connection: Connection,
|
|
18
|
+
protected readonly configService: ConfigService,
|
|
19
|
+
@Optional() protected readonly s3Service?: CoreS3Service,
|
|
20
|
+
) {
|
|
21
|
+
super(connection, 'fs', { configService, s3Service });
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
/**
|
|
@@ -19,4 +27,81 @@ export class FileService extends CoreFileService {
|
|
|
19
27
|
async duplicate(fileName: string, newName: string): Promise<any> {
|
|
20
28
|
return this.files.openDownloadStreamByName(fileName).pipe(this.files.openUploadStream(newName));
|
|
21
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-file authorization for the two inherited download routes.
|
|
33
|
+
*
|
|
34
|
+
* THIS IS DELIBERATELY EXECUTED CODE, NOT AN ILLUSTRATION. It used to be a
|
|
35
|
+
* commented-out `@example` here, on the reasoning that `file.downloadRoles`
|
|
36
|
+
* defaults to `[ADMIN]`, the roles guard therefore answers before the service
|
|
37
|
+
* is reached, and a rule that cannot fire is worse than none. The reasoning was
|
|
38
|
+
* locally sound and globally harmful: a comment is never compiled, never
|
|
39
|
+
* type-checked and never run, so nothing in this repository exercised the
|
|
40
|
+
* `Core*` inheritance seam that every consuming project depends on — and a
|
|
41
|
+
* `deleteFileByName()` bug that dropped `serviceOptions` on the way to its
|
|
42
|
+
* inner lookup shipped green through 2777 tests, to be found downstream hours
|
|
43
|
+
* after release. The commented example was itself wrong (it allowed on a
|
|
44
|
+
* missing `currentUser`), and it was copied verbatim.
|
|
45
|
+
*
|
|
46
|
+
* The tension is resolved the other way round now: `config.env.ts` widens the
|
|
47
|
+
* coarse gate to `[RoleEnum.S_USER]` in every environment, which is what real
|
|
48
|
+
* projects do, so the rule below actually runs on every download this server
|
|
49
|
+
* serves.
|
|
50
|
+
*
|
|
51
|
+
* The rule: ADMIN sees everything; everyone else sees only files whose
|
|
52
|
+
* `metadata.ownerId` is their own id. `AvatarController` writes that metadata
|
|
53
|
+
* at upload time. A file with NO owner recorded — the admin uploads via
|
|
54
|
+
* `/files/upload` and the GraphQL mutations, and TUS uploads, which carry
|
|
55
|
+
* `tusUploadId` but no owner — is therefore ADMIN-only.
|
|
56
|
+
*
|
|
57
|
+
* Two properties worth knowing:
|
|
58
|
+
*
|
|
59
|
+
* - **A refusal answers 404, not 403.** That is the framework's doing, not
|
|
60
|
+
* this method's: returning `false` makes the caller answer as if the file
|
|
61
|
+
* did not exist, because a 403 would confirm that the id names a real file
|
|
62
|
+
* and turn the endpoint into an existence oracle.
|
|
63
|
+
* - **A missing `currentUser` DENIES.** This is the one part that is easy to
|
|
64
|
+
* get backwards. "No user in context" is NOT "system-internal call" — it is
|
|
65
|
+
* also exactly what an ANONYMOUS request looks like. Today the coarse gate
|
|
66
|
+
* turns those away before this hook runs, so an `if (!options.currentUser)
|
|
67
|
+
* return true` shortcut looks harmless; widen `downloadRoles` to
|
|
68
|
+
* `S_EVERYONE` and the same branch hands every file to everyone, so the
|
|
69
|
+
* ownership rule evaporates precisely when it starts to matter. Genuinely
|
|
70
|
+
* internal callers say so instead: `FileController` / `FileResolver` pass
|
|
71
|
+
* `{ force: true }` because their `@Roles(ADMIN)` already decided, and
|
|
72
|
+
* `AvatarController` passes the real `{ currentUser }` so its cleanup delete
|
|
73
|
+
* is COVERED by this rule rather than exempt from it.
|
|
74
|
+
*
|
|
75
|
+
* BOTH the `id` and the `filename` branch are covered. Covering only `id` is
|
|
76
|
+
* enough while bytes are streamed, because the filename route resolves an id
|
|
77
|
+
* and checks it again — but not once `s3.presignedDownloads` is enabled, where
|
|
78
|
+
* the filename route authorizes on the by-name lookup alone and then redirects.
|
|
79
|
+
* The by-name half is also where the shipped `deleteFileByName()` bug lived.
|
|
80
|
+
*
|
|
81
|
+
* See `src/core/modules/file/README.md` § Access control, and
|
|
82
|
+
* `tests/file-ownership.e2e-spec.ts` for the end-to-end contract test.
|
|
83
|
+
*/
|
|
84
|
+
protected override async checkRights(
|
|
85
|
+
input: any,
|
|
86
|
+
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate.
|
|
89
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The RAW document on purpose: the public getFileInfo() runs prepareOutput(), which
|
|
98
|
+
// strips `metadata` — the very field this decision rests on.
|
|
99
|
+
const raw =
|
|
100
|
+
options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
|
|
101
|
+
|
|
102
|
+
// Fails closed on a missing user: `String(undefined)` can never equal a real owner id.
|
|
103
|
+
// Requiring `metadata.ownerId` to be PRESENT is load-bearing too — without it an
|
|
104
|
+
// owner-less file would compare `String(undefined)` against `String(undefined)` and match.
|
|
105
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
106
|
+
}
|
|
22
107
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
|
1
|
+
import { Logger, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
|
2
2
|
import { Controller } from '@nestjs/common/decorators/core/controller.decorator';
|
|
3
3
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
4
4
|
|
|
5
|
-
import envConfig from '../../../config.env';
|
|
6
5
|
import { CurrentUser } from '../../../core/common/decorators/current-user.decorator';
|
|
7
6
|
import { Roles } from '../../../core/common/decorators/roles.decorator';
|
|
8
7
|
import { RoleEnum } from '../../../core/common/enums/role.enum';
|
|
9
|
-
import {
|
|
8
|
+
import { getStringIds } from '../../../core/common/helpers/db.helper';
|
|
9
|
+
import { multerFileToUpload, multerOptionsForImageUpload } from '../../../core/common/helpers/file.helper';
|
|
10
|
+
import { FileService } from '../file/file.service';
|
|
10
11
|
import { User } from './user.model';
|
|
11
12
|
import { UserService } from './user.service';
|
|
12
13
|
|
|
@@ -16,10 +17,15 @@ import { UserService } from './user.service';
|
|
|
16
17
|
@Controller('avatar')
|
|
17
18
|
@Roles(RoleEnum.ADMIN)
|
|
18
19
|
export class AvatarController {
|
|
20
|
+
protected readonly logger = new Logger(AvatarController.name);
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* Import services
|
|
21
24
|
*/
|
|
22
|
-
constructor(
|
|
25
|
+
constructor(
|
|
26
|
+
protected readonly usersService: UserService,
|
|
27
|
+
protected readonly fileService: FileService,
|
|
28
|
+
) {}
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Upload files
|
|
@@ -29,12 +35,43 @@ export class AvatarController {
|
|
|
29
35
|
@UseInterceptors(
|
|
30
36
|
FileInterceptor(
|
|
31
37
|
'file',
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
// `memory: true`, NOT a disk destination: the avatar goes into the central
|
|
39
|
+
// file storage (GridFS/S3) so every replica can serve it and a restart or
|
|
40
|
+
// reschedule does not lose it. A pod-local `staticAssets` path is readable
|
|
41
|
+
// by exactly one replica.
|
|
42
|
+
multerOptionsForImageUpload({ memory: true }),
|
|
35
43
|
),
|
|
36
44
|
)
|
|
37
|
-
uploadFile(@UploadedFile() file: Express.Multer.File, @CurrentUser() user: User): Promise<string> {
|
|
38
|
-
|
|
45
|
+
async uploadFile(@UploadedFile() file: Express.Multer.File, @CurrentUser() user: User): Promise<string> {
|
|
46
|
+
// Record the owner. `file.downloadRoles` defaults to ADMIN, so without this the
|
|
47
|
+
// uploader could not fetch their own avatar back — roles answer "may this caller
|
|
48
|
+
// reach the route", never "may this caller have THIS file". FileService.checkRights()
|
|
49
|
+
// reads this metadata to answer the second question.
|
|
50
|
+
const stored = await this.fileService.createFile(multerFileToUpload(file), {
|
|
51
|
+
metadata: { ownerId: getStringIds(user.id) },
|
|
52
|
+
});
|
|
53
|
+
const previousAvatar = await this.usersService.setAvatar(stored.id, user);
|
|
54
|
+
|
|
55
|
+
// Drop the replaced file. A failure here must not fail the upload: the new avatar
|
|
56
|
+
// is already stored and referenced, so an orphaned object is a cleanup concern,
|
|
57
|
+
// not a request error.
|
|
58
|
+
//
|
|
59
|
+
// `{ currentUser: user }`, not an empty context: the uploader IS in scope here, so a
|
|
60
|
+
// per-file rule in FileService.checkRights() should COVER this delete (the previous
|
|
61
|
+
// avatar is their own file) rather than be exempted from it. Passing nothing would ask
|
|
62
|
+
// the rule to infer "internal call" from an absent user — which is also what an
|
|
63
|
+
// anonymous request looks like, and is exactly the shortcut that makes an ownership
|
|
64
|
+
// rule evaporate once `file.downloadRoles` is widened.
|
|
65
|
+
if (previousAvatar) {
|
|
66
|
+
try {
|
|
67
|
+
await this.fileService.deleteFile(previousAvatar, { currentUser: user });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.logger.warn(
|
|
70
|
+
`Could not remove previous avatar ${previousAvatar}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return stored.id;
|
|
39
76
|
}
|
|
40
77
|
}
|