From 9c1106586a5ad937659ce99169bdd411dd09b618 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Sat, 22 Aug 2026 11:55:13 +0800 Subject: [PATCH] fix(security): harden auth, upload, and API configuration - Lock public registration to first-user bootstrap (403 afterwards) - Require JwtAuthGuard on upload + whitelist png/jpg/webp/gif (SVG/XSS blocked) - Add global throttling (login/register 5/min, upload 10/min) - Add helmet security headers; serve uploads with nosniff - Replace permissive CORS (origin:true+credentials) with CORS_ORIGINS whitelist - Disable Swagger outside development; sanitize 500 error responses - Enforce 32+ char JWT_SECRET; make token expiry configurable (TOKEN_EXPIRES_IN) - Re-check user in DB on every JWT validation (revocation on user delete) - Dummy bcrypt compare to prevent login user-enumeration via timing - Map malformed BigInt inputs to 400 instead of 500 - Widen .gitignore to .env* and add apps/api/.env.example - Disable Nuxt devtools and sourcemaps --- .gitignore | 6 +- apps/api/.env.example | 17 +++ apps/api/package.json | 2 + apps/api/src/app.module.ts | 18 ++- apps/api/src/auth/auth.controller.ts | 17 ++- apps/api/src/auth/auth.module.ts | 7 +- apps/api/src/auth/auth.service.spec.ts | 109 +++++++++--------- apps/api/src/auth/auth.service.ts | 33 +++--- apps/api/src/auth/strategies/jwt.strategy.ts | 22 +++- .../common/filters/http-exception.filter.ts | 31 ++++- apps/api/src/main.ts | 44 ++++--- apps/api/src/upload/upload.controller.ts | 18 ++- apps/website/nuxt.config.ts | 3 +- pnpm-lock.yaml | 25 ++++ 14 files changed, 240 insertions(+), 112 deletions(-) create mode 100644 apps/api/.env.example diff --git a/.gitignore b/.gitignore index 91b3ee1..fdadca6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,8 @@ yarn-error.log* lerna-debug.log* # Environment -.env -.env.local -.env.development -.env.*.local +.env* +!.env.example # OS .DS_Store diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..4ffd602 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,17 @@ +# Prisma connection string (PostgreSQL) +DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website + +# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"` +# Must be at least 32 characters. +JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET + +# Access token lifetime (jwt-rest compatible, e.g. 30m, 12h, 7d) +TOKEN_EXPIRES_IN=7d + +# Comma-separated list of allowed CORS origins (leave empty to disable CORS) +CORS_ORIGINS=http://localhost:5173 + +# Global rate limit per minute (per IP) +THROTTLE_LIMIT=120 + +PORT=3001 diff --git a/apps/api/package.json b/apps/api/package.json index 3859875..7dc870f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -34,6 +34,7 @@ "@nestjs/platform-express": "^10.3.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.17", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.8.0", "@types/multer": "^2.2.0", "axios": "^1.6.5", @@ -41,6 +42,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "express": "^4.21.0", + "helmet": "^8.3.0", "multer": "^2.2.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bd82d6f..7dfc00e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { CountriesModule } from './countries/countries.module'; @@ -18,6 +20,14 @@ import { UploadModule } from './upload/upload.module'; ConfigModule.forRoot({ isGlobal: true, }), + // Global rate limiting: 120 req/min per IP. Stricter limits are set + // per-endpoint with @Throttle (auth, upload). + ThrottlerModule.forRoot([ + { + ttl: 60_000, + limit: Number(process.env.THROTTLE_LIMIT ?? 120), + }, + ]), PrismaModule, AuthModule, CountriesModule, @@ -31,5 +41,11 @@ import { UploadModule } from './upload/upload.module'; PublicModule, UploadModule, ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 0c6af18..55c919d 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,16 +1,10 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; -import { - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; -import { - LoginResponseDto, - UserPublicDto, -} from './dto/auth-response.dto'; +import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto'; @ApiTags('auth') @Controller('auth') @@ -19,15 +13,18 @@ export class AuthController { @Post('register') @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: 'Register a new admin user' }) + @Throttle({ default: { limit: 5, ttl: 60_000 } }) + @ApiOperation({ summary: 'Register the first admin user (bootstrap only)' }) @ApiResponse({ status: 201, type: UserPublicDto }) @ApiResponse({ status: 409, description: 'Username already exists' }) + @ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' }) register(@Body() dto: RegisterDto): Promise { return this.authService.register(dto) as unknown as Promise; } @Post('login') @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 5, ttl: 60_000 } }) @ApiOperation({ summary: 'Login and obtain a JWT' }) @ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index ced7d44..d982102 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy'; if (!secret) { throw new Error('JWT_SECRET must be configured'); } + if (secret.length < 32) { + throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)'); + } return { secret, - signOptions: { expiresIn: '7d' }, + signOptions: { + expiresIn: config.get('TOKEN_EXPIRES_IN') ?? '7d', + }, }; }, }), diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index b8b3c3c..23e3529 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -1,101 +1,104 @@ import { Test } from '@nestjs/testing'; import { JwtModule } from '@nestjs/jwt'; -import { ConfigModule } from '@nestjs/config'; -import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { AuthService } from './auth.service'; import { PrismaService } from '../prisma/prisma.service'; describe('AuthService', () => { let service: AuthService; - let prisma: PrismaService; - const createdUsernames: string[] = []; + let prisma: { + user: { + count: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + }; + + const HASH = bcrypt.hashSync('plain-pwd', 10); + const dbUser = { + id: 1n, + username: 'alice', + passwordHash: HASH, + createdAt: new Date('2026-01-01T00:00:00Z'), + }; beforeAll(async () => { + prisma = { + user: { count: jest.fn(), findUnique: jest.fn(), create: jest.fn() }, + }; const moduleRef = await Test.createTestingModule({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), JwtModule.register({ - secret: 'test-secret', + secret: 'a'.repeat(32), signOptions: { expiresIn: '1h' }, }), ], - providers: [AuthService, PrismaService], + providers: [AuthService, { provide: PrismaService, useValue: prisma }], }).compile(); - service = moduleRef.get(AuthService); - prisma = moduleRef.get(PrismaService); - await prisma.onModuleInit(); }); - afterAll(async () => { - // Cleanup created test users - if (createdUsernames.length) { - await prisma.user.deleteMany({ - where: { username: { in: createdUsernames } }, - }); - } - await prisma.onModuleDestroy(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); + beforeEach(() => { + jest.clearAllMocks(); }); describe('register', () => { - it('creates a new user and stores a hashed password', async () => { - const username = `test_reg_${Date.now()}`; - createdUsernames.push(username); + it('creates the first user with a hashed password', async () => { + prisma.user.count.mockResolvedValueOnce(0); + prisma.user.findUnique.mockResolvedValueOnce(null); + prisma.user.create.mockResolvedValueOnce(dbUser); - const user = await service.register({ username, password: 'plain-pwd' }); + const user = await service.register({ + username: 'alice', + password: 'plain-pwd', + }); - expect(user.username).toBe(username); - expect(user.id).toBeTruthy(); + expect(user.username).toBe('alice'); + const created = prisma.user.create.mock.calls[0][0].data; + expect(created.passwordHash).not.toBe('plain-pwd'); + await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true); + }); - const stored = await prisma.user.findUnique({ where: { username } }); - expect(stored).not.toBeNull(); - expect(stored?.passwordHash).not.toBe('plain-pwd'); - const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash); - expect(matches).toBe(true); + it('refuses registration once a user exists (bootstrap lock)', async () => { + prisma.user.count.mockResolvedValueOnce(1); + await expect( + service.register({ username: 'mallory', password: 'evil-pwd' }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.user.create).not.toHaveBeenCalled(); }); it('throws ConflictException for duplicate usernames', async () => { - const username = `test_dup_${Date.now()}`; - createdUsernames.push(username); - - await service.register({ username, password: 'pwd1234' }); + prisma.user.count.mockResolvedValueOnce(0); + prisma.user.findUnique.mockResolvedValueOnce(dbUser); await expect( - service.register({ username, password: 'pwd5678' }), + service.register({ username: 'alice', password: 'pwd5678' }), ).rejects.toBeInstanceOf(ConflictException); }); }); describe('login', () => { it('returns an access token for valid credentials', async () => { - const username = `test_login_${Date.now()}`; - createdUsernames.push(username); - await service.register({ username, password: 'correct-pwd' }); - - const result = await service.login({ username, password: 'correct-pwd' }); - expect(result.accessToken).toEqual(expect.any(String)); - const parts = result.accessToken.split('.'); - expect(parts.length).toBe(3); - expect(result.user.username).toBe(username); + prisma.user.findUnique.mockResolvedValueOnce(dbUser); + const result = await service.login({ + username: 'alice', + password: 'plain-pwd', + }); + expect(result.accessToken.split('.').length).toBe(3); + expect(result.user.username).toBe('alice'); }); it('throws UnauthorizedException for wrong password', async () => { - const username = `test_wrong_${Date.now()}`; - createdUsernames.push(username); - await service.register({ username, password: 'right-pwd' }); - + prisma.user.findUnique.mockResolvedValueOnce(dbUser); await expect( - service.login({ username, password: 'wrong-pwd' }), + service.login({ username: 'alice', password: 'wrong-pwd' }), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('throws UnauthorizedException for unknown user', async () => { + prisma.user.findUnique.mockResolvedValueOnce(null); await expect( - service.login({ username: 'no-such-user-xyz', password: 'whatever' }), + service.login({ username: 'no-such-user', password: 'whatever' }), ).rejects.toBeInstanceOf(UnauthorizedException); }); }); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 1e565ae..910d80b 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,5 +1,6 @@ import { ConflictException, + ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; @@ -22,7 +23,13 @@ export interface LoginResult { } const BCRYPT_ROUNDS = 10; -const TOKEN_EXPIRES_IN = '7d'; +const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '7d'; + +/** + * Compared against when the username does not exist so that login takes + * the same time either way (prevents user enumeration via timing). + */ +const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6'; @Injectable() export class AuthService { @@ -32,10 +39,15 @@ export class AuthService { ) {} /** - * Registers a brand-new admin user. Throws {@link ConflictException} - * if the username is already taken. + * Bootstrap-only registration: allowed just while the instance has no + * users. Once an admin exists the endpoint refuses to create accounts + * (use database seeding / an operator flow instead). */ async register(dto: RegisterDto): Promise { + const userCount = await this.prisma.user.count(); + if (userCount > 0) { + throw new ForbiddenException('Registration is disabled'); + } const existing = await this.prisma.user.findUnique({ where: { username: dto.username }, }); @@ -56,11 +68,10 @@ export class AuthService { const user = await this.prisma.user.findUnique({ where: { username: dto.username }, }); - if (!user) { - throw new UnauthorizedException('Invalid credentials'); - } - const ok = await bcrypt.compare(dto.password, user.passwordHash); - if (!ok) { + // Always run a bcrypt compare (against a dummy hash when the user is + // unknown) so response timing cannot be used to enumerate usernames. + const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH); + if (!user || !ok) { throw new UnauthorizedException('Invalid credentials'); } const payload: JwtPayload = { @@ -73,11 +84,7 @@ export class AuthService { return { accessToken, user: this.toPublic(user) }; } - private toPublic(user: { - id: bigint; - username: string; - createdAt: Date; - }): PublicUser { + private toPublic(user: { id: bigint; username: string; createdAt: Date }): PublicUser { return { id: user.id.toString(), username: user.username, diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index c7a19bb..b10b9cb 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../prisma/prisma.service'; /** * Shape of the JWT we issue. @@ -15,11 +16,17 @@ export interface JwtPayload { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { const secret = config.get('JWT_SECRET'); if (!secret) { throw new Error('JWT_SECRET is not configured'); } + if (secret.length < 32) { + throw new Error('JWT_SECRET must be at least 32 characters'); + } super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -29,12 +36,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) { /** * Runs on every authenticated request. The returned object becomes - * `request.user` for downstream controllers. + * `request.user` for downstream controllers. The user is re-checked in + * the database so tokens of deleted users stop working immediately. */ - validate(payload: JwtPayload): { id: bigint; username: string } { + async validate(payload: JwtPayload): Promise<{ id: bigint; username: string }> { if (!payload?.sub || !payload.username) { throw new UnauthorizedException('Invalid token payload'); } - return { id: BigInt(payload.sub), username: payload.username }; + const user = await this.prisma.user + .findUnique({ where: { id: BigInt(payload.sub) } }) + .catch(() => null); + if (!user || user.username !== payload.username) { + throw new UnauthorizedException('Invalid token'); + } + return { id: user.id, username: user.username }; } } diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/filters/http-exception.filter.ts index d875604..1fd7901 100644 --- a/apps/api/src/common/filters/http-exception.filter.ts +++ b/apps/api/src/common/filters/http-exception.filter.ts @@ -32,9 +32,24 @@ export class HttpExceptionFilter implements ExceptionFilter { const request = ctx.getRequest(); const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + // Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client + // errors โ€” map them to 400 instead of leaking a 500. + if ( + status === HttpStatus.INTERNAL_SERVER_ERROR && + exception instanceof Error && + /Cannot convert .+ to (a BigInt|number)/i.test(exception.message) + ) { + response.status(HttpStatus.BAD_REQUEST).json({ + statusCode: HttpStatus.BAD_REQUEST, + message: 'Invalid numeric identifier', + error: 'BadRequestError', + timestamp: new Date().toISOString(), + path: request.url, + }); + return; + } let message: string | string[] = 'Internal server error'; let error = 'InternalServerError'; @@ -51,11 +66,15 @@ export class HttpExceptionFilter implements ExceptionFilter { message = exception.message; } } else if (exception instanceof Error) { - message = exception.message; - error = exception.name; + // Unexpected errors (Prisma, driver, ...) may contain SQL or + // connection details โ€” never send them to the client. + this.logger.error( + `${request.method} ${request.url} -> ${status} ${exception.message}`, + exception.stack, + ); } - if (status >= 500) { + if (status >= 500 && exception instanceof HttpException) { this.logger.error( `${request.method} ${request.url} -> ${status} ${message}`, exception instanceof Error ? exception.stack : undefined, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3eca3d0..689aa05 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import helmet from 'helmet'; import { json } from 'express'; import { join } from 'path'; import { AppModule } from './app.module'; @@ -28,15 +29,24 @@ async function bootstrap() { }), ); - // CORS - app.enableCors({ - origin: true, - credentials: true, - }); + // Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...) + app.use(helmet()); - // Serve uploaded files + // CORS: only origins listed in CORS_ORIGINS (comma-separated) are allowed. + // Authentication uses Bearer headers, so credentialed CORS is not needed. + const corsOrigins = (process.env.CORS_ORIGINS ?? '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean); + app.enableCors(corsOrigins.length > 0 ? { origin: corsOrigins } : undefined); + + // Serve uploaded files. nosniff prevents browsers from sniffing a + // non-image content type out of an uploaded file. app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads/', + setHeaders: (res) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + }, }); app.useStaticAssets(join(process.cwd(), 'public'), { prefix: '/assets/', @@ -55,21 +65,23 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); - // Swagger - const config = new DocumentBuilder() - .setTitle('InkReach Product Center API') - .setDescription('Backend API for InkReach Product Center') - .setVersion('1.0') - .addBearerAuth() - .build(); + // Swagger is only exposed outside production to avoid leaking the + // full admin API surface. + if (process.env.NODE_ENV !== 'production') { + const config = new DocumentBuilder() + .setTitle('InkReach Product Center API') + .setDescription('Backend API for InkReach Product Center') + .setVersion('1.0') + .addBearerAuth() + .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } const port = process.env.PORT ?? 3001; await app.listen(port, '0.0.0.0'); console.log(`๐Ÿš€ Application is running on: http://0.0.0.0:${port}`); - console.log(`๐Ÿ“š Swagger documentation: http://0.0.0.0:${port}/api/docs`); } // Make JSON.stringify aware of BigInt so outgoing responses containing diff --git a/apps/api/src/upload/upload.controller.ts b/apps/api/src/upload/upload.controller.ts index 604dad6..48115af 100644 --- a/apps/api/src/upload/upload.controller.ts +++ b/apps/api/src/upload/upload.controller.ts @@ -1,33 +1,45 @@ import { Controller, Post, + UseGuards, UseInterceptors, UploadedFile, BadRequestException, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { FileInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { extname, join } from 'path'; import { randomUUID } from 'crypto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; const UPLOAD_DIR = join(process.cwd(), 'uploads'); +// Explicit safe-image whitelist. SVG is deliberately excluded: it can +// carry scripts and is served from the same origin (stored XSS). +const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i; +const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i; + +@UseGuards(JwtAuthGuard) @Controller('upload') export class UploadController { @Post('image') + @Throttle({ default: { limit: 10, ttl: 60_000 } }) @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ destination: UPLOAD_DIR, filename: (_req, file, cb) => { - const ext = extname(file.originalname) || '.png'; + const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname)) + ? extname(file.originalname).toLowerCase() + : '.png'; cb(null, `${randomUUID()}${ext}`); }, }), limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { - if (!file.mimetype.startsWith('image/')) { - return cb(new BadRequestException('ไป…ๆ”ฏๆŒๅ›พ็‰‡ๆ–‡ไปถ'), false); + if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) { + return cb(new BadRequestException('ไป…ๆ”ฏๆŒ png/jpg/webp/gif ๅ›พ็‰‡'), false); } cb(null, true); }, diff --git a/apps/website/nuxt.config.ts b/apps/website/nuxt.config.ts index 6476815..5e1f376 100644 --- a/apps/website/nuxt.config.ts +++ b/apps/website/nuxt.config.ts @@ -1,6 +1,7 @@ export default defineNuxtConfig({ compatibilityDate: '2025-07-15', - devtools: { enabled: true }, + devtools: { enabled: false }, + sourcemap: { server: false, client: false }, modules: ['@nuxtjs/seo'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43c7b54..f9f44a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@nestjs/swagger': specifier: ^7.1.17 version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2) '@prisma/client': specifier: ^5.8.0 version: 5.22.0(prisma@5.22.0) @@ -120,6 +123,9 @@ importers: express: specifier: ^4.21.0 version: 4.22.1 + helmet: + specifier: ^8.3.0 + version: 8.3.0 multer: specifier: ^2.2.0 version: 2.2.0 @@ -1401,6 +1407,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -5488,6 +5501,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + hey-listen@1.0.8: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} @@ -9907,6 +9924,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@noble/hashes@1.8.0': {} '@nodable/entities@2.2.0': {} @@ -14247,6 +14270,8 @@ snapshots: dependencies: '@types/hast': 3.0.5 + helmet@8.3.0: {} + hey-listen@1.0.8: {} hookable@5.5.3: {}