@lenne.tech/nest-server 11.7.2 → 11.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/common/interfaces/server-options.interface.d.ts +22 -0
- package/dist/core/modules/auth/guards/roles.guard.d.ts +12 -2
- package/dist/core/modules/auth/guards/roles.guard.js +192 -5
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +1 -0
- package/dist/core/modules/file/core-file.controller.js +22 -0
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +9 -0
- package/dist/core/modules/tus/core-tus.controller.js +85 -0
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -0
- package/dist/core/modules/tus/core-tus.service.d.ts +30 -0
- package/dist/core/modules/tus/core-tus.service.js +284 -0
- package/dist/core/modules/tus/core-tus.service.js.map +1 -0
- package/dist/core/modules/tus/index.d.ts +4 -0
- package/dist/core/modules/tus/index.js +21 -0
- package/dist/core/modules/tus/index.js.map +1 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.d.ts +10 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +59 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +21 -0
- package/dist/core/modules/tus/tus.module.js +99 -0
- package/dist/core/modules/tus/tus.module.js.map +1 -0
- package/dist/core/modules/user/core-user.service.d.ts +1 -0
- package/dist/core/modules/user/core-user.service.js +12 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file.controller.d.ts +5 -7
- package/dist/server/modules/file/file.controller.js +3 -31
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/server.module.js +3 -1
- package/dist/server/server.module.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -1
- package/src/core/common/interfaces/server-options.interface.ts +154 -0
- package/src/core/modules/auth/guards/roles.guard.ts +298 -5
- package/src/core/modules/file/README.md +165 -0
- package/src/core/modules/file/core-file.controller.ts +27 -1
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +176 -0
- package/src/core/modules/tus/README.md +439 -0
- package/src/core/modules/tus/core-tus.controller.ts +88 -0
- package/src/core/modules/tus/core-tus.service.ts +424 -0
- package/src/core/modules/tus/index.ts +5 -0
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +107 -0
- package/src/core/modules/tus/tus.module.ts +187 -0
- package/src/core/modules/user/core-user.service.ts +27 -0
- package/src/index.ts +6 -0
- package/src/server/modules/file/file.controller.ts +14 -34
- package/src/server/server.module.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.8.0",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -93,6 +93,8 @@
|
|
|
93
93
|
"@nestjs/swagger": "11.2.3",
|
|
94
94
|
"@nestjs/terminus": "11.0.0",
|
|
95
95
|
"@nestjs/websockets": "11.1.9",
|
|
96
|
+
"@tus/file-store": "2.0.0",
|
|
97
|
+
"@tus/server": "2.3.0",
|
|
96
98
|
"apollo-server-core": "3.13.0",
|
|
97
99
|
"bcrypt": "6.0.0",
|
|
98
100
|
"better-auth": "1.4.8-beta.4",
|
|
@@ -167,6 +169,7 @@
|
|
|
167
169
|
"ts-morph": "27.0.2",
|
|
168
170
|
"ts-node": "10.9.2",
|
|
169
171
|
"tsconfig-paths": "4.2.0",
|
|
172
|
+
"tus-js-client": "4.3.1",
|
|
170
173
|
"typescript": "5.9.3",
|
|
171
174
|
"unplugin-swc": "1.5.9",
|
|
172
175
|
"vite": "7.3.0",
|
|
@@ -1132,4 +1132,158 @@ export interface IServerOptions {
|
|
|
1132
1132
|
*/
|
|
1133
1133
|
path?: string;
|
|
1134
1134
|
};
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* TUS resumable upload configuration.
|
|
1138
|
+
*
|
|
1139
|
+
* Follows the "Enabled by Default" pattern - tus is automatically enabled
|
|
1140
|
+
* without any configuration. Set `tus: false` to explicitly disable.
|
|
1141
|
+
*
|
|
1142
|
+
* Accepts:
|
|
1143
|
+
* - `true` or `undefined`: Enable with defaults (enabled by default)
|
|
1144
|
+
* - `false`: Disable TUS uploads
|
|
1145
|
+
* - `{ ... }`: Enable with custom configuration
|
|
1146
|
+
*
|
|
1147
|
+
* @example
|
|
1148
|
+
* ```typescript
|
|
1149
|
+
* // Default: TUS enabled with all defaults (no config needed)
|
|
1150
|
+
*
|
|
1151
|
+
* // Disable TUS
|
|
1152
|
+
* tus: false,
|
|
1153
|
+
*
|
|
1154
|
+
* // Custom configuration
|
|
1155
|
+
* tus: {
|
|
1156
|
+
* maxSize: 100 * 1024 * 1024, // 100 MB
|
|
1157
|
+
* path: '/uploads',
|
|
1158
|
+
* },
|
|
1159
|
+
* ```
|
|
1160
|
+
*
|
|
1161
|
+
* @since 11.8.0
|
|
1162
|
+
*/
|
|
1163
|
+
tus?: boolean | ITusConfig;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* TUS Upload Configuration Interface
|
|
1168
|
+
*
|
|
1169
|
+
* Follows the "Enabled by Default" pattern - tus is automatically enabled
|
|
1170
|
+
* without any configuration. Set `tus: false` to explicitly disable.
|
|
1171
|
+
*/
|
|
1172
|
+
export interface ITusConfig {
|
|
1173
|
+
/**
|
|
1174
|
+
* Additional allowed HTTP headers for TUS requests (beyond @tus/server defaults).
|
|
1175
|
+
*
|
|
1176
|
+
* Note: @tus/server already includes all TUS protocol headers:
|
|
1177
|
+
* Authorization, Content-Type, Location, Tus-Extension, Tus-Max-Size,
|
|
1178
|
+
* Tus-Resumable, Tus-Version, Upload-Concat, Upload-Defer-Length,
|
|
1179
|
+
* Upload-Length, Upload-Metadata, Upload-Offset, X-HTTP-Method-Override,
|
|
1180
|
+
* X-Requested-With, X-Forwarded-Host, X-Forwarded-Proto, Forwarded
|
|
1181
|
+
*
|
|
1182
|
+
* Use this only for project-specific custom headers.
|
|
1183
|
+
*
|
|
1184
|
+
* @default [] (no additional headers needed)
|
|
1185
|
+
*/
|
|
1186
|
+
allowedHeaders?: string[];
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Allowed MIME types for uploads.
|
|
1190
|
+
* If undefined, all types are allowed.
|
|
1191
|
+
* @default undefined (all types allowed)
|
|
1192
|
+
*/
|
|
1193
|
+
allowedTypes?: string[];
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Checksum extension configuration.
|
|
1197
|
+
* Enables data integrity verification.
|
|
1198
|
+
* @default true
|
|
1199
|
+
*/
|
|
1200
|
+
checksum?: boolean;
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Concatenation extension configuration.
|
|
1204
|
+
* Allows parallel uploads that are merged.
|
|
1205
|
+
* @default true
|
|
1206
|
+
*/
|
|
1207
|
+
concatenation?: boolean;
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Creation extension configuration.
|
|
1211
|
+
* Allows creating new uploads via POST.
|
|
1212
|
+
* @default true
|
|
1213
|
+
*/
|
|
1214
|
+
creation?: boolean | ITusCreationConfig;
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Creation With Upload extension configuration.
|
|
1218
|
+
* Allows sending data in the initial POST request.
|
|
1219
|
+
* @default true
|
|
1220
|
+
*/
|
|
1221
|
+
creationWithUpload?: boolean;
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Whether tus uploads are enabled.
|
|
1225
|
+
* @default true (enabled by default)
|
|
1226
|
+
*/
|
|
1227
|
+
enabled?: boolean;
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Expiration extension configuration.
|
|
1231
|
+
* Automatically cleans up incomplete uploads.
|
|
1232
|
+
* @default { expiresIn: '24h' }
|
|
1233
|
+
*/
|
|
1234
|
+
expiration?: boolean | ITusExpirationConfig;
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Maximum upload size in bytes
|
|
1238
|
+
* @default 50 * 1024 * 1024 * 1024 (50 GB)
|
|
1239
|
+
*/
|
|
1240
|
+
maxSize?: number;
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Base path for tus endpoints
|
|
1244
|
+
* @default '/tus'
|
|
1245
|
+
*/
|
|
1246
|
+
path?: string;
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Termination extension configuration.
|
|
1250
|
+
* Allows deleting uploads via DELETE.
|
|
1251
|
+
* @default true
|
|
1252
|
+
*/
|
|
1253
|
+
termination?: boolean;
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Directory for temporary upload chunks.
|
|
1257
|
+
* @default 'uploads/tus'
|
|
1258
|
+
*/
|
|
1259
|
+
uploadDir?: string;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* TUS Creation extension configuration
|
|
1264
|
+
*/
|
|
1265
|
+
export interface ITusCreationConfig {
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether creation is enabled
|
|
1268
|
+
* @default true
|
|
1269
|
+
*/
|
|
1270
|
+
enabled?: boolean;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* TUS Expiration extension configuration
|
|
1275
|
+
*/
|
|
1276
|
+
export interface ITusExpirationConfig {
|
|
1277
|
+
/**
|
|
1278
|
+
* Whether expiration is enabled
|
|
1279
|
+
* @default true
|
|
1280
|
+
*/
|
|
1281
|
+
enabled?: boolean;
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Time until incomplete uploads expire
|
|
1285
|
+
* Supports formats: '24h', '1d', '12h', etc.
|
|
1286
|
+
* @default '24h'
|
|
1287
|
+
*/
|
|
1288
|
+
expiresIn?: string;
|
|
1135
1289
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
2
|
-
import { Reflector } from '@nestjs/core';
|
|
1
|
+
import { ExecutionContext, Injectable, Logger, Optional, UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { ModuleRef, Reflector } from '@nestjs/core';
|
|
3
3
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
4
|
+
import { getConnectionToken } from '@nestjs/mongoose';
|
|
5
|
+
import { Connection, Types } from 'mongoose';
|
|
6
|
+
import { firstValueFrom, isObservable } from 'rxjs';
|
|
4
7
|
|
|
5
8
|
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
9
|
+
import { BetterAuthService } from '../../better-auth/better-auth.service';
|
|
6
10
|
import { AuthGuardStrategy } from '../auth-guard-strategy.enum';
|
|
7
11
|
import { ExpiredTokenException } from '../exceptions/expired-token.exception';
|
|
8
12
|
import { InvalidTokenException } from '../exceptions/invalid-token.exception';
|
|
@@ -14,16 +18,305 @@ import { AuthGuard } from './auth.guard';
|
|
|
14
18
|
* The RoleGuard is activated by the Role decorator. It checks whether the current user has at least one of the
|
|
15
19
|
* specified roles or is logged in when the S_USER role is set.
|
|
16
20
|
* If this is not the case, an UnauthorizedException is thrown.
|
|
21
|
+
*
|
|
22
|
+
* MULTI-TOKEN SUPPORT:
|
|
23
|
+
* This guard supports multiple authentication token types:
|
|
24
|
+
* 1. Legacy JWT tokens (Passport JWT strategy)
|
|
25
|
+
* 2. BetterAuth JWT tokens (verified via BetterAuth service)
|
|
26
|
+
* 3. BetterAuth session tokens (verified via database lookup)
|
|
27
|
+
*
|
|
28
|
+
* When Passport JWT validation fails, the guard falls back to BetterAuth verification:
|
|
29
|
+
* - First tries JWT verification if the JWT plugin is enabled
|
|
30
|
+
* - Then tries session token lookup via MongoDB
|
|
31
|
+
*
|
|
32
|
+
* This enables users who sign in via IAM (/iam/sign-in/email) to access all protected endpoints,
|
|
33
|
+
* regardless of whether they use JWT or session-based authentication.
|
|
17
34
|
*/
|
|
18
35
|
@Injectable()
|
|
19
36
|
export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
37
|
+
private readonly logger = new Logger(RolesGuard.name);
|
|
38
|
+
private betterAuthService: BetterAuthService | null = null;
|
|
39
|
+
private mongoConnection: Connection | null = null;
|
|
40
|
+
private servicesResolved = false;
|
|
41
|
+
|
|
20
42
|
/**
|
|
21
|
-
* Integrate reflector
|
|
43
|
+
* Integrate reflector and moduleRef for lazy service resolution
|
|
22
44
|
*/
|
|
23
|
-
constructor(
|
|
45
|
+
constructor(
|
|
46
|
+
protected readonly reflector: Reflector,
|
|
47
|
+
@Optional() private readonly moduleRef?: ModuleRef,
|
|
48
|
+
) {
|
|
24
49
|
super();
|
|
25
50
|
}
|
|
26
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Lazily resolve BetterAuth service and MongoDB connection
|
|
54
|
+
*/
|
|
55
|
+
private resolveServices(): void {
|
|
56
|
+
if (this.servicesResolved || !this.moduleRef) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
this.betterAuthService = this.moduleRef.get(BetterAuthService, { strict: false });
|
|
62
|
+
} catch {
|
|
63
|
+
// BetterAuth not available - that's fine, we'll use Legacy JWT only
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Get the Mongoose connection to query users directly
|
|
68
|
+
this.mongoConnection = this.moduleRef.get(getConnectionToken(), { strict: false });
|
|
69
|
+
} catch {
|
|
70
|
+
// MongoDB connection not available
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.servicesResolved = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Override canActivate to add BetterAuth JWT fallback
|
|
78
|
+
*
|
|
79
|
+
* Flow:
|
|
80
|
+
* 1. Try Passport JWT authentication (Legacy JWT)
|
|
81
|
+
* 2. If that fails, try BetterAuth JWT verification
|
|
82
|
+
* 3. If BetterAuth succeeds, load the user and proceed
|
|
83
|
+
*/
|
|
84
|
+
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
85
|
+
// Resolve services lazily
|
|
86
|
+
this.resolveServices();
|
|
87
|
+
|
|
88
|
+
// First, try the parent canActivate (Passport JWT)
|
|
89
|
+
try {
|
|
90
|
+
const result = super.canActivate(context);
|
|
91
|
+
return isObservable(result) ? await firstValueFrom(result) : await result;
|
|
92
|
+
} catch (passportError) {
|
|
93
|
+
// Passport JWT validation failed - try BetterAuth token fallback (JWT or session)
|
|
94
|
+
if (!this.betterAuthService?.isEnabled()) {
|
|
95
|
+
// BetterAuth not available - rethrow original error
|
|
96
|
+
throw passportError;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Try to verify the token via BetterAuth (JWT or session token)
|
|
100
|
+
const user = await this.verifyBetterAuthTokenFromContext(context);
|
|
101
|
+
if (!user) {
|
|
102
|
+
// BetterAuth verification also failed - rethrow original Passport error
|
|
103
|
+
throw passportError;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// BetterAuth token is valid - set the user on the request
|
|
107
|
+
const request = this.getRequest(context);
|
|
108
|
+
if (request) {
|
|
109
|
+
request.user = user;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Now call handleRequest with the BetterAuth-authenticated user to check roles
|
|
113
|
+
this.handleRequest(null, user, null, context);
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify BetterAuth token (JWT or session) and load the corresponding user
|
|
121
|
+
*
|
|
122
|
+
* This method tries multiple verification strategies:
|
|
123
|
+
* 1. BetterAuth JWT verification (if JWT plugin is enabled)
|
|
124
|
+
* 2. BetterAuth session token lookup (database lookup)
|
|
125
|
+
*
|
|
126
|
+
* @param context - ExecutionContext to extract request from
|
|
127
|
+
* @returns User object if verification succeeds, null otherwise
|
|
128
|
+
*/
|
|
129
|
+
private async verifyBetterAuthTokenFromContext(context: ExecutionContext): Promise<any> {
|
|
130
|
+
if (!this.betterAuthService || !this.mongoConnection) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// Get the raw HTTP request from multiple possible sources
|
|
136
|
+
let authHeader: string | undefined;
|
|
137
|
+
|
|
138
|
+
// Try GraphQL context first
|
|
139
|
+
try {
|
|
140
|
+
const gqlContext = GqlExecutionContext.create(context);
|
|
141
|
+
const ctx = gqlContext.getContext();
|
|
142
|
+
if (ctx?.req?.headers) {
|
|
143
|
+
authHeader = ctx.req.headers.authorization || ctx.req.headers.Authorization;
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// GraphQL context not available
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fallback to HTTP context
|
|
150
|
+
if (!authHeader) {
|
|
151
|
+
try {
|
|
152
|
+
const httpRequest = context.switchToHttp().getRequest();
|
|
153
|
+
if (httpRequest?.headers) {
|
|
154
|
+
authHeader = httpRequest.headers.authorization || httpRequest.headers.Authorization;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// HTTP context not available
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let token: string | undefined;
|
|
162
|
+
|
|
163
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
164
|
+
token = authHeader.substring(7);
|
|
165
|
+
} else if (authHeader?.startsWith('bearer ')) {
|
|
166
|
+
// Handle lowercase 'bearer' as well
|
|
167
|
+
token = authHeader.substring(7);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!token) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Strategy 1: Try JWT verification (if JWT plugin is enabled)
|
|
175
|
+
if (this.betterAuthService.isJwtEnabled()) {
|
|
176
|
+
try {
|
|
177
|
+
const payload = await this.betterAuthService.verifyJwtToken(token);
|
|
178
|
+
if (payload?.sub) {
|
|
179
|
+
const user = await this.loadUserFromPayload(payload);
|
|
180
|
+
if (user) {
|
|
181
|
+
return user;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// JWT verification failed - try session token next
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Strategy 2: Try session token lookup (database lookup)
|
|
190
|
+
try {
|
|
191
|
+
const sessionResult = await this.betterAuthService.getSessionByToken(token);
|
|
192
|
+
if (sessionResult?.user) {
|
|
193
|
+
return this.loadUserFromSessionResult(sessionResult.user);
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Session lookup failed
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.logger.debug(
|
|
202
|
+
`BetterAuth token fallback failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
203
|
+
);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Load user from JWT payload using direct MongoDB query
|
|
210
|
+
*
|
|
211
|
+
* @param payload - JWT payload with sub (user ID or iamId)
|
|
212
|
+
* @returns User object with hasRole method
|
|
213
|
+
*/
|
|
214
|
+
private async loadUserFromPayload(payload: { [key: string]: any; sub: string }): Promise<any> {
|
|
215
|
+
if (!this.mongoConnection) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const usersCollection = this.mongoConnection.collection('users');
|
|
221
|
+
let user: any = null;
|
|
222
|
+
|
|
223
|
+
// Try to find by MongoDB _id first
|
|
224
|
+
if (Types.ObjectId.isValid(payload.sub)) {
|
|
225
|
+
user = await usersCollection.findOne({ _id: new Types.ObjectId(payload.sub) });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// If not found, try by iamId
|
|
229
|
+
if (!user) {
|
|
230
|
+
user = await usersCollection.findOne({ iamId: payload.sub });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!user) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Convert MongoDB document to user-like object with hasRole method
|
|
238
|
+
const userObject = {
|
|
239
|
+
...user,
|
|
240
|
+
_authenticatedViaBetterAuth: true,
|
|
241
|
+
// Add hasRole method for role checking
|
|
242
|
+
hasRole: (roles: string[]): boolean => {
|
|
243
|
+
if (!user.roles || !Array.isArray(user.roles)) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
return roles.some((role) => user.roles.includes(role));
|
|
247
|
+
},
|
|
248
|
+
id: user._id?.toString(),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return userObject;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.logger.debug(
|
|
254
|
+
`Failed to load user from payload: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
255
|
+
);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Load user from session result (from getSessionByToken)
|
|
262
|
+
*
|
|
263
|
+
* @param sessionUser - User object from session lookup
|
|
264
|
+
* @returns User object with hasRole method
|
|
265
|
+
*/
|
|
266
|
+
private async loadUserFromSessionResult(sessionUser: any): Promise<any> {
|
|
267
|
+
if (!this.mongoConnection || !sessionUser) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const usersCollection = this.mongoConnection.collection('users');
|
|
273
|
+
|
|
274
|
+
// The sessionUser might have id (BetterAuth ID) or email
|
|
275
|
+
// We need to find the corresponding user in our users collection
|
|
276
|
+
let user: any = null;
|
|
277
|
+
|
|
278
|
+
// Try to find by email (most reliable)
|
|
279
|
+
if (sessionUser.email) {
|
|
280
|
+
user = await usersCollection.findOne({ email: sessionUser.email });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// If not found by email, try by iamId
|
|
284
|
+
if (!user && sessionUser.id) {
|
|
285
|
+
user = await usersCollection.findOne({ iamId: sessionUser.id });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// If still not found, try by _id (if the ID looks like a MongoDB ObjectId)
|
|
289
|
+
if (!user && sessionUser.id && Types.ObjectId.isValid(sessionUser.id)) {
|
|
290
|
+
user = await usersCollection.findOne({ _id: new Types.ObjectId(sessionUser.id) });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!user) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Convert MongoDB document to user-like object with hasRole method
|
|
298
|
+
const userObject = {
|
|
299
|
+
...user,
|
|
300
|
+
_authenticatedViaBetterAuth: true,
|
|
301
|
+
// Add hasRole method for role checking
|
|
302
|
+
hasRole: (roles: string[]): boolean => {
|
|
303
|
+
if (!user.roles || !Array.isArray(user.roles)) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
return roles.some((role) => user.roles.includes(role));
|
|
307
|
+
},
|
|
308
|
+
id: user._id?.toString(),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return userObject;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
this.logger.debug(
|
|
314
|
+
`Failed to load user from session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
315
|
+
);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
27
320
|
/**
|
|
28
321
|
* Handle request
|
|
29
322
|
*/
|
|
@@ -42,7 +335,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
42
335
|
}
|
|
43
336
|
|
|
44
337
|
// Check roles
|
|
45
|
-
if (!roles || !roles.some(value => !!value)) {
|
|
338
|
+
if (!roles || !roles.some((value) => !!value)) {
|
|
46
339
|
return user;
|
|
47
340
|
}
|
|
48
341
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# File Module
|
|
2
|
+
|
|
3
|
+
File upload and download functionality with MongoDB GridFS storage.
|
|
4
|
+
|
|
5
|
+
## Endpoints
|
|
6
|
+
|
|
7
|
+
### Public Endpoints (via CoreFileController)
|
|
8
|
+
|
|
9
|
+
| Method | Endpoint | Description |
|
|
10
|
+
|--------|----------|-------------|
|
|
11
|
+
| GET | `/files/id/:id` | Download file by ID |
|
|
12
|
+
| GET | `/files/:filename` | Download file by filename |
|
|
13
|
+
|
|
14
|
+
**Note:** These endpoints are public (`S_EVERYONE`) by default. Projects can restrict access by extending `CoreFileController`.
|
|
15
|
+
|
|
16
|
+
### Admin Endpoints (project-specific)
|
|
17
|
+
|
|
18
|
+
Projects typically add admin-only endpoints like:
|
|
19
|
+
|
|
20
|
+
| Method | Endpoint | Description |
|
|
21
|
+
|--------|----------|-------------|
|
|
22
|
+
| POST | `/files/upload` | Upload file (multipart/form-data) |
|
|
23
|
+
| GET | `/files/info/:id` | Get file metadata |
|
|
24
|
+
| DELETE | `/files/:id` | Delete file |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Usage in Projects
|
|
29
|
+
|
|
30
|
+
### Basic Setup (Extend CoreFileController)
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// src/server/modules/file/file.controller.ts
|
|
34
|
+
import { Controller } from '@nestjs/common';
|
|
35
|
+
import { CoreFileController, Roles, RoleEnum } from '@lenne.tech/nest-server';
|
|
36
|
+
import { FileService } from './file.service';
|
|
37
|
+
|
|
38
|
+
@Controller('files')
|
|
39
|
+
@Roles(RoleEnum.ADMIN)
|
|
40
|
+
export class FileController extends CoreFileController {
|
|
41
|
+
constructor(protected override readonly fileService: FileService) {
|
|
42
|
+
super(fileService);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add admin-only endpoints here (upload, delete, etc.)
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Restrict Download Access
|
|
50
|
+
|
|
51
|
+
To require authentication for downloads, override the inherited methods:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
@Controller('files')
|
|
55
|
+
@Roles(RoleEnum.ADMIN)
|
|
56
|
+
export class FileController extends CoreFileController {
|
|
57
|
+
constructor(protected override readonly fileService: FileService) {
|
|
58
|
+
super(fileService);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Override to require authentication for ID-based download
|
|
62
|
+
@Get('id/:id')
|
|
63
|
+
@Roles(RoleEnum.S_USER) // Require logged-in user
|
|
64
|
+
override async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
65
|
+
return super.getFileById(id, res);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Override to require authentication for filename-based download
|
|
69
|
+
@Get(':filename')
|
|
70
|
+
@Roles(RoleEnum.S_USER)
|
|
71
|
+
override async getFile(@Param('filename') filename: string, @Res() res: Response) {
|
|
72
|
+
return super.getFile(filename, res);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## GraphQL Support
|
|
80
|
+
|
|
81
|
+
File operations are also available via GraphQL through `CoreFileResolver`:
|
|
82
|
+
|
|
83
|
+
```graphql
|
|
84
|
+
# Query file by ID
|
|
85
|
+
query {
|
|
86
|
+
file(id: "...") {
|
|
87
|
+
id
|
|
88
|
+
filename
|
|
89
|
+
contentType
|
|
90
|
+
length
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Query file by filename
|
|
95
|
+
query {
|
|
96
|
+
fileByFilename(filename: "...") {
|
|
97
|
+
id
|
|
98
|
+
filename
|
|
99
|
+
contentType
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Upload file (via GraphQL Upload scalar)
|
|
104
|
+
mutation {
|
|
105
|
+
uploadFile(file: Upload!) {
|
|
106
|
+
id
|
|
107
|
+
filename
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Delete file
|
|
112
|
+
mutation {
|
|
113
|
+
deleteFile(filename: "...") {
|
|
114
|
+
id
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Integration with TUS
|
|
122
|
+
|
|
123
|
+
Files uploaded via TUS are automatically stored in GridFS and can be accessed through the same endpoints:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# After TUS upload completes, download by ID
|
|
127
|
+
GET /files/id/<gridfs-file-id>
|
|
128
|
+
|
|
129
|
+
# Or by filename (if unique)
|
|
130
|
+
GET /files/<original-filename>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Recommendation:** Use ID-based downloads for TUS uploads as filenames may not be unique.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## GridFS Storage
|
|
138
|
+
|
|
139
|
+
Files are stored in MongoDB GridFS with the following structure:
|
|
140
|
+
|
|
141
|
+
**fs.files collection:**
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"_id": ObjectId,
|
|
145
|
+
"filename": "example.pdf",
|
|
146
|
+
"length": 1048576,
|
|
147
|
+
"uploadDate": ISODate,
|
|
148
|
+
"metadata": {
|
|
149
|
+
"contentType": "application/pdf",
|
|
150
|
+
"tusUploadId": "...", // If uploaded via TUS
|
|
151
|
+
"uploadedAt": ISODate
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**fs.chunks collection:**
|
|
157
|
+
- Binary file data split into 255KB chunks
|
|
158
|
+
- Automatically managed by GridFS
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Related Documentation
|
|
163
|
+
|
|
164
|
+
- [TUS Module](../tus/README.md) - Resumable upload protocol
|
|
165
|
+
- [CoreFileService](./core-file.service.ts) - File service implementation
|
|
@@ -17,7 +17,33 @@ export abstract class CoreFileController {
|
|
|
17
17
|
protected constructor(protected fileService: CoreFileService) {}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Download file
|
|
20
|
+
* Download file by ID
|
|
21
|
+
*
|
|
22
|
+
* More reliable than filename-based download as IDs are unique.
|
|
23
|
+
* Recommended for TUS uploads and when filename uniqueness cannot be guaranteed.
|
|
24
|
+
*/
|
|
25
|
+
@Get('id/:id')
|
|
26
|
+
@Roles(RoleEnum.S_EVERYONE)
|
|
27
|
+
async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
28
|
+
if (!id) {
|
|
29
|
+
throw new BadRequestException('Missing file ID for download');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const file = await this.fileService.getFileInfo(id);
|
|
33
|
+
if (!file) {
|
|
34
|
+
throw new NotFoundException('File not found');
|
|
35
|
+
}
|
|
36
|
+
const filestream = await this.fileService.getFileStream(id);
|
|
37
|
+
res.header('Content-Type', file.contentType || 'application/octet-stream');
|
|
38
|
+
res.header('Content-Disposition', `attachment; filename=${file.filename}`);
|
|
39
|
+
return filestream.pipe(res);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Download file by filename
|
|
44
|
+
*
|
|
45
|
+
* Note: If multiple files have the same filename, only the first match is returned.
|
|
46
|
+
* For unique file access, use GET /files/id/:id instead.
|
|
21
47
|
*/
|
|
22
48
|
@Get(':filename')
|
|
23
49
|
@Roles(RoleEnum.S_EVERYONE)
|