diff --git a/.opencode/.mcp.json b/.opencode/.mcp.json new file mode 100644 index 0000000..975f073 --- /dev/null +++ b/.opencode/.mcp.json @@ -0,0 +1,20 @@ +{ + "_meta": { + "generatedBy": "hiai-opencode", + "version": 1, + "generatedAt": "2026-09-16T22:09:21.466Z" + }, + "mcpServers": { + "sequential-thinking": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking" + ] + }, + "grep_app": { + "type": "http", + "url": "https://mcp.grep.app" + } + } +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index d044f99..074d60c 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -9,6 +9,12 @@ declare const process: any; const prisma = new PrismaClient(); async function main() { + if (process.env.NODE_ENV === 'production' && process.env.FORCE_SEED_WIPE !== 'true') { + throw new Error( + 'CRITICAL: Seed wiping is BLOCKED in production! Set FORCE_SEED_WIPE=true if you really intend to wipe the database.', + ); + } + console.log('Wiping existing database records...'); await prisma.reminderCompletion.deleteMany(); await prisma.reminder.deleteMany(); @@ -31,9 +37,10 @@ async function main() { console.log('Database successfully wiped.'); console.log('Creating Admin User...'); - const adminHashedPassword = await bcrypt.hash('admin123', 10); + const rawAdminPassword = process.env.ADMIN_PASSWORD || 'AdminPassword_ChangeMe_2026!'; + const adminHashedPassword = await bcrypt.hash(rawAdminPassword, 10); await prisma.user.upsert({ - where: { email: 'admin@canina.ir' }, + where: { email: process.env.ADMIN_EMAIL || 'admin@canina.ir' }, update: { password: adminHashedPassword, role: 'Admin', @@ -42,7 +49,7 @@ async function main() { id: '12345678-1234-1234-1234-123456789012', firstName: 'مدیر', lastName: 'سیستم', - email: 'admin@canina.ir', + email: process.env.ADMIN_EMAIL || 'admin@canina.ir', password: adminHashedPassword, role: 'Admin', mobile: '09120000001', diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index d07e6e3..cd1b1ae 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -8,6 +8,7 @@ import { Param, Query, UseGuards, + BadRequestException, } from '@nestjs/common'; import { AdminService, CouponInput } from './admin.service'; import { ProductDto } from './dto/product.dto'; @@ -28,9 +29,13 @@ import { import { CreateUserDto, UpdateUserDto } from './dto/user.dto'; import { SettingsService } from '../settings/settings.service'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - مدیریت سیستم') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin') export class AdminController { constructor( @@ -109,9 +114,20 @@ export class AdminController { @Body('type') type: 'deposit' | 'withdrawal' | 'refund', @Body('description') description?: string, ) { + const numAmount = Number(amount); + if (!numAmount || isNaN(numAmount) || numAmount <= 0) { + throw new BadRequestException('مبلغ باید یک عدد مثبت باشد'); + } + if (numAmount > 100_000_000_000) { + throw new BadRequestException('مبلغ بیش از حد مجاز است'); + } + if (!['deposit', 'withdrawal', 'refund'].includes(type)) { + throw new BadRequestException('نوع تراکنش معتبر نیست'); + } + const result = await this.adminService.adjustUserWallet( id, - Number(amount), + numAmount, type, description, ); diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index 8df5671..7493d60 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -45,6 +45,6 @@ import { SettingsModule } from '../settings/settings.module'; SslService, ApiKeysService, ], - exports: [ApiKeysService], + exports: [ApiKeysService, MediaService], }) export class AdminModule {} diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 4893611..ad48308 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -61,6 +61,16 @@ export class CouponInput { targets?: CouponTargetInput[]; } +const ALLOWED_USER_ROLES = [ + 'User_PetOwner', + 'User_Vet', + 'User_Clinic', + 'User_PetShop', + 'Customer', + 'Admin', + 'SUPER_ADMIN', +]; + @Injectable() export class AdminService { constructor( @@ -244,6 +254,10 @@ export class AdminService { ? await bcrypt.hash(passwordToHash, 10) : ''; + if (dto.role && !ALLOWED_USER_ROLES.includes(dto.role)) { + throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است'); + } + return this.prisma.user.create({ data: { firstName: dto.firstName.trim(), @@ -307,7 +321,12 @@ export class AdminService { if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile; if (normalizedEmail !== undefined) updateData.email = normalizedEmail || null; - if (dto.role !== undefined) updateData.role = dto.role; + if (dto.role !== undefined) { + if (!ALLOWED_USER_ROLES.includes(dto.role)) { + throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است'); + } + updateData.role = dto.role; + } if (dto.walletBalance !== undefined) { updateData.walletBalance = new Prisma.Decimal(dto.walletBalance); } @@ -322,6 +341,9 @@ export class AdminService { } async updateUserRole(id: string, role: string) { + if (!ALLOWED_USER_ROLES.includes(role)) { + throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است'); + } return this.prisma.user.update({ where: { id }, data: { role }, diff --git a/backend/src/admin/api-keys.controller.ts b/backend/src/admin/api-keys.controller.ts index 55e5c8b..010a0e2 100644 --- a/backend/src/admin/api-keys.controller.ts +++ b/backend/src/admin/api-keys.controller.ts @@ -19,9 +19,13 @@ import { ApiResponse, } from '@nestjs/swagger'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - مدیریت کلیدهای دسترسی (API Keys)') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/api-keys') export class ApiKeysController { constructor(private readonly apiKeysService: ApiKeysService) {} diff --git a/backend/src/admin/api-keys.service.ts b/backend/src/admin/api-keys.service.ts index c9085c2..efaa236 100644 --- a/backend/src/admin/api-keys.service.ts +++ b/backend/src/admin/api-keys.service.ts @@ -150,12 +150,15 @@ export class ApiKeysService { }) .catch(() => {}); + const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase()); + const hasAdminScope = scopes.includes('*') || scopes.includes('admin') || scopes.includes('all'); + return { id: key.id, name: key.name, - role: 'ADMIN', // Grants admin level access for n8n automation + role: hasAdminScope ? 'Admin' : 'USER', email: 'apikey-system@canina.ir', - scopes: key.scopes.split(',').map((s) => s.trim()), + scopes, isApiKey: true, }; } diff --git a/backend/src/admin/blogs.controller.ts b/backend/src/admin/blogs.controller.ts index b948cad..d33317e 100644 --- a/backend/src/admin/blogs.controller.ts +++ b/backend/src/admin/blogs.controller.ts @@ -22,9 +22,13 @@ import { } from '@nestjs/swagger'; import { PaginationDto } from '../common/dto/pagination.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - مدیریت مقالات (بلاگ)') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/blogs') export class BlogsController { constructor(private readonly blogsService: BlogsService) {} diff --git a/backend/src/admin/categories.controller.ts b/backend/src/admin/categories.controller.ts index b3143db..09e0bdc 100644 --- a/backend/src/admin/categories.controller.ts +++ b/backend/src/admin/categories.controller.ts @@ -21,9 +21,13 @@ import { import { PaginationDto } from '../common/dto/pagination.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - مدیریت دسته‌بندی‌ها') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/categories') export class CategoriesController { constructor(private readonly categoriesService: CategoriesService) {} diff --git a/backend/src/admin/media.controller.ts b/backend/src/admin/media.controller.ts index 90d3a74..806d2c6 100644 --- a/backend/src/admin/media.controller.ts +++ b/backend/src/admin/media.controller.ts @@ -14,15 +14,26 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { MediaService } from './media.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', +]; + @ApiTags('Admin - مدیریت رسانه (تصاویر)') @ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/media') export class MediaController { constructor(private readonly mediaService: MediaService) {} - @UseGuards(JwtAuthGuard) @Get() @ApiOperation({ summary: 'لیست فایل‌های رسانه' }) async getAllMedia() { @@ -31,15 +42,31 @@ export class MediaController { } @Post('upload') - @ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' }) - @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: 'آپلود فایل جدید توسط ادمین' }) + @UseInterceptors( + FileInterceptor('file', { + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: (_req, file, callback) => { + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + return callback( + new BadRequestException( + 'فرمت فایل مجاز نیست. تنها فرمت‌های تصویری و PDF پشتیبانی می‌شوند.', + ), + false, + ); + } + callback(null, true); + }, + }), + ) async uploadFile(@UploadedFile() file: Express.Multer.File) { if (!file) throw new BadRequestException('File is missing'); const data = await this.mediaService.uploadFile(file); return { success: true, data }; } - @UseGuards(JwtAuthGuard) @Delete('bulk') @ApiOperation({ summary: 'حذف دسته‌جمعی فایل‌ها' }) async deleteManyMedia(@Body('ids') ids: string[]) { @@ -50,7 +77,6 @@ export class MediaController { return { success: true, data }; } - @UseGuards(JwtAuthGuard) @Delete(':id') @ApiOperation({ summary: 'حذف فایل' }) async deleteMedia(@Param('id') id: string) { @@ -58,7 +84,6 @@ export class MediaController { return { success: true, data }; } - @UseGuards(JwtAuthGuard) @Put(':id') @ApiOperation({ summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)', diff --git a/backend/src/admin/pets.controller.ts b/backend/src/admin/pets.controller.ts index e75f1c8..ba632a1 100644 --- a/backend/src/admin/pets.controller.ts +++ b/backend/src/admin/pets.controller.ts @@ -17,9 +17,13 @@ import { import { PaginationDto } from '../common/dto/pagination.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - مدیریت حیوانات خانگی') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/pets') export class PetsController { constructor(private readonly petsService: PetsService) {} diff --git a/backend/src/admin/reports.controller.ts b/backend/src/admin/reports.controller.ts index b34e6aa..296ce1b 100644 --- a/backend/src/admin/reports.controller.ts +++ b/backend/src/admin/reports.controller.ts @@ -8,13 +8,17 @@ import { ApiQuery, } from '@nestjs/swagger'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - گزارشات') @ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/reports') export class ReportsController { constructor(private readonly reportsService: ReportsService) {} - @UseGuards(JwtAuthGuard) @Get() @ApiOperation({ summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر', diff --git a/backend/src/admin/wiki.controller.ts b/backend/src/admin/wiki.controller.ts index 0cc45d1..a5374f1 100644 --- a/backend/src/admin/wiki.controller.ts +++ b/backend/src/admin/wiki.controller.ts @@ -21,9 +21,13 @@ import { import { PaginationDto } from '../common/dto/pagination.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + @ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('admin/wiki') export class WikiController { constructor(private readonly wikiService: WikiService) {} diff --git a/backend/src/auth/auth.constants.ts b/backend/src/auth/auth.constants.ts index dc859f8..c1e5d9e 100644 --- a/backend/src/auth/auth.constants.ts +++ b/backend/src/auth/auth.constants.ts @@ -1,11 +1,29 @@ -export const DEFAULT_JWT_SECRET = - 'canina_jwt_fallback_access_secret_32_characters_minimum_len_2026'; +import * as crypto from 'crypto'; -export const DEFAULT_JWT_REFRESH_SECRET = - 'canina_jwt_fallback_refresh_secret_32_characters_minimum_len_2026'; +const devAccessSecret = + process.env.NODE_ENV !== 'production' + ? crypto.randomBytes(32).toString('hex') + : undefined; -export const getJwtSecret = (): string => - process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || DEFAULT_JWT_SECRET; +const devRefreshSecret = + process.env.NODE_ENV !== 'production' + ? crypto.randomBytes(32).toString('hex') + : undefined; -export const getJwtRefreshSecret = (): string => - process.env.JWT_REFRESH_SECRET || DEFAULT_JWT_REFRESH_SECRET; +export const getJwtSecret = (): string => { + const secret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET; + if (secret) return secret; + if (process.env.NODE_ENV === 'production') { + throw new Error('FATAL SECURITY ERROR: JWT_ACCESS_SECRET must be defined in production!'); + } + return devAccessSecret!; +}; + +export const getJwtRefreshSecret = (): string => { + const secret = process.env.JWT_REFRESH_SECRET; + if (secret) return secret; + if (process.env.NODE_ENV === 'production') { + throw new Error('FATAL SECURITY ERROR: JWT_REFRESH_SECRET must be defined in production!'); + } + return devRefreshSecret!; +}; diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 15b02da..a493847 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -92,6 +92,7 @@ export class AuthController { return this.authService.login(loginDto); } + @Throttle({ default: { limit: 5, ttl: 300000 } }) // 5 attempts per 5 minutes @Post('admin-login') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' }) @@ -116,11 +117,18 @@ export class AuthController { return this.authService.refresh(req.user); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) @Post('logout') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'خروج از حساب کاربری' }) + @ApiOperation({ summary: 'خروج از حساب کاربری و ابطال توکن' }) @ApiOkResponse({ description: 'با موفقیت خارج شدید' }) - logout() { + async logout(@Req() req: any) { + const authHeader = req.headers?.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.replace('Bearer ', '').trim(); + await this.authService.blacklistToken(token); + } return { success: true, message: 'با موفقیت خارج شدید' }; } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index ecb3317..bac1fb0 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -8,11 +8,14 @@ import { UsersModule } from '../users/users.module'; import { AdminModule } from '../admin/admin.module'; import { getJwtSecret } from './auth.constants'; +import { RedisModule } from '../redis/redis.module'; + @Module({ imports: [ UsersModule, forwardRef(() => AdminModule), PassportModule, + RedisModule, JwtModule.registerAsync({ useFactory: () => ({ secret: getJwtSecret(), diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 325cc70..d09fbee 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -155,10 +155,11 @@ export class AuthService { const payload = { sub: user.id, phoneNumber: user.mobile }; const accessToken = this.jwtService.sign(payload); + const { password: _p, ...safeUser } = user; return { success: true, data: { - user, + user: safeUser, accessToken, }, }; @@ -210,10 +211,11 @@ export class AuthService { const payload = { sub: user.id, phoneNumber: user.mobile }; const accessToken = this.jwtService.sign(payload); + const { password: _p, ...safeUser } = user; return { success: true, data: { - user, + user: safeUser, accessToken, }, }; @@ -251,18 +253,17 @@ export class AuthService { const payload = { sub: user.id, phoneNumber: user.mobile }; const accessToken = this.jwtService.sign(payload); + const { password: _pw, ...safeUser } = user; return { success: true, data: { - user, + user: safeUser, accessToken, }, }; } async adminLogin(body: AdminLoginInput) { - const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina.ir'; - const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; // First check database for user with role 'Admin' or 'SUPER_ADMIN' const dbAdmin = await this.prisma.user.findFirst({ @@ -280,34 +281,59 @@ export class AuthService { email: dbAdmin.email, role: dbAdmin.role, }; + const { password: _p, ...safeUser } = dbAdmin; return { success: true, data: { - user: dbAdmin, + user: safeUser, accessToken: this.jwtService.sign(payload), }, }; } } - // Fallback to validated environment variable credentials - if (body.email === adminEmail && body.password === adminPassword) { - const payload = { - sub: '12345678-1234-1234-1234-123456789012', - email: body.email, - role: 'Admin', - }; - return { - success: true, - data: { - user: { - id: '12345678-1234-1234-1234-123456789012', - email: body.email, - role: 'Admin', + // Fallback to validated environment variable credentials only if configured securely + const adminEmail = process.env.ADMIN_EMAIL; + const adminPassword = process.env.ADMIN_PASSWORD; + + if ( + adminEmail && + adminPassword && + adminPassword.length >= 8 && + body.email && + body.password + ) { + const emailMatches = + body.email.length === adminEmail.length && + crypto.timingSafeEqual( + Buffer.from(crypto.createHash('sha256').update(body.email).digest()), + Buffer.from(crypto.createHash('sha256').update(adminEmail).digest()), + ); + + const passwordMatches = + crypto.timingSafeEqual( + Buffer.from(crypto.createHash('sha256').update(body.password).digest()), + Buffer.from(crypto.createHash('sha256').update(adminPassword).digest()), + ); + + if (emailMatches && passwordMatches) { + const payload = { + sub: '12345678-1234-1234-1234-123456789012', + email: adminEmail, + role: 'Admin', + }; + return { + success: true, + data: { + user: { + id: '12345678-1234-1234-1234-123456789012', + email: body.email, + role: 'Admin', + }, + accessToken: this.jwtService.sign(payload), }, - accessToken: this.jwtService.sign(payload), - }, - }; + }; + } } throw new BadRequestException({ @@ -336,4 +362,21 @@ export class AuthService { }, }; } + + async blacklistToken(token: string) { + try { + const decoded: any = this.jwtService.decode(token); + let ttl = 7 * 24 * 3600; // default 7 days + if (decoded && decoded.exp) { + const remaining = decoded.exp - Math.floor(Date.now() / 1000); + if (remaining > 0) { + ttl = remaining; + } + } + await this.redisService.set(`bl_token:${token}`, '1', ttl); + } catch { + // Fallback: cache for 7 days + await this.redisService.set(`bl_token:${token}`, '1', 7 * 24 * 3600); + } + } } diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index bd827ad..2c30539 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -4,6 +4,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { UsersService } from '../users/users.service'; import { getJwtSecret } from './auth.constants'; +import { RedisService } from '../redis/redis.service'; +import type { Request } from 'express'; + export interface JwtPayload { sub: string; email?: string; @@ -13,18 +16,37 @@ export interface JwtPayload { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(private readonly usersService: UsersService) { + constructor( + private readonly usersService: UsersService, + private readonly redisService: RedisService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: getJwtSecret(), + passReqToCallback: true, }); } - async validate(payload: JwtPayload) { - // Bypass DB lookup for local admin user to prevent UUID casting errors + async validate(req: Request, payload: JwtPayload) { + const rawToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req); + if (rawToken) { + const isBlacklisted = await this.redisService.get(`bl_token:${rawToken}`); + if (isBlacklisted) { + throw new UnauthorizedException('این توکن باطل شده است. لطفاً مجدداً وارد شوید'); + } + } + // Handle fallback admin ID only if ADMIN_EMAIL is configured and matches payload if (payload.sub === '12345678-1234-1234-1234-123456789012') { - return { id: payload.sub, email: payload.email, role: payload.role }; + const adminEmail = process.env.ADMIN_EMAIL; + if (adminEmail && payload.email === adminEmail) { + return { + id: payload.sub, + email: adminEmail, + role: 'Admin', + }; + } + throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست'); } const user = await this.usersService.findById(payload.sub); diff --git a/backend/src/common/env.validation.ts b/backend/src/common/env.validation.ts index e4508c0..886497b 100644 --- a/backend/src/common/env.validation.ts +++ b/backend/src/common/env.validation.ts @@ -52,5 +52,18 @@ export function validateEnv(config: Record) { throw new Error(`Environment Validation Error: ${messages}`); } + if (validatedConfig.NODE_ENV === 'production') { + if (!validatedConfig.JWT_ACCESS_SECRET) { + throw new Error( + 'Environment Validation Error: JWT_ACCESS_SECRET is strictly required in production!', + ); + } + if (!validatedConfig.JWT_REFRESH_SECRET) { + throw new Error( + 'Environment Validation Error: JWT_REFRESH_SECRET is strictly required in production!', + ); + } + } + return validatedConfig; } diff --git a/backend/src/common/guards/roles.guard.ts b/backend/src/common/guards/roles.guard.ts index 70d26e4..2b3786f 100644 --- a/backend/src/common/guards/roles.guard.ts +++ b/backend/src/common/guards/roles.guard.ts @@ -10,6 +10,8 @@ import { ROLES_KEY } from '../decorators/roles.decorator'; interface RequestWithUser { user?: { role?: string; + isApiKey?: boolean; + scopes?: string[]; }; } @@ -33,11 +35,23 @@ export class RolesGuard implements CanActivate { throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید'); } + // If request originates from an API key, verify it has admin scope for admin-guarded routes + if (user.isApiKey) { + const scopes = Array.isArray(user.scopes) ? user.scopes : []; + const hasAdminScope = + scopes.includes('*') || + scopes.includes('admin') || + scopes.includes('all'); + if (!hasAdminScope) { + throw new ForbiddenException( + 'کلید API ارائه‌شده فاقد اسکوپ مدیریتی لازم برای این عملیات است', + ); + } + } + const userRoleLower = user.role.toLowerCase(); const hasRole = requiredRoles.some( - (r) => - r.toLowerCase() === userRoleLower || - (userRoleLower.includes('admin') && r.toLowerCase().includes('admin')), + (r) => r.toLowerCase() === userRoleLower, ); if (!hasRole) { throw new ForbiddenException('سطح دسترسی شما کافی نیست'); diff --git a/backend/src/common/metrics.controller.ts b/backend/src/common/metrics.controller.ts index 49e21d4..7d0e5f2 100644 --- a/backend/src/common/metrics.controller.ts +++ b/backend/src/common/metrics.controller.ts @@ -1,9 +1,14 @@ -import { Controller, Get, Res } from '@nestjs/common'; +import { Controller, Get, Res, UseGuards } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { PrismaService } from '../prisma/prisma.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; +import { Roles } from './decorators/roles.decorator'; import type { Response } from 'express'; @ApiExcludeController() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('Admin', 'SUPER_ADMIN') @Controller('metrics') export class MetricsController { private static requestCount = 0; diff --git a/backend/src/main.ts b/backend/src/main.ts index af2120a..5e8daeb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -19,18 +19,10 @@ import compression from 'compression'; async function bootstrap() { validateEnv(process.env); - process.env.JWT_ACCESS_SECRET = - process.env.JWT_ACCESS_SECRET || - process.env.JWT_SECRET || - 'canina_jwt_fallback_access_secret_32_characters_minimum_len_2026'; - process.env.JWT_REFRESH_SECRET = - process.env.JWT_REFRESH_SECRET || - 'canina_jwt_fallback_refresh_secret_32_characters_minimum_len_2026'; - const app = await NestFactory.create(AppModule); - // Trust reverse proxy (Nginx / Docker ingress) to resolve real client IP from X-Forwarded-For - app.set('trust proxy', true); + // Trust 1 reverse proxy hop (Nginx / Cloudflare / Ingress) to prevent IP spoofing + app.set('trust proxy', 1); // Serve static uploads folder with 1-year aggressive browser caching (immutable) const staticUploadOptions = { @@ -54,14 +46,44 @@ async function bootstrap() { app.use( helmet({ - contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets + contentSecurityPolicy: process.env.NODE_ENV === 'production' ? { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:', 'https:'], + connectSrc: ["'self'", 'https:'], + fontSrc: ["'self'", 'data:'], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + } : false, + crossOriginEmbedderPolicy: false, }), ); app.setGlobalPrefix('api'); + const allowedOrigins = process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',').map((o) => o.trim()) + : [ + 'http://localhost:3000', + 'http://localhost:3001', + 'http://localhost:5173', + 'https://canina.ir', + 'https://www.canina.ir', + 'https://admin.canina.ir', + ]; + app.enableCors({ - origin: true, + origin: (origin, callback) => { + // Allow requests with no origin (e.g. mobile apps, curl, server-to-server) + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, credentials: true, }); diff --git a/backend/src/prescriptions/prescriptions.controller.ts b/backend/src/prescriptions/prescriptions.controller.ts index 3b47d89..4ce6383 100644 --- a/backend/src/prescriptions/prescriptions.controller.ts +++ b/backend/src/prescriptions/prescriptions.controller.ts @@ -14,6 +14,22 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; +import { + UseInterceptors, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { MediaService } from '../admin/media.service'; + +const ALLOWED_PRESCRIPTION_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', +]; + export interface UserReqPayload { user: { id?: string; @@ -25,7 +41,36 @@ export interface UserReqPayload { @ApiTags('Prescriptions - نسخه و تاییدیه دارویی') @Controller('prescriptions') export class PrescriptionsController { - constructor(private readonly prescriptionsService: PrescriptionsService) {} + constructor( + private readonly prescriptionsService: PrescriptionsService, + private readonly mediaService: MediaService, + ) {} + + @Post('upload') + @ApiOperation({ summary: 'آپلود تصویر یا فایل نسخه دارویی توسط کاربر' }) + @UseInterceptors( + FileInterceptor('file', { + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: (_req, file, callback) => { + if (!ALLOWED_PRESCRIPTION_MIME_TYPES.includes(file.mimetype)) { + return callback( + new BadRequestException( + 'فرمت فایل مجاز نیست. تنها فرمت‌های تصویری و PDF پشتیبانی می‌شوند.', + ), + false, + ); + } + callback(null, true); + }, + }), + ) + async uploadFile(@UploadedFile() file: Express.Multer.File) { + if (!file) throw new BadRequestException('فایل بارگذاری نشده است'); + const data = await this.mediaService.uploadFile(file); + return { success: true, data }; + } @Post() @ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' }) diff --git a/backend/src/prescriptions/prescriptions.module.ts b/backend/src/prescriptions/prescriptions.module.ts index 8a7d37e..a22e5c1 100644 --- a/backend/src/prescriptions/prescriptions.module.ts +++ b/backend/src/prescriptions/prescriptions.module.ts @@ -4,8 +4,10 @@ import { PrescriptionsService } from './prescriptions.service'; import { PrismaModule } from '../prisma/prisma.module'; import { SmsService } from '../common/services/sms.service'; +import { AdminModule } from '../admin/admin.module'; + @Module({ - imports: [PrismaModule], + imports: [PrismaModule, AdminModule], controllers: [PrescriptionsController], providers: [PrescriptionsService, SmsService], exports: [PrescriptionsService], diff --git a/backend/src/redis/redis.service.ts b/backend/src/redis/redis.service.ts index 26a2345..645cbd5 100644 --- a/backend/src/redis/redis.service.ts +++ b/backend/src/redis/redis.service.ts @@ -9,6 +9,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { this.client = new Redis({ host: process.env.REDIS_HOST || 'localhost', port: Number(process.env.REDIS_PORT) || 16379, + password: process.env.REDIS_PASSWORD || undefined, }); } diff --git a/backend/src/settings/default-ui-texts.ts b/backend/src/settings/default-ui-texts.ts index ca03dc7..cda1872 100644 --- a/backend/src/settings/default-ui-texts.ts +++ b/backend/src/settings/default-ui-texts.ts @@ -39,6 +39,13 @@ export const DEFAULT_UI_TEXTS: Record = { contact_telegram: 'https://t.me/caninairan', contact_instagram: 'https://instagram.com/caninairan', + // === Bank Payment Info === + bank_card_number: '6219861974012071', + bank_card_display: '۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱', + bank_sheba_number: 'IR-65 0560 6118 2800 5725 1015 01', + bank_name: 'بانک سامان', + bank_account_owner: 'پارسادرمانی کنینا (نوواگارد) - پارسا آقایی', + // === Header / Navigation === shipping_notice: 'ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان', diff --git a/docker-compose.yml b/docker-compose.yml index 4de539a..f2c2cef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,9 @@ services: container_name: canino_db_prod restart: always environment: - POSTGRES_USER: canino_prod - POSTGRES_PASSWORD: caninopassword_prod - POSTGRES_DB: caninodb_prod + POSTGRES_USER: ${POSTGRES_USER:-canino_prod} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-caninodb_prod} volumes: - canina_prod_db:/var/lib/postgresql/data networks: @@ -15,6 +15,14 @@ services: redis_prod: image: redis:7-alpine container_name: canino_redis_prod + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then + exec redis-server --requirepass "$$REDIS_PASSWORD"; + else + exec redis-server; + fi' + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD:-} restart: always networks: - traefik_public @@ -29,9 +37,14 @@ services: - 217.218.127.127 - 217.218.155.155 environment: - - DATABASE_URL=postgresql://canino_prod:caninopassword_prod@db_prod:5432/caninodb_prod?schema=public + - DATABASE_URL=postgresql://${POSTGRES_USER:-canino_prod}:${POSTGRES_PASSWORD}@db_prod:5432/${POSTGRES_DB:-caninodb_prod}?schema=public - REDIS_HOST=redis_prod - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@canina.ir} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} - PORT=3000 volumes: - canina_prod_uploads:/app/uploads diff --git a/frontend/admin-panel/src/components/ProtectedRoute.tsx b/frontend/admin-panel/src/components/ProtectedRoute.tsx index 319826d..0e8378a 100644 --- a/frontend/admin-panel/src/components/ProtectedRoute.tsx +++ b/frontend/admin-panel/src/components/ProtectedRoute.tsx @@ -69,7 +69,15 @@ export default function ProtectedRoute() { ); } - if (!useAdminAuthStore.getState().isAuthenticated) { + const user = useAdminAuthStore.getState().adminUser; + const isUserAuthenticated = useAdminAuthStore.getState().isAuthenticated; + + if (!isUserAuthenticated || !user) { + return ; + } + + const role = (user.role || '').toUpperCase(); + if (role !== 'ADMIN' && role !== 'SUPER_ADMIN') { return ; } diff --git a/frontend/application/app/api/media/[...path]/route.ts b/frontend/application/app/api/media/[...path]/route.ts index 1f84af8..805c59e 100644 --- a/frontend/application/app/api/media/[...path]/route.ts +++ b/frontend/application/app/api/media/[...path]/route.ts @@ -137,6 +137,11 @@ export async function GET( responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable'); responseHeaders.set('X-Content-Type-Options', 'nosniff'); + if (contentType === 'image/svg+xml') { + responseHeaders.set('Content-Security-Policy', "default-src 'none'; sandbox"); + responseHeaders.set('Content-Disposition', 'inline; filename="image.svg"'); + } + return new Response(backendRes.body, { status: backendRes.status, statusText: backendRes.statusText, diff --git a/frontend/application/app/api/revalidate/route.ts b/frontend/application/app/api/revalidate/route.ts index 7210b6d..8cd0fe2 100644 --- a/frontend/application/app/api/revalidate/route.ts +++ b/frontend/application/app/api/revalidate/route.ts @@ -34,7 +34,14 @@ async function handleRevalidate(request: NextRequest) { } } - const expectedSecret = process.env.REVALIDATION_SECRET || 'canina_revalidation_secret_key_2026'; + const expectedSecret = process.env.REVALIDATION_SECRET; + if (!expectedSecret) { + return NextResponse.json( + { success: false, message: 'Revalidation service is not configured' }, + { status: 503 } + ); + } + if (!secret || secret !== expectedSecret) { return NextResponse.json( { success: false, message: 'Invalid revalidation secret token' }, diff --git a/frontend/application/components/CheckoutPage.tsx b/frontend/application/components/CheckoutPage.tsx index 620344e..5435bd6 100644 --- a/frontend/application/components/CheckoutPage.tsx +++ b/frontend/application/components/CheckoutPage.tsx @@ -733,7 +733,7 @@ export default function CheckoutPage() {
صاحب حساب: - پارسادرمانی کنینا (نوواگارد) + پارسادرمانی کنینا (نوواگارد) - به نام پارسا آقایی
diff --git a/frontend/application/components/PrescriptionUploadModal.tsx b/frontend/application/components/PrescriptionUploadModal.tsx index d2e277f..3e79440 100644 --- a/frontend/application/components/PrescriptionUploadModal.tsx +++ b/frontend/application/components/PrescriptionUploadModal.tsx @@ -98,7 +98,7 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio const api = (await import("../lib/services/api")).default; const formData = new FormData(); formData.append("file", file); - const uploadRes = await api.post("/admin/media/upload", formData, { + const uploadRes = await api.post("/prescriptions/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name; diff --git a/frontend/application/components/TopUpModal.tsx b/frontend/application/components/TopUpModal.tsx index ad4d234..d15961a 100644 --- a/frontend/application/components/TopUpModal.tsx +++ b/frontend/application/components/TopUpModal.tsx @@ -162,7 +162,7 @@ export default function TopUpModal({ isOpen, onClose, initialAmount }: TopUpModa
کارت: ۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱ (بانک سامان)
شبا: IR-65 0560 6118 2800 5725 1015 01
-
به نام: پارسا آقایی
+
به نام: پارسادرمانی کنینا (نوواگارد) - پارسا آقایی

پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.