fix(security): resolve all critical, high, and medium security vulnerabilities
Some checks failed
Deploy Canina / deploy (push) Waiting to run
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 11s

This commit is contained in:
پارسا آقایی 2026-09-23 22:04:30 +03:30
parent da26dd1c8d
commit dc88677169
33 changed files with 438 additions and 85 deletions

20
.opencode/.mcp.json Normal file
View File

@ -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"
}
}
}

View File

@ -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',

View File

@ -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,
);

View File

@ -45,6 +45,6 @@ import { SettingsModule } from '../settings/settings.module';
SslService,
ApiKeysService,
],
exports: [ApiKeysService],
exports: [ApiKeysService, MediaService],
})
export class AdminModule {}

View File

@ -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 },

View File

@ -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) {}

View File

@ -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,
};
}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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)',

View File

@ -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) {}

View File

@ -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: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر',

View File

@ -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) {}

View File

@ -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!;
};

View File

@ -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: 'با موفقیت خارج شدید' };
}
}

View File

@ -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(),

View File

@ -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);
}
}
}

View File

@ -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);

View File

@ -52,5 +52,18 @@ export function validateEnv(config: Record<string, unknown>) {
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;
}

View File

@ -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('سطح دسترسی شما کافی نیست');

View File

@ -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;

View File

@ -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<NestExpressApplication>(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,
});

View File

@ -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: 'بارگذاری نسخه جدید توسط کاربر' })

View File

@ -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],

View File

@ -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,
});
}

View File

@ -39,6 +39,13 @@ export const DEFAULT_UI_TEXTS: Record<string, string> = {
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:
'ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان',

View File

@ -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

View File

@ -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 <Navigate to="/login" replace />;
}
const role = (user.role || '').toUpperCase();
if (role !== 'ADMIN' && role !== 'SUPER_ADMIN') {
return <Navigate to="/login" replace />;
}

View File

@ -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,

View File

@ -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' },

View File

@ -733,7 +733,7 @@ export default function CheckoutPage() {
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div>
<span className="text-[10px] text-gray-500 block">صاحب حساب:</span>
<span className="font-bold text-xs text-gray-900">پارسادرمانی کنینا (نوواگارد)</span>
<span className="font-bold text-xs text-gray-900">پارسادرمانی کنینا (نوواگارد) - به نام پارسا آقایی</span>
</div>
</div>
</div>

View File

@ -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;

View File

@ -162,7 +162,7 @@ export default function TopUpModal({ isOpen, onClose, initialAmount }: TopUpModa
<div className="text-[11px] text-gray-700 space-y-1 font-mono dir-ltr bg-white p-2.5 rounded-xl border border-blue-100">
<div>کارت: <strong>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</strong> (بانک سامان)</div>
<div>شبا: <strong>IR-65 0560 6118 2800 5725 1015 01</strong></div>
<div className="text-gray-500 font-sans text-[10px] pt-1 border-t">به نام: پارسا آقایی</div>
<div className="text-gray-500 font-sans text-[10px] pt-1 border-t">به نام: پارسادرمانی کنینا (نوواگارد) - پارسا آقایی</div>
</div>
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.