Spaces:
Running
Running
| import { | |
| Body, | |
| Controller, | |
| Delete, | |
| Get, | |
| HttpCode, | |
| HttpException, | |
| Param, | |
| Post, | |
| Put, | |
| Req, | |
| Res, | |
| UploadedFile, | |
| UseGuards, | |
| UseInterceptors, | |
| } from '@nestjs/common'; | |
| import { FileInterceptor } from '@nestjs/platform-express'; | |
| import { diskStorage } from 'multer'; | |
| import type { Request, Response } from 'express'; | |
| import path from 'path'; | |
| import fs from 'fs'; | |
| import { v4 as uuid } from 'uuid'; | |
| import { AuthService } from './auth.service'; | |
| import { RateLimitService } from './rate-limit.service'; | |
| import { JwtAuthGuard } from './jwt-auth.guard'; | |
| import { CurrentUser } from './current-user.decorator'; | |
| import { writeAudit, getClientIp } from '../../services/auditLog'; | |
| import { isDemoEmail } from '../../services/demo'; | |
| import type { User } from '../../types'; | |
| const WINDOW = 15 * 60 * 1000; | |
| const avatarDir = path.join(__dirname, '../../../uploads/avatars'); | |
| const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; | |
| const AVATAR_UPLOAD = { | |
| storage: diskStorage({ | |
| destination: (_req, _file, cb) => { if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); cb(null, avatarDir); }, | |
| filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)), | |
| }), | |
| limits: { fileSize: 5 * 1024 * 1024 }, | |
| fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { | |
| const ext = path.extname(file.originalname).toLowerCase(); | |
| if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) { | |
| const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed'); | |
| err.statusCode = 400; | |
| return cb(err, false); | |
| } | |
| cb(null, true); | |
| }, | |
| }; | |
| /** | |
| * Authenticated account endpoints — byte-identical to the legacy Express route | |
| * (server/src/routes/auth.ts): the same /me/* account ops, avatar upload (with | |
| * the demo-mode block), settings, key validation, MFA setup/enable/disable, MCP | |
| * tokens and the short-lived ws/resource tokens. The per-IP rate limits reuse | |
| * the shared buckets (the inline rateLimiter(5) shares the 'login' bucket, as in | |
| * the legacy code). create-token answers 201; everything else 200. | |
| */ | |
| ('api/auth') | |
| (JwtAuthGuard) | |
| export class AuthController { | |
| constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {} | |
| private limit(bucket: string, req: Request, max: number): void { | |
| if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { | |
| throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429); | |
| } | |
| } | |
| ('me') | |
| me(() user: User) { | |
| const loaded = this.auth.getCurrentUser(user.id); | |
| if (!loaded) { | |
| throw new HttpException({ error: 'User not found' }, 404); | |
| } | |
| return { user: loaded }; | |
| } | |
| ('me/password') | |
| changePassword(() user: User, () body: unknown, () req: Request, ({ passthrough: true }) res: Response) { | |
| this.limit('login', req, 5); | |
| const result = this.auth.changePassword(user.id, user.email, body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| // Refresh this device's cookie with the new password_version so the user | |
| // stays logged in here while all other sessions are invalidated. | |
| if (result.token) this.auth.setAuthCookie(res, result.token, req); | |
| writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) }); | |
| return { success: true }; | |
| } | |
| ('me') | |
| deleteAccount(() user: User, () req: Request) { | |
| const result = this.auth.deleteAccount(user.id, user.email, user.role); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) }); | |
| return { success: true }; | |
| } | |
| ('me/maps-key') | |
| mapsKey(() user: User, () body: { maps_api_key?: unknown }) { | |
| return this.auth.updateMapsKey(user.id, body.maps_api_key); | |
| } | |
| ('me/api-keys') | |
| apiKeys(() user: User, () body: unknown) { | |
| return this.auth.updateApiKeys(user.id, body); | |
| } | |
| ('me/settings') | |
| updateSettings(() user: User, () body: unknown) { | |
| const result = this.auth.updateSettings(user.id, body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { success: result.success, user: result.user }; | |
| } | |
| ('me/settings') | |
| getSettings(() user: User) { | |
| const result = this.auth.getSettings(user.id); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { settings: result.settings }; | |
| } | |
| ('avatar') | |
| (200) | |
| (FileInterceptor('avatar', AVATAR_UPLOAD)) | |
| async avatar(() user: User, () file: Express.Multer.File | undefined) { | |
| if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) { | |
| throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403); | |
| } | |
| if (!file) { | |
| throw new HttpException({ error: 'No image uploaded' }, 400); | |
| } | |
| return this.auth.saveAvatar(user.id, file.filename); | |
| } | |
| ('avatar') | |
| async deleteAvatar(() user: User) { | |
| return this.auth.deleteAvatar(user.id); | |
| } | |
| ('users') | |
| users(() user: User) { | |
| return { users: this.auth.listUsers(user.id) }; | |
| } | |
| ('validate-keys') | |
| async validateKeys(() user: User) { | |
| const result = await this.auth.validateKeys(user.id); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { maps: result.maps, weather: result.weather, maps_details: result.maps_details }; | |
| } | |
| ('app-settings') | |
| getAppSettings(() user: User) { | |
| const result = this.auth.getAppSettings(user.id); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return result.data; | |
| } | |
| ('app-settings') | |
| updateAppSettings(() user: User, () body: unknown, () req: Request) { | |
| const result = this.auth.updateAppSettings(user.id, body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails }); | |
| return { success: true }; | |
| } | |
| ('travel-stats') | |
| travelStats(() user: User) { | |
| return this.auth.getTravelStats(user.id); | |
| } | |
| ('mfa/setup') | |
| (200) | |
| async mfaSetup(() user: User) { | |
| const result = this.auth.setupMfa(user.id, user.email); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| try { | |
| const qr_svg = await result.qrPromise!; | |
| return { secret: result.secret, otpauth_url: result.otpauth_url, qr_svg }; | |
| } catch (err) { | |
| console.error('[MFA] QR code generation error:', err); | |
| throw new HttpException({ error: 'Could not generate QR code' }, 500); | |
| } | |
| } | |
| ('mfa/enable') | |
| (200) | |
| mfaEnable(() user: User, () body: { code?: unknown }, () req: Request) { | |
| this.limit('mfa', req, 5); | |
| const result = this.auth.enableMfa(user.id, body.code); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); | |
| return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes }; | |
| } | |
| ('mfa/disable') | |
| (200) | |
| mfaDisable(() user: User, () body: unknown, () req: Request) { | |
| this.limit('login', req, 5); | |
| const result = this.auth.disableMfa(user.id, user.email, body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); | |
| return { success: true, mfa_enabled: result.mfa_enabled }; | |
| } | |
| ('mcp-tokens') | |
| listMcpTokens(() user: User) { | |
| return { tokens: this.auth.listMcpTokens(user.id) }; | |
| } | |
| ('mcp-tokens') | |
| (201) | |
| createMcpToken(() user: User, () body: { name?: unknown }, () req: Request) { | |
| this.limit('login', req, 5); | |
| const result = this.auth.createMcpToken(user.id, body.name); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { token: result.token }; | |
| } | |
| ('mcp-tokens/:id') | |
| deleteMcpToken(() user: User, ('id') id: string) { | |
| const result = this.auth.deleteMcpToken(user.id, id); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { success: true }; | |
| } | |
| ('ws-token') | |
| (200) | |
| wsToken(() user: User) { | |
| const result = this.auth.createWsToken(user.id); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { token: result.token }; | |
| } | |
| ('resource-token') | |
| (200) | |
| resourceToken(() user: User, () body: { purpose?: unknown }) { | |
| const token = this.auth.createResourceToken(user.id, body.purpose); | |
| if (!token) { | |
| throw new HttpException({ error: 'Service unavailable' }, 503); | |
| } | |
| return token; | |
| } | |
| } | |