@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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { NotFoundException } from '@nestjs/common';
|
|
1
|
+
import { Logger, NotFoundException } from '@nestjs/common';
|
|
2
2
|
import mongoose, { Connection, mongo, Types } from 'mongoose';
|
|
3
|
+
import { Readable } from 'stream';
|
|
3
4
|
|
|
4
5
|
import { FilterArgs } from '../../common/args/filter.args';
|
|
5
6
|
import { getObjectIds, getStringIds } from '../../common/helpers/db.helper';
|
|
@@ -7,16 +8,43 @@ import { convertFilterArgsToQuery } from '../../common/helpers/filter.helper';
|
|
|
7
8
|
import { GridFSHelper } from '../../common/helpers/gridfs.helper';
|
|
8
9
|
import { check } from '../../common/helpers/input.helper';
|
|
9
10
|
import { prepareOutput } from '../../common/helpers/service.helper';
|
|
11
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
12
|
+
import { CoreS3Service } from '../../common/services/core-s3.service';
|
|
10
13
|
import { MaybePromise } from '../../common/types/maybe-promise.type';
|
|
11
14
|
import { CoreFileInfo } from './core-file-info.model';
|
|
12
15
|
import { FileServiceOptions } from './interfaces/file-service-options.interface';
|
|
13
|
-
import {
|
|
16
|
+
import { FileUploadSource } from './interfaces/file-upload.interface';
|
|
17
|
+
import {
|
|
18
|
+
assertFileStorageAvailable,
|
|
19
|
+
FileStorageDriver,
|
|
20
|
+
FileStorageResolution,
|
|
21
|
+
logFileStorage,
|
|
22
|
+
resolveFileStorage,
|
|
23
|
+
} from './file-storage.helper';
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_FILESYSTEM_DIR,
|
|
26
|
+
FILESYSTEM_FILES_COLLECTION,
|
|
27
|
+
FilesystemFileHelper,
|
|
28
|
+
FilesystemFileInfo,
|
|
29
|
+
} from './filesystem-file.helper';
|
|
30
|
+
import { S3_FILES_COLLECTION, S3FileHelper, S3FileInfo, streamToBuffer } from './s3-file.helper';
|
|
14
31
|
|
|
15
32
|
/**
|
|
16
33
|
* Type for checking input
|
|
17
34
|
*/
|
|
18
35
|
export type FileInputCheckType = 'file' | 'filename' | 'files' | 'filterArgs' | 'id';
|
|
19
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Optional dependencies of CoreFileService.
|
|
39
|
+
*
|
|
40
|
+
* Both are required for the S3 storage driver (`file.storage: 's3'`); without
|
|
41
|
+
* them the service behaves exactly as before and stores everything in GridFS.
|
|
42
|
+
*/
|
|
43
|
+
export interface CoreFileServiceOptions {
|
|
44
|
+
configService?: ConfigService;
|
|
45
|
+
s3Service?: CoreS3Service;
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
/**
|
|
21
49
|
* Abstract core file service
|
|
22
50
|
*/
|
|
@@ -24,29 +52,126 @@ export abstract class CoreFileService {
|
|
|
24
52
|
// Use the native MongoDB driver's types (accessed via Mongoose's exports) to avoid BSON version conflicts
|
|
25
53
|
files: mongo.GridFSBucket;
|
|
26
54
|
|
|
55
|
+
/** Metadata collection for files stored in S3 (no Mongoose schema) */
|
|
56
|
+
protected s3Files: mongo.Collection<any>;
|
|
57
|
+
|
|
58
|
+
/** Metadata collection for files stored on the local filesystem (no Mongoose schema) */
|
|
59
|
+
protected filesystemFiles: mongo.Collection<any>;
|
|
60
|
+
|
|
61
|
+
protected readonly logger = new Logger(CoreFileService.name);
|
|
62
|
+
|
|
63
|
+
/** Which driver new files are written to, and why */
|
|
64
|
+
protected readonly storageResolution: FileStorageResolution;
|
|
65
|
+
|
|
27
66
|
/**
|
|
28
67
|
* Include MongoDB connection and create File bucket
|
|
29
68
|
*/
|
|
30
69
|
protected constructor(
|
|
31
70
|
protected readonly connection: Connection,
|
|
32
71
|
bucketName = 'fs',
|
|
72
|
+
protected readonly options?: CoreFileServiceOptions,
|
|
33
73
|
) {
|
|
34
74
|
// Use the native MongoDB driver's GridFSBucket via Mongoose's mongo export to avoid BSON version conflicts
|
|
35
75
|
this.files = new mongo.GridFSBucket(connection.db, { bucketName });
|
|
76
|
+
this.s3Files = connection.db.collection(S3_FILES_COLLECTION);
|
|
77
|
+
this.filesystemFiles = connection.db.collection(FILESYSTEM_FILES_COLLECTION);
|
|
78
|
+
|
|
79
|
+
// Resolve from the global config rather than the optional injected
|
|
80
|
+
// ConfigService: a project on the pre-11.33 constructor (`super(connection)`)
|
|
81
|
+
// forwards no services, and must still get a correctly derived driver.
|
|
82
|
+
this.storageResolution = resolveFileStorage(ConfigService.configFastButReadOnly);
|
|
83
|
+
assertFileStorageAvailable(this.storageResolution, this.isStorageAvailable(this.storageResolution.driver));
|
|
84
|
+
logFileStorage(this.storageResolution);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The driver new files are written to.
|
|
89
|
+
*
|
|
90
|
+
* Reads are not restricted to it: every store is consulted, so files written
|
|
91
|
+
* before a driver change stay readable and switching is forward-only with no
|
|
92
|
+
* migration step.
|
|
93
|
+
*/
|
|
94
|
+
protected get storageDriver(): FileStorageDriver {
|
|
95
|
+
return this.storageResolution.driver;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Directory used by the `'filesystem'` driver */
|
|
99
|
+
protected get filesystemDir(): string {
|
|
100
|
+
return ConfigService.configFastButReadOnly?.file?.storageDir || DEFAULT_FILESYSTEM_DIR;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether a driver can actually be used right now.
|
|
105
|
+
*
|
|
106
|
+
* GridFS and the filesystem need nothing beyond what the constructor already
|
|
107
|
+
* has (a database connection, a writable directory). S3 needs both the client
|
|
108
|
+
* library and the service wired through `super()`.
|
|
109
|
+
*/
|
|
110
|
+
protected isStorageAvailable(driver: FileStorageDriver): boolean {
|
|
111
|
+
return driver === 's3' ? !!this.options?.s3Service?.enabled : true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @deprecated Use `storageDriver === 's3'`. Kept so an override or a project
|
|
116
|
+
* that read this keeps compiling.
|
|
117
|
+
*/
|
|
118
|
+
protected get s3Storage(): boolean {
|
|
119
|
+
return this.storageDriver === 's3';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether new files go to the local filesystem */
|
|
123
|
+
protected get filesystemStorage(): boolean {
|
|
124
|
+
return this.storageDriver === 'filesystem';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Find the filesystem metadata of a file by ID (null when it is stored elsewhere)
|
|
129
|
+
*/
|
|
130
|
+
protected async findFilesystemFileById(id: string | Types.ObjectId): Promise<FilesystemFileInfo | null> {
|
|
131
|
+
return FilesystemFileHelper.findFileById(this.filesystemFiles, getObjectIds(id));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Find the filesystem metadata of a file by filename (null when it is stored elsewhere)
|
|
136
|
+
*/
|
|
137
|
+
protected async findFilesystemFileByName(filename: string): Promise<FilesystemFileInfo | null> {
|
|
138
|
+
return FilesystemFileHelper.findFileByName(this.filesystemFiles, filename);
|
|
36
139
|
}
|
|
37
140
|
|
|
38
141
|
/**
|
|
39
142
|
* Save file in DB
|
|
40
143
|
*/
|
|
41
|
-
async createFile(file: MaybePromise<
|
|
144
|
+
async createFile(file: MaybePromise<FileUploadSource>, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
|
|
42
145
|
if (!(await this.checkRights(file, { ...serviceOptions, checkInputType: 'file' }))) {
|
|
43
146
|
return null;
|
|
44
147
|
}
|
|
45
148
|
const { createReadStream, filename, mimetype } = await file;
|
|
46
149
|
const readStream = createReadStream();
|
|
150
|
+
if (this.filesystemStorage) {
|
|
151
|
+
const fsFileInfo = await FilesystemFileHelper.writeFile(this.filesystemDir, this.filesystemFiles, {
|
|
152
|
+
body: readStream,
|
|
153
|
+
contentType: mimetype,
|
|
154
|
+
filename,
|
|
155
|
+
...(serviceOptions?.metadata ? { metadata: serviceOptions.metadata } : {}),
|
|
156
|
+
});
|
|
157
|
+
return this.prepareOutput(fsFileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
158
|
+
}
|
|
159
|
+
if (this.storageDriver === 's3') {
|
|
160
|
+
// Only buffer when the size is unknown: a GraphQL upload stream carries no length, and the
|
|
161
|
+
// S3 SDK needs one. Callers that know it (TUS) pass `body` + `contentLength` instead and
|
|
162
|
+
// stream straight through — see S3FileHelper.writeFile.
|
|
163
|
+
const s3FileInfo = await S3FileHelper.writeFile(this.options.s3Service, this.s3Files, {
|
|
164
|
+
buffer: await streamToBuffer(readStream),
|
|
165
|
+
contentType: mimetype,
|
|
166
|
+
filename,
|
|
167
|
+
...(serviceOptions?.metadata ? { metadata: serviceOptions.metadata } : {}),
|
|
168
|
+
});
|
|
169
|
+
return this.prepareOutput(s3FileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
170
|
+
}
|
|
47
171
|
const fileInfo = await GridFSHelper.writeFileFromStream(this.files, readStream, {
|
|
48
172
|
contentType: mimetype,
|
|
49
173
|
filename,
|
|
174
|
+
...(serviceOptions?.metadata ? { metadata: serviceOptions.metadata } : {}),
|
|
50
175
|
});
|
|
51
176
|
return this.prepareOutput(fileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
52
177
|
}
|
|
@@ -54,7 +179,10 @@ export abstract class CoreFileService {
|
|
|
54
179
|
/**
|
|
55
180
|
* Save files in DB
|
|
56
181
|
*/
|
|
57
|
-
async createFiles(
|
|
182
|
+
async createFiles(
|
|
183
|
+
files: MaybePromise<FileUploadSource>[],
|
|
184
|
+
serviceOptions?: FileServiceOptions,
|
|
185
|
+
): Promise<CoreFileInfo[]> {
|
|
58
186
|
if (!(await this.checkRights(files, { ...serviceOptions, checkInputType: 'files' }))) {
|
|
59
187
|
return null;
|
|
60
188
|
}
|
|
@@ -69,9 +197,28 @@ export abstract class CoreFileService {
|
|
|
69
197
|
* Duplicate file by name
|
|
70
198
|
*/
|
|
71
199
|
async duplicateByName(name: string, newName: string): Promise<any> {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
200
|
+
// Route through the storage dispatch like every other read/write. Going straight to GridFS
|
|
201
|
+
// meant that with `file.storage: 's3'` the source simply is not there — and the resulting
|
|
202
|
+
// FileNotFound arrives on a stream with NO error handler, so it becomes an uncaught
|
|
203
|
+
// exception that takes the process down instead of a 404.
|
|
204
|
+
const nonGridFsSource = (await this.findS3FileByName(name)) || (await this.findFilesystemFileByName(name));
|
|
205
|
+
if (nonGridFsSource) {
|
|
206
|
+
const source = await this.getFileStreamByName(name);
|
|
207
|
+
return this.createFile({
|
|
208
|
+
createReadStream: () => source,
|
|
209
|
+
filename: newName,
|
|
210
|
+
mimetype: nonGridFsSource.contentType || 'application/octet-stream',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
const downloadStream = GridFSHelper.openDownloadStreamByName(this.files, name);
|
|
216
|
+
downloadStream.on('error', reject);
|
|
217
|
+
const uploadStream = GridFSHelper.openUploadStream(this.files, newName);
|
|
218
|
+
uploadStream.on('error', reject);
|
|
219
|
+
uploadStream.on('finish', () => resolve(uploadStream));
|
|
220
|
+
downloadStream.pipe(uploadStream);
|
|
221
|
+
});
|
|
75
222
|
}
|
|
76
223
|
|
|
77
224
|
/**
|
|
@@ -80,6 +227,19 @@ export abstract class CoreFileService {
|
|
|
80
227
|
async duplicateById(id: string): Promise<string> {
|
|
81
228
|
const objectId = getObjectIds(id);
|
|
82
229
|
const file = await this.getFileInfo(objectId);
|
|
230
|
+
|
|
231
|
+
// Same dispatch as duplicateByName: a file stored outside GridFS has no GridFS counterpart.
|
|
232
|
+
const nonGridFsSource = (await this.findS3FileById(objectId)) || (await this.findFilesystemFileById(objectId));
|
|
233
|
+
if (nonGridFsSource) {
|
|
234
|
+
const source = await this.getFileStream(objectId);
|
|
235
|
+
const copy = await this.createFile({
|
|
236
|
+
createReadStream: () => source,
|
|
237
|
+
filename: file.filename,
|
|
238
|
+
mimetype: file.contentType || 'application/octet-stream',
|
|
239
|
+
});
|
|
240
|
+
return copy.id;
|
|
241
|
+
}
|
|
242
|
+
|
|
83
243
|
return new Promise((resolve, reject) => {
|
|
84
244
|
const downloadStream = GridFSHelper.openDownloadStream(this.files, objectId);
|
|
85
245
|
|
|
@@ -112,8 +272,68 @@ export abstract class CoreFileService {
|
|
|
112
272
|
return null;
|
|
113
273
|
}
|
|
114
274
|
const filterQuery = convertFilterArgsToQuery(filterArgs);
|
|
115
|
-
|
|
116
|
-
|
|
275
|
+
if (this.storageDriver === 'gridfs') {
|
|
276
|
+
// Nothing was ever written to the other stores by this configuration, so the
|
|
277
|
+
// single-store path stays exact: no merging, no over-fetching.
|
|
278
|
+
const docs = await GridFSHelper.findFiles(this.files, filterQuery[0], filterQuery[1]);
|
|
279
|
+
return this.prepareOutput(docs as unknown as CoreFileInfo[], serviceOptions);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Several stores, one page. Applying `limit`/`skip` to each and concatenating returns up to
|
|
283
|
+
// N times the page size and advances the offset independently in each store, so rows are both
|
|
284
|
+
// duplicated and skipped — and a project that switched drivers with files already written
|
|
285
|
+
// under the previous one (an explicitly supported state) hits exactly that. So: fetch enough
|
|
286
|
+
// from each to cover skip+limit, merge, then page the merged result once.
|
|
287
|
+
const { limit, skip, ...rest } = (filterQuery[1] ?? {}) as { limit?: number; skip?: number };
|
|
288
|
+
const upperBound = limit === undefined ? undefined : (skip ?? 0) + limit;
|
|
289
|
+
const pageOptions = upperBound === undefined ? rest : { ...rest, limit: upperBound };
|
|
290
|
+
|
|
291
|
+
const [s3Docs, fsDocs, docs] = await Promise.all([
|
|
292
|
+
S3FileHelper.findFiles(this.s3Files, filterQuery[0], pageOptions),
|
|
293
|
+
FilesystemFileHelper.findFiles(this.filesystemFiles, filterQuery[0], pageOptions),
|
|
294
|
+
GridFSHelper.findFiles(this.files, filterQuery[0], pageOptions),
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
// Each store returned its own rows already sorted; concatenating them does NOT
|
|
298
|
+
// preserve that order, so without this re-sort a `sort` in the filter args held
|
|
299
|
+
// only WITHIN a store and the merged page came out interleaved by store. Paging
|
|
300
|
+
// a wrongly-ordered merge also returns the wrong rows, not just the right rows
|
|
301
|
+
// in the wrong order.
|
|
302
|
+
const merged = this.sortMergedFileInfo([...s3Docs, ...fsDocs, ...docs], (rest as { sort?: any }).sort);
|
|
303
|
+
const paged = limit === undefined ? merged.slice(skip ?? 0) : merged.slice(skip ?? 0, (skip ?? 0) + limit);
|
|
304
|
+
return this.prepareOutput(paged as unknown as CoreFileInfo[], serviceOptions);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Re-apply a Mongo-style sort spec to rows merged from several stores.
|
|
309
|
+
*
|
|
310
|
+
* Only used on the multi-store path — the single-store path never merges and
|
|
311
|
+
* keeps the database's own ordering. Falls back to `uploadDate` descending
|
|
312
|
+
* (newest first), which is the order a file listing is expected in and the one
|
|
313
|
+
* each store already returns on its own.
|
|
314
|
+
*/
|
|
315
|
+
protected sortMergedFileInfo<T extends Record<string, any>>(docs: T[], sort?: Record<string, any>): T[] {
|
|
316
|
+
const spec = Object.entries(sort ?? {}).filter(([, direction]) => direction === 1 || direction === -1);
|
|
317
|
+
const effective: [string, number][] = spec.length ? (spec as [string, number][]) : [['uploadDate', -1]];
|
|
318
|
+
return [...docs].sort((a, b) => {
|
|
319
|
+
for (const [field, direction] of effective) {
|
|
320
|
+
const left = a?.[field];
|
|
321
|
+
const right = b?.[field];
|
|
322
|
+
if (left === right) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Undefined sorts last regardless of direction — a store that does not
|
|
326
|
+
// carry the field must not win the page just because `undefined < x`.
|
|
327
|
+
if (left === undefined || left === null) {
|
|
328
|
+
return 1;
|
|
329
|
+
}
|
|
330
|
+
if (right === undefined || right === null) {
|
|
331
|
+
return -1;
|
|
332
|
+
}
|
|
333
|
+
return left < right ? -direction : direction;
|
|
334
|
+
}
|
|
335
|
+
return 0;
|
|
336
|
+
});
|
|
117
337
|
}
|
|
118
338
|
|
|
119
339
|
/**
|
|
@@ -123,7 +343,12 @@ export abstract class CoreFileService {
|
|
|
123
343
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
124
344
|
return null;
|
|
125
345
|
}
|
|
126
|
-
|
|
346
|
+
// Every store is consulted, newest driver first, so a change of `file.storage`
|
|
347
|
+
// never hides files written under the previous one.
|
|
348
|
+
const fileInfo =
|
|
349
|
+
(await this.findS3FileById(id)) ||
|
|
350
|
+
(await this.findFilesystemFileById(id)) ||
|
|
351
|
+
(await GridFSHelper.findFileById(this.files, getObjectIds(id)));
|
|
127
352
|
return this.prepareOutput(fileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
128
353
|
}
|
|
129
354
|
|
|
@@ -134,30 +359,139 @@ export abstract class CoreFileService {
|
|
|
134
359
|
if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
|
|
135
360
|
return null;
|
|
136
361
|
}
|
|
137
|
-
const fileInfo =
|
|
362
|
+
const fileInfo =
|
|
363
|
+
(await this.findS3FileByName(filename)) ||
|
|
364
|
+
(await this.findFilesystemFileByName(filename)) ||
|
|
365
|
+
(await GridFSHelper.findFileByName(this.files, filename));
|
|
138
366
|
return this.prepareOutput(fileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
139
367
|
}
|
|
140
368
|
|
|
141
369
|
/**
|
|
142
370
|
* Get file stream (for big files) via file ID
|
|
143
371
|
*/
|
|
144
|
-
async getFileStream(
|
|
372
|
+
async getFileStream(
|
|
373
|
+
id: string | Types.ObjectId,
|
|
374
|
+
serviceOptions?: FileServiceOptions,
|
|
375
|
+
knownStore?: FileStorageDriver,
|
|
376
|
+
): Promise<Readable> {
|
|
145
377
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
146
378
|
return null;
|
|
147
379
|
}
|
|
380
|
+
// `knownStore` is a pure optimization, never an authorization shortcut: the
|
|
381
|
+
// rights check above already ran. A caller that has just resolved the file
|
|
382
|
+
// (the controller does, via resolveFile) can pass which store it came from so
|
|
383
|
+
// the same one-to-three metadata round trips are not repeated per download.
|
|
384
|
+
// Omitting it keeps the previous behaviour exactly.
|
|
385
|
+
const store = knownStore ?? (await this.locateFile(id));
|
|
386
|
+
if (store === 's3') {
|
|
387
|
+
return S3FileHelper.getStream(this.options.s3Service, id);
|
|
388
|
+
}
|
|
389
|
+
if (store === 'filesystem') {
|
|
390
|
+
return FilesystemFileHelper.getStream(this.filesystemDir, id);
|
|
391
|
+
}
|
|
148
392
|
return GridFSHelper.openDownloadStream(this.files, getObjectIds(id)) as mongo.GridFSBucketReadStream;
|
|
149
393
|
}
|
|
150
394
|
|
|
151
395
|
/**
|
|
152
|
-
*
|
|
396
|
+
* Which store currently holds a file's bytes.
|
|
397
|
+
*
|
|
398
|
+
* Consults the stores in the same order as `getFileInfo()`. Answers `'gridfs'`
|
|
399
|
+
* for an unknown id, which is what the download path did before this existed:
|
|
400
|
+
* GridFS is the terminal fallback and reports the miss itself.
|
|
401
|
+
*/
|
|
402
|
+
protected async locateFile(id: string | Types.ObjectId): Promise<FileStorageDriver> {
|
|
403
|
+
if (await this.findS3FileById(id)) {
|
|
404
|
+
return 's3';
|
|
405
|
+
}
|
|
406
|
+
if (await this.findFilesystemFileById(id)) {
|
|
407
|
+
return 'filesystem';
|
|
408
|
+
}
|
|
409
|
+
return 'gridfs';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Resolve a file's metadata AND which store holds its bytes, in one pass.
|
|
414
|
+
*
|
|
415
|
+
* A download used to answer the same question up to three times — once in
|
|
416
|
+
* `getFileInfo()`, once in `getDownloadUrl()` and once in `getFileStream()` —
|
|
417
|
+
* because each of them probed the stores in turn and then discarded which one
|
|
418
|
+
* had answered. The probe order here is unchanged; what is new is that the
|
|
419
|
+
* answering store is returned alongside the metadata, so the callers stop
|
|
420
|
+
* re-probing.
|
|
421
|
+
*
|
|
422
|
+
* The store is derived from WHICH COLLECTION produced the document, never from
|
|
423
|
+
* anything the document says about itself: the collection is the only source
|
|
424
|
+
* that cannot disagree with where the bytes actually are.
|
|
425
|
+
*
|
|
426
|
+
* Runs the SAME `checkRights()` as `getFileInfo()` — it is a replacement for that
|
|
427
|
+
* call, not a way around it.
|
|
153
428
|
*/
|
|
154
|
-
async
|
|
155
|
-
|
|
429
|
+
async resolveFile(
|
|
430
|
+
id: string | Types.ObjectId,
|
|
156
431
|
serviceOptions?: FileServiceOptions,
|
|
157
|
-
): Promise<
|
|
432
|
+
): Promise<{ info: CoreFileInfo; store: FileStorageDriver } | null> {
|
|
433
|
+
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const s3Info = await this.findS3FileById(id);
|
|
438
|
+
if (s3Info) {
|
|
439
|
+
return { info: await this.prepareOutput(s3Info as unknown as CoreFileInfo, serviceOptions), store: 's3' };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const fsInfo = await this.findFilesystemFileById(id);
|
|
443
|
+
if (fsInfo) {
|
|
444
|
+
// The COLLECTION a document was found in is what says where the bytes are —
|
|
445
|
+
// same as the S3 branch above. Reading the document's own `storage` marker
|
|
446
|
+
// instead would agree in every sound case and disagree in exactly one: a
|
|
447
|
+
// document sitting in `filesystem-files` while claiming `storage: 's3'`.
|
|
448
|
+
// Believing it there hands `knownStore: 's3'` to `getFileStream()`, which
|
|
449
|
+
// then reads bytes out of the wrong store.
|
|
450
|
+
return {
|
|
451
|
+
info: await this.prepareOutput(fsInfo as unknown as CoreFileInfo, serviceOptions),
|
|
452
|
+
store: 'filesystem',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const gridFsInfo = await GridFSHelper.findFileById(this.files, getObjectIds(id));
|
|
457
|
+
if (!gridFsInfo) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
return { info: await this.prepareOutput(gridFsInfo as unknown as CoreFileInfo, serviceOptions), store: 'gridfs' };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get a presigned download URL for a file stored in S3.
|
|
465
|
+
*
|
|
466
|
+
* Returns undefined when the file is not in S3 or `s3.presignedDownloads` is
|
|
467
|
+
* disabled — callers then fall back to streaming the file themselves.
|
|
468
|
+
*/
|
|
469
|
+
async getDownloadUrl(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<string | undefined> {
|
|
470
|
+
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
const fileInfo = await this.findS3FileById(id);
|
|
474
|
+
if (!fileInfo) {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
return this.options.s3Service.getPresignedDownloadUrl(getObjectIds(id).toHexString(), fileInfo.filename);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get file stream (for big files) via filename
|
|
482
|
+
*/
|
|
483
|
+
async getFileStreamByName(filename: string, serviceOptions?: FileServiceOptions): Promise<Readable> {
|
|
158
484
|
if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
|
|
159
485
|
return null;
|
|
160
486
|
}
|
|
487
|
+
const s3FileInfo = await this.findS3FileByName(filename);
|
|
488
|
+
if (s3FileInfo) {
|
|
489
|
+
return S3FileHelper.getStream(this.options.s3Service, s3FileInfo._id);
|
|
490
|
+
}
|
|
491
|
+
const fsFileInfo = await this.findFilesystemFileByName(filename);
|
|
492
|
+
if (fsFileInfo) {
|
|
493
|
+
return FilesystemFileHelper.getStream(this.filesystemDir, fsFileInfo._id);
|
|
494
|
+
}
|
|
161
495
|
return GridFSHelper.openDownloadStreamByName(this.files, filename);
|
|
162
496
|
}
|
|
163
497
|
|
|
@@ -168,6 +502,12 @@ export abstract class CoreFileService {
|
|
|
168
502
|
if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
|
|
169
503
|
return null;
|
|
170
504
|
}
|
|
505
|
+
if (await this.findS3FileById(id)) {
|
|
506
|
+
return S3FileHelper.getBuffer(this.options.s3Service, id);
|
|
507
|
+
}
|
|
508
|
+
if (await this.findFilesystemFileById(id)) {
|
|
509
|
+
return FilesystemFileHelper.getBuffer(this.filesystemDir, id);
|
|
510
|
+
}
|
|
171
511
|
return await GridFSHelper.readFileToBuffer(this.files, { _id: getObjectIds(id) });
|
|
172
512
|
}
|
|
173
513
|
|
|
@@ -178,6 +518,14 @@ export abstract class CoreFileService {
|
|
|
178
518
|
if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
|
|
179
519
|
return null;
|
|
180
520
|
}
|
|
521
|
+
const s3FileInfo = await this.findS3FileByName(filename);
|
|
522
|
+
if (s3FileInfo) {
|
|
523
|
+
return S3FileHelper.getBuffer(this.options.s3Service, s3FileInfo._id);
|
|
524
|
+
}
|
|
525
|
+
const fsFileInfo = await this.findFilesystemFileByName(filename);
|
|
526
|
+
if (fsFileInfo) {
|
|
527
|
+
return FilesystemFileHelper.getBuffer(this.filesystemDir, fsFileInfo._id);
|
|
528
|
+
}
|
|
181
529
|
return await GridFSHelper.readFileToBuffer(this.files, { filename });
|
|
182
530
|
}
|
|
183
531
|
|
|
@@ -190,6 +538,14 @@ export abstract class CoreFileService {
|
|
|
190
538
|
}
|
|
191
539
|
const objectId = getObjectIds(id);
|
|
192
540
|
const fileInfo = await this.getFileInfo(objectId, serviceOptions);
|
|
541
|
+
if (await this.findS3FileById(objectId)) {
|
|
542
|
+
await S3FileHelper.deleteFile(this.options.s3Service, this.s3Files, objectId);
|
|
543
|
+
return fileInfo;
|
|
544
|
+
}
|
|
545
|
+
if (await this.findFilesystemFileById(objectId)) {
|
|
546
|
+
await FilesystemFileHelper.deleteFile(this.filesystemDir, this.filesystemFiles, objectId);
|
|
547
|
+
return fileInfo;
|
|
548
|
+
}
|
|
193
549
|
await GridFSHelper.deleteFile(this.files, objectId);
|
|
194
550
|
return fileInfo;
|
|
195
551
|
}
|
|
@@ -201,7 +557,13 @@ export abstract class CoreFileService {
|
|
|
201
557
|
if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
|
|
202
558
|
return null;
|
|
203
559
|
}
|
|
204
|
-
|
|
560
|
+
// Forward the caller's context, exactly as deleteFile() does for getFileInfo().
|
|
561
|
+
// Without it this method authorizes the caller and then re-resolves the file with
|
|
562
|
+
// an EMPTY context, so an overridden checkRights() is asked two different
|
|
563
|
+
// questions about one request. For an ownership rule that means the lookup denies
|
|
564
|
+
// — and the caller gets `File not found` for a file that exists and that they were
|
|
565
|
+
// just authorized for, which reads as a missing file rather than a refused one.
|
|
566
|
+
const fileInfo = await this.getFileInfoByName(filename, serviceOptions);
|
|
205
567
|
if (!fileInfo) {
|
|
206
568
|
throw new NotFoundException(`File not found with filename ${filename}`);
|
|
207
569
|
}
|
|
@@ -212,9 +574,147 @@ export abstract class CoreFileService {
|
|
|
212
574
|
// Helper methods
|
|
213
575
|
// ===================================================================================================================
|
|
214
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Find the S3 metadata of a file by ID (null when S3 is not usable, or the file is in another store)
|
|
579
|
+
*
|
|
580
|
+
* Gated on whether S3 is USABLE, not on whether it is the active write driver.
|
|
581
|
+
* Gating on the driver made the documented "reads consult every store, so
|
|
582
|
+
* switching is forward-only" promise true only in one direction: adopting S3
|
|
583
|
+
* kept GridFS readable, but switching back to `gridfs`/`filesystem` turned
|
|
584
|
+
* every S3-stored file into a 404. The metadata lookup is a cheap indexed
|
|
585
|
+
* `findOne`; the driver only decides where new bytes GO.
|
|
586
|
+
*/
|
|
587
|
+
protected async findS3FileById(id: string | Types.ObjectId): Promise<null | S3FileInfo> {
|
|
588
|
+
if (!this.isStorageAvailable('s3')) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
return S3FileHelper.findFileById(this.s3Files, getObjectIds(id));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Find the S3 metadata of a file by filename (null when S3 is not usable, or the file is in another store)
|
|
596
|
+
*
|
|
597
|
+
* See {@link findS3FileById} for why this is gated on availability, not on the
|
|
598
|
+
* active driver.
|
|
599
|
+
*/
|
|
600
|
+
protected async findS3FileByName(filename: string): Promise<null | S3FileInfo> {
|
|
601
|
+
if (!this.isStorageAvailable('s3')) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
return S3FileHelper.findFileByName(this.s3Files, filename);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Read a file's raw document, bypassing `prepareOutput`.
|
|
609
|
+
*
|
|
610
|
+
* `getFileInfo()` runs the result through `prepareOutput` → `check()`, which
|
|
611
|
+
* strips fields the current user may not see — including `metadata`. That is
|
|
612
|
+
* correct for a response, and useless for an authorization decision, which
|
|
613
|
+
* has to look at the very field being protected.
|
|
614
|
+
*
|
|
615
|
+
* So this is the read side of a per-file rule: use it inside an overridden
|
|
616
|
+
* `checkRights()`, never to build a response.
|
|
617
|
+
*
|
|
618
|
+
* Checks S3 metadata first, then the filesystem store, then GridFS — the same
|
|
619
|
+
* three stores in the same order as `getFileInfo()`, so a rule written against
|
|
620
|
+
* this sees the same file the download would serve. Getting that order wrong
|
|
621
|
+
* would make an owner check pass on a stale GridFS document while the bytes
|
|
622
|
+
* come from S3; omitting a store would make it decide on a file that is not
|
|
623
|
+
* the one being served at all (see {@link getRawFileInfoByName}).
|
|
624
|
+
*
|
|
625
|
+
* @param id file id
|
|
626
|
+
* @returns the raw document, or null when no file has that id
|
|
627
|
+
*/
|
|
628
|
+
protected async getRawFileInfo(id: string | Types.ObjectId): Promise<null | Record<string, any>> {
|
|
629
|
+
const fileInfo =
|
|
630
|
+
(await this.findS3FileById(id)) ||
|
|
631
|
+
(await this.findFilesystemFileById(id)) ||
|
|
632
|
+
(await GridFSHelper.findFileById(this.files, getObjectIds(id)));
|
|
633
|
+
return (fileInfo as unknown as Record<string, any>) || null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Read a file's raw document by filename, bypassing `prepareOutput`.
|
|
638
|
+
*
|
|
639
|
+
* Companion to {@link getRawFileInfo} for the `checkInputType: 'filename'`
|
|
640
|
+
* branch of `checkRights()`. Note that the filename route resolves the FIRST
|
|
641
|
+
* match, so a rule built on this is only as strong as filename uniqueness in
|
|
642
|
+
* your project — prefer authorizing by id where you can.
|
|
643
|
+
*/
|
|
644
|
+
protected async getRawFileInfoByName(filename: string): Promise<null | Record<string, any>> {
|
|
645
|
+
// Same three stores, in the same order, as getRawFileInfo() and
|
|
646
|
+
// getFileInfoByName(). Skipping the filesystem store here made a by-name
|
|
647
|
+
// ownership rule see a DIFFERENT file set than the download serves: under
|
|
648
|
+
// `file.storage: 'filesystem'` it returned null for a file the route then
|
|
649
|
+
// happily streamed. That fails closed for the documented `!!raw && …` shape
|
|
650
|
+
// and OPEN for the equally natural `if (!raw) return true`.
|
|
651
|
+
const fileInfo =
|
|
652
|
+
(await this.findS3FileByName(filename)) ||
|
|
653
|
+
(await this.findFilesystemFileByName(filename)) ||
|
|
654
|
+
(await GridFSHelper.findFileByName(this.files, filename));
|
|
655
|
+
return (fileInfo as unknown as Record<string, any>) || null;
|
|
656
|
+
}
|
|
657
|
+
|
|
215
658
|
/**
|
|
216
659
|
* Check rights before processing file handling
|
|
217
660
|
* Can throw an exception if the rights do not fit
|
|
661
|
+
*
|
|
662
|
+
* Returning `false` makes the caller answer as if the file did not exist
|
|
663
|
+
* (`404`), so a refusal never confirms that an id is real.
|
|
664
|
+
*
|
|
665
|
+
* The role decorators on the controller and resolver are the COARSE filter —
|
|
666
|
+
* they decide who may reach the endpoint at all, and are configurable via
|
|
667
|
+
* `file.downloadRoles` / `uploadRoles` / `deleteRoles`. This hook is the fine
|
|
668
|
+
* one: it is the only place that can express "…but only their OWN file".
|
|
669
|
+
*
|
|
670
|
+
* `options.currentUser` is populated by the core controller for both download
|
|
671
|
+
* routes. Metadata to compare against must be written at upload time via
|
|
672
|
+
* `serviceOptions.metadata` and read back with `getRawFileInfo()`.
|
|
673
|
+
*
|
|
674
|
+
* **A missing `currentUser` must DENY.** This is the one part of the rule that
|
|
675
|
+
* is easy to get backwards: "no user in context" is NOT "system-internal call"
|
|
676
|
+
* — it is also exactly what an ANONYMOUS request looks like. The coarse gate
|
|
677
|
+
* turns those away while `downloadRoles` is narrower than `S_EVERYONE`, so an
|
|
678
|
+
* `if (!options.currentUser) return true` shortcut looks harmless — right up to
|
|
679
|
+
* the moment a project widens the gate, at which point it hands every file to
|
|
680
|
+
* everyone and the ownership rule evaporates precisely when it starts to
|
|
681
|
+
* matter. Genuinely internal callers say so with `force: true`, or pass the
|
|
682
|
+
* user they already have; see `src/server/modules/file` and
|
|
683
|
+
* `src/server/modules/user/avatar.controller.ts`.
|
|
684
|
+
*
|
|
685
|
+
* **Cover the `filename` branch too, not just `id`.** An id-only rule is
|
|
686
|
+
* enough while bytes are streamed, because the filename route resolves an id
|
|
687
|
+
* and checks it again — but NOT once `s3.presignedDownloads` is enabled, where
|
|
688
|
+
* the filename route authorizes on the by-name lookup alone and then redirects,
|
|
689
|
+
* and not for `deleteFileByName()`, which authorizes by name only.
|
|
690
|
+
*
|
|
691
|
+
* The example below is the rule `src/server/modules/file/file.service.ts`
|
|
692
|
+
* actually runs — it is compiled, type-checked and exercised end to end by
|
|
693
|
+
* every file-touching e2e spec, plus the dedicated contract test in
|
|
694
|
+
* `tests/file-ownership.e2e-spec.ts`. Prefer reading it there over copying
|
|
695
|
+
* from here.
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* ```typescript
|
|
699
|
+
* protected override async checkRights(
|
|
700
|
+
* input: any,
|
|
701
|
+
* options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
702
|
+
* ): Promise<boolean> {
|
|
703
|
+
* if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
704
|
+
* return true;
|
|
705
|
+
* }
|
|
706
|
+
* if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
707
|
+
* return true;
|
|
708
|
+
* }
|
|
709
|
+
* const raw = options.checkInputType === 'id'
|
|
710
|
+
* ? await this.getRawFileInfo(input)
|
|
711
|
+
* : await this.getRawFileInfoByName(input);
|
|
712
|
+
* // Fails closed without a user: `String(undefined)` cannot equal a real owner id.
|
|
713
|
+
* // Requiring the owner to be PRESENT is load-bearing too — comparing a missing
|
|
714
|
+
* // ownerId against a missing user id would compare 'undefined' with 'undefined'.
|
|
715
|
+
* return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
716
|
+
* }
|
|
717
|
+
* ```
|
|
218
718
|
*/
|
|
219
719
|
protected checkRights(
|
|
220
720
|
_input: any,
|