Compare commits

...

2 Commits

Author SHA1 Message Date
bd49fbc4a0 fix(security): remediate audit vulnerabilities SEC-001 through SEC-023
Some checks failed
Deploy Canina / deploy (push) Successful in 2m25s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s
2026-09-23 23:00:25 +03:30
dc88677169 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
2026-09-23 22:05:12 +03:30
57 changed files with 1292 additions and 186 deletions

51
.github/workflows/security-audit.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Security & Audit Pipeline
on:
push:
branches: [ "main", "master", "develop" ]
pull_request:
branches: [ "main", "master" ]
jobs:
audit-and-security:
name: Security Scan & Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Run Gitleaks to detect secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Backend Security Audit
run: |
cd backend
npm audit --audit-level=high
- name: Frontend Security Audit
run: |
cd frontend/application
npm audit --audit-level=high
- name: Backend Build & Type Check
run: |
cd backend
npm ci
npm run build
- name: Frontend Build & Type Check
run: |
cd frontend/application
npm ci
npm run build

16
.gitleaks.toml Normal file
View File

@ -0,0 +1,16 @@
# Gitleaks Configuration for Canina Iran Monorepo
title = "Canina Iran Gitleaks Config"
[extend]
useDefault = true
[allowlist]
description = "Global allowlist for example and mock files"
paths = [
'''^\.env\.example$''',
'''^backend/\.env\.example$''',
'''^frontend/application/\.env\.example$''',
'''node_modules/''',
'''\.next/''',
'''dist/'''
]

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,16 @@ export class ApiKeysService {
})
.catch(() => {});
const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase());
// SEC-016: Disallow wildcard '*' from granting admin role; require explicit 'admin' scope
const hasAdminScope = scopes.includes('admin');
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

@ -51,7 +51,11 @@ export class MediaService {
}
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(originalname);
const ext = path.extname(originalname).toLowerCase();
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'];
if (!ALLOWED_EXTENSIONS.includes(ext)) {
throw new BadRequestException('پسوند فایل ارسالی مجاز نیست (فرمت SVG و فایل‌های اجرایی مسدود هستند).');
}
const filename = `${uniqueSuffix}${ext}`;
const filePath = path.join(uploadDir, filename);

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

@ -179,7 +179,27 @@ export class SslService {
const cleanDomain = domain
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.trim();
.trim()
.toLowerCase();
// SEC-009: Strict domain validation preventing SSRF
const DOMAIN_REGEX = /^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z0-9-]{1,63})*\.[a-zA-Z]{2,}$/;
const IP_REGEX = /^(?:\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
if (
!cleanDomain ||
!DOMAIN_REGEX.test(cleanDomain) ||
IP_REGEX.test(cleanDomain) ||
cleanDomain === 'localhost' ||
cleanDomain.endsWith('.local') ||
cleanDomain.endsWith('.internal')
) {
return resolve({
onlineCheck: false,
error: 'فرمت نام دامنه نامعتبر است یا آدرس‌های IP و داخلی مجاز نیستند.',
});
}
const socket = tls.connect(
{
host: cleanDomain,

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

@ -24,6 +24,7 @@ import {
} from '@nestjs/swagger';
import { AdminLoginDto } from './dto/admin-login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Auth - احراز هویت')
@Controller('auth')
@ -72,6 +73,7 @@ export class AuthController {
return this.authService.verifyOtp(verifyOtpDto);
}
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'ثبت‌نام با ایمیل/موبایل و رمز عبور' })
@ -83,6 +85,7 @@ export class AuthController {
return this.authService.register(registerDto);
}
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ورود با موبایل و رمز عبور' })
@ -92,6 +95,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: 'ورود ادمین به پنل مدیریت' })
@ -101,6 +105,16 @@ export class AuthController {
return this.authService.adminLogin(body);
}
@Throttle({ default: { limit: 10, ttl: 60000 } })
@Post('refresh-token')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'دریافت جفت توکن جدید با استفاده از توکن تازه‌سازی' })
@ApiOkResponse({ description: 'توکن‌های جدید با موفقیت صادر شدند' })
@ApiBadRequestResponse({ description: 'توکن نامعتبر یا منقضی است' })
refreshToken(@Body() body: RefreshTokenDto) {
return this.authService.refreshToken(body.refreshToken);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('refresh')
@ -116,11 +130,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,15 +8,18 @@ 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(),
signOptions: { expiresIn: '7d' },
signOptions: { expiresIn: '15m' },
}),
}),
],

View File

@ -1,6 +1,7 @@
import {
Injectable,
BadRequestException,
UnauthorizedException,
HttpException,
HttpStatus,
} from '@nestjs/common';
@ -13,6 +14,7 @@ import { SmsService } from '../common/services/sms.service';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
import { normalizeMobile } from '../common/utils/phone.utils';
import { getJwtRefreshSecret } from './auth.constants';
export class RegisterInput {
firstName!: string;
@ -42,6 +44,28 @@ export class AuthService {
private smsService: SmsService,
) {}
private async generateTokens(payload: Record<string, any>) {
const accessToken = this.jwtService.sign(payload);
// Refresh token with 30-day lifetime signed by refresh secret
const refreshSecret = getJwtRefreshSecret();
const tokenId = crypto.randomUUID();
const refreshPayload = { ...payload, jti: tokenId };
const refreshToken = this.jwtService.sign(refreshPayload, {
secret: refreshSecret,
expiresIn: '30d',
});
// Store active refresh token in Redis (30 days TTL = 2,592,000s)
await this.redisService.set(
`refresh_token:${payload.sub}:${tokenId}`,
'1',
30 * 24 * 3600,
);
return { accessToken, refreshToken };
}
async sendOtp(sendOtpDto: SendOtpDto) {
const phoneNumber = normalizeMobile(sendOtpDto.phoneNumber);
@ -152,14 +176,16 @@ export class AuthService {
});
}
const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload);
const payload = { sub: user.id, phoneNumber: user.mobile, role: user.role };
const tokens = await this.generateTokens(payload);
const { password: _p, ...safeUser } = user;
return {
success: true,
data: {
user,
accessToken,
user: safeUser,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
}
@ -207,14 +233,16 @@ export class AuthService {
},
});
const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload);
const payload = { sub: user.id, phoneNumber: user.mobile, role: user.role };
const tokens = await this.generateTokens(payload);
const { password: _p, ...safeUser } = user;
return {
success: true,
data: {
user,
accessToken,
user: safeUser,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
}
@ -248,22 +276,21 @@ export class AuthService {
});
}
const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload);
const payload = { sub: user.id, phoneNumber: user.mobile, role: user.role };
const tokens = await this.generateTokens(payload);
const { password: _pw, ...safeUser } = user;
return {
success: true,
data: {
user,
accessToken,
user: safeUser,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
}
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({
where: {
@ -280,34 +307,71 @@ export class AuthService {
email: dbAdmin.email,
role: dbAdmin.role,
};
const tokens = await this.generateTokens(payload);
const { password: _p, ...safeUser } = dbAdmin;
return {
success: true,
data: {
user: dbAdmin,
accessToken: this.jwtService.sign(payload),
user: safeUser,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
}
}
// 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',
// In production, fallback credentials MUST NOT be accepted
if (process.env.NODE_ENV === 'production') {
throw new BadRequestException({
message: 'ایمیل یا رمز عبور مدیریت اشتباه است',
error: 'INVALID_ADMIN_CREDENTIALS',
});
}
// Fallback to validated environment variable credentials only in non-production
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',
};
const tokens = await this.generateTokens(payload);
return {
success: true,
data: {
user: {
id: '12345678-1234-1234-1234-123456789012',
email: body.email,
role: 'Admin',
},
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
accessToken: this.jwtService.sign(payload),
},
};
};
}
}
throw new BadRequestException({
@ -316,7 +380,83 @@ export class AuthService {
});
}
refresh(user: {
async refreshToken(rawRefreshToken: string) {
if (!rawRefreshToken) {
throw new UnauthorizedException('توکن تازه‌سازی یافت نشد');
}
let decoded: any;
try {
decoded = this.jwtService.verify(rawRefreshToken, {
secret: getJwtRefreshSecret(),
});
} catch {
throw new UnauthorizedException('توکن تازه‌سازی نامعتبر یا منقضی شده است');
}
const userId = decoded.sub;
const tokenId = decoded.jti;
if (!userId || !tokenId) {
throw new UnauthorizedException('اطلاعات توکن نامعتبر است');
}
// Check if token exists in Redis
const tokenKey = `refresh_token:${userId}:${tokenId}`;
const tokenValid = await this.redisService.get(tokenKey);
if (!tokenValid) {
throw new UnauthorizedException('توکن تازه‌سازی باطل شده است. لطفاً مجدداً وارد شوید');
}
// Invalidate old token (Rotation)
await this.redisService.del(tokenKey);
let userPayload: Record<string, any>;
let safeUser: any;
if (userId === '12345678-1234-1234-1234-123456789012') {
if (process.env.NODE_ENV === 'production') {
throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست');
}
userPayload = {
sub: userId,
email: decoded.email,
role: 'Admin',
};
safeUser = {
id: userId,
email: decoded.email,
role: 'Admin',
};
} else {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('کاربر یافت نشد');
}
userPayload = {
sub: user.id,
email: user.email,
phoneNumber: user.mobile,
role: user.role,
};
const { password: _p, ...restUser } = user;
safeUser = restUser;
}
// Issue new pair of tokens
const tokens = await this.generateTokens(userPayload);
return {
success: true,
data: {
user: safeUser,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
}
async refresh(user: {
id: string;
email?: string;
role?: string;
@ -328,12 +468,35 @@ export class AuthService {
role: user.role,
phoneNumber: user.mobile,
};
const tokens = await this.generateTokens(payload);
return {
success: true,
data: {
user,
accessToken: this.jwtService.sign(payload),
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
}
async blacklistToken(token: string) {
try {
const decoded: any = this.jwtService.decode(token);
let ttl = 15 * 60; // 15 minutes default for access token
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);
// If token payload has jti and sub, also invalidate the refresh token in Redis
if (decoded && decoded.sub && decoded.jti) {
await this.redisService.del(`refresh_token:${decoded.sub}:${decoded.jti}`);
}
} catch {
await this.redisService.set(`bl_token:${token}`, '1', 15 * 60);
}
}
}

View File

@ -0,0 +1,12 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({
description: 'توکن تازه‌سازی (Refresh Token)',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsNotEmpty({ message: 'ارسال توکن تازه‌سازی الزامی است' })
@IsString({ message: 'توکن تازه‌سازی باید رشته متنی باشد' })
refreshToken: string;
}

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,40 @@ 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 in non-production environments if ADMIN_EMAIL matches
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
return { id: payload.sub, email: payload.email, role: payload.role };
if (process.env.NODE_ENV === 'production') {
throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست');
}
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,20 @@ 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('admin');
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

@ -13,9 +13,15 @@ export class RevalidationService {
}
private get secret(): string {
return (
process.env.REVALIDATION_SECRET || 'canina_revalidation_secret_key_2026'
);
const secret = process.env.REVALIDATION_SECRET;
if (!secret) {
if (process.env.NODE_ENV === 'production') {
throw new Error('REVALIDATION_SECRET is required in production environment.');
}
this.logger.warn('REVALIDATION_SECRET is not defined in environment variables.');
return '';
}
return secret;
}
async revalidateTag(tag: string): Promise<boolean> {

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,46 @@ 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'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Needed for swagger and UI styling if applicable
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https:'],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'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

@ -147,6 +147,25 @@ export class OrdersController {
return this.ordersService.findAllByUser(req.user.id, query);
}
@Post(':id/retry-payment')
@ApiOperation({
summary: 'پرداخت مجدد و تکمیل سفارش با امکان انتخاب روش پرداخت',
})
@ApiOkResponse({
description: 'درخواست پرداخت مجدد با موفقیت پردازش شد',
})
retryPayment(
@Req() req: { user: { id: string } },
@Param('id') id: string,
@Body() body: { paymentMethod?: string },
) {
return this.ordersService.retryPayment(
req.user.id,
id,
body?.paymentMethod,
);
}
@Get(':id')
@ApiOperation({ summary: 'دریافت جزئیات یک سفارش خاص' })
@ApiOkResponse({
@ -186,25 +205,6 @@ export class OrdersController {
},
},
})
@Post(':id/retry-payment')
@ApiOperation({
summary: 'پرداخت مجدد و تکمیل سفارش با امکان انتخاب روش پرداخت',
})
@ApiOkResponse({
description: 'درخواست پرداخت مجدد با موفقیت پردازش شد',
})
retryPayment(
@Req() req: { user: { id: string } },
@Param('id') id: string,
@Body() body: { paymentMethod?: string },
) {
return this.ordersService.retryPayment(
req.user.id,
id,
body?.paymentMethod,
);
}
findOne(@Req() req: { user: { id: string } }, @Param('id') id: string) {
return this.ordersService.findOne(id, req.user.id);
}

View File

@ -196,20 +196,20 @@ export class OrdersService {
if (!user) {
throw new NotFoundException('کاربر یافت نشد');
}
const userBalance = new Prisma.Decimal(user.walletBalance || 0);
if (userBalance.lessThan(finalAmount)) {
throw new BadRequestException(
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
);
}
const createdWalletOrder = await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
walletBalance: { decrement: Number(finalAmount) },
},
});
// SEC-002: Atomic decrement preventing race condition & overdraft
const updatedCount = await tx.$executeRaw`
UPDATE "users"
SET "wallet_balance" = "wallet_balance" - ${Number(finalAmount)}::decimal
WHERE "id" = ${userId}::uuid AND "wallet_balance" >= ${Number(finalAmount)}::decimal
`;
if (updatedCount === 0) {
throw new BadRequestException(
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
);
}
await tx.walletTransaction.create({
data: {
@ -581,18 +581,19 @@ export class OrdersService {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException('کاربر یافت نشد');
const userBalance = Number(user.walletBalance || 0);
if (userBalance < finalAmount) {
throw new BadRequestException(
`موجودی کیف پول شما (${userBalance.toLocaleString('fa-IR')} تومان) کمتر از مبلغ سفارش (${finalAmount.toLocaleString('fa-IR')} تومان) است.`,
);
}
await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: { walletBalance: { decrement: finalAmount } },
});
// SEC-002: Atomic decrement preventing race condition & overdraft
const updatedCount = await tx.$executeRaw`
UPDATE "users"
SET "wallet_balance" = "wallet_balance" - ${finalAmount}::decimal
WHERE "id" = ${userId}::uuid AND "wallet_balance" >= ${finalAmount}::decimal
`;
if (updatedCount === 0) {
throw new BadRequestException(
`موجودی کیف پول شما برای پرداخت این سفارش (${finalAmount.toLocaleString('fa-IR')} تومان) کافی نیست.`,
);
}
await tx.walletTransaction.create({
data: {

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

@ -0,0 +1,60 @@
# Canina Iran — Security Guidelines & Architecture Manual
این مستند حاوی راهنماها، چک‌لیست‌های امنیتی و چارچوب‌های فنی پیاده‌سازی‌شده در پروژه کنینا ایران (NestJS + Next.js) می‌باشد.
---
## ۱. مدیریت نشست و کوکی / CSRF Protection (#SEC-021)
### استراتژی احراز هویت توکن‌ها:
1. **Access Token:**
- زمان اعتبار: **۱۵ دقیقه (15m)**.
- امضا شده با کلید اختصاصی `JWT_ACCESS_SECRET`.
- پس از انقضا، فرانت‌اند به صورت خودکار از طریق Interceptor در `lib/services/api.ts` درخواست `/auth/refresh-token` را ارسال کرده و توکن جدید را دریافت و ریکوئست متوقف‌شده را مجدداً ارسال می‌کند.
2. **Refresh Token:**
- زمان اعتبار: **۳۰ روز (30d)**.
- امضا شده با کلید جداگانه `JWT_REFRESH_SECRET`.
- ذخیره در Redis با کلید `refresh_token:{userId}:{tokenId}`.
- **Token Rotation:** به ازای هر بار فراخوانی `/auth/refresh-token`، توکن قبلی در ردیس باطل شده و یک جفت توکن کاملاً تازه صادر می‌شود.
3. **حفاظت CSRF در معماری Cookie:**
- در صورت انتقال Refresh Token به Cookie:
- باید فلگ‌های `httpOnly: true`, `secure: true`, و `sameSite: 'strict'` یا `'lax'` فعال باشند.
- برای درخواست‌های جهش‌دهنده وضعیت (POST, PUT, DELETE, PATCH)، هدر اختصاصی `X-Requested-With: XMLHttpRequest` یا الگوی Double Submit Cookie (CSRF Token در هدر `X-CSRF-Token`) پیاده‌سازی می‌شود.
- در حال حاضر درخواست‌های API از هدر `Authorization: Bearer <token>` استفاده می‌کنند که مرورگرها آن را در درخواست‌های کراس‌سایت (Cross-Site) ناخواسته ارسال نمی‌کنند و در برابر CSRF سنتی ایمن است.
---
## ۲. چک‌لیست بررسی امنیتی کد (Security Code Review Checklist - #SEC-022)
هر توسعه‌دهنده پیش از باز کردن Pull Request یا ادغام کد، باید موارد زیر را بررسی کند:
### الف) ورودی‌ها و اعتبارسنجی (Input Validation)
- [ ] تمام DTOها از `class-validator` استفاده کرده و دارای دکوراتورهای متناسب (`@IsString()`, `@IsNumber()`, `@IsUUID()`, `@IsEmail()`) هستند.
- [ ] هیچ ورودی کاربری مستقیماً به دستورات SQL یا shell متصل نمی‌شود (استفاده الزامی از Prisma پارامتریک).
- [ ] آپلود فایل‌ها دارای اعتبارسنجی جدی پسوند، حجم (`fileSize`) و هدر MIME است (پسوندهای مشکوک نظیر `.svg`، `.html`، `.exe`، `.sh` کاملاً مسدود شوند).
### ب) خروجی‌ها و XSS Prevention
- [ ] هیچ محتوایی که توسط کاربر یا ادمین تایپ شده به طور مستقیم در `dangerouslySetInnerHTML` قرار نمی‌گیرد.
- [ ] در فرانت‌اند برای کامپوننت‌های رندر HTML از `DOMPurify.sanitize()` و در کامپوننت‌های SSR از `sanitize-html` استفاده می‌شود.
- [ ] داده‌های Schema.org و JSON-LD از تابع کمکی `safeJsonLd()` استفاده می‌کنند تا حملات Script Injection مهار شوند.
### ج) کنترل دسترسی (Access Control)
- [ ] تمام روت‌های محافظت‌شده دارای `@UseGuards(JwtAuthGuard, RolesGuard)` هستند.
- [ ] متدهای دسترسی به منابع (مانند جزئیات سفارش‌ها) بررسی می‌کنند که شناسه کاربر متصل با شناسه مالک داده تطابق داشته باشد (`order.userId === req.user.id`).
- [ ] در کنترلرهای ادمین، دسترسی‌های کلیدهای API (`ApiKeysService`) منحصراً نیازمند اسکوپ صریح `admin` باشند و اسکوپ‌های عامیانه و وایلدکارد مثل `*` یا `all` نادیده گرفته شوند.
### د) نرخ مصرف و سهمیه‌بندی (Rate Limiting)
- [ ] روت‌های حساس شامل ورود (`/auth/login`)، ثبت‌نام (`/auth/register`)، ارسال پیامک (`/auth/send-otp`) و پنل مدیریت دارای `@Throttle` اختصاصی با محدودیت‌های سخت‌گیرانه هستند.
---
## ۳. برنامه تست نفوذ دوره‌ای (Penetration Testing Plan - #SEC-023)
1. **فواصل زمانی ممیزی:**
- تست نفوذ خارجی باید حداقل **سالانه یک‌بار** یا قبل از هر عرضه ماژور (Major Release) توسط یک تیم یا شرکت مستقل امنیت سایبری انجام شود.
2. **حیطه آزمون (Scope):**
- آزمون جعبه سیاه (Black-box) و خاکستری (Grey-box) روی تمامی آدرس‌های دامنه عمومی، وب‌سرویس‌های RESTful و درگاه‌های پرداخت.
- ممیزی فرآیندهای مالی (Race condition در افزایش/کاهش موجودی کیف پول و تایید تراکنش‌های زیبال).
- آزمون نفوذ سرورهای زیرساخت و تنظیمات فایروال WAF و Nginx/Caddy.

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

@ -2,7 +2,7 @@ import React from "react";
import type { Metadata } from "next";
import B2BLandingClient from "@/components/B2BLandingClient";
import { getPageMetadata } from "@/lib/seo";
import { generateB2BServiceSchema, generateBreadcrumbSchema } from "@/lib/schema";
import { generateB2BServiceSchema, generateBreadcrumbSchema, safeJsonLd } from "@/lib/schema";
export async function generateMetadata(): Promise<Metadata> {
return getPageMetadata('b2b', {
@ -26,11 +26,11 @@ export default function B2BPage() {
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(b2bServiceSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(b2bServiceSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<B2BLandingClient />
</>

View File

@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import BlogPostClient from "../../../components/BlogPostClient";
import { getSeoConfig, formatPageTitle } from "../../../lib/seo";
import { safeJsonLd } from "../../../lib/schema";
function safeIso(val: any, fallback?: string): string | undefined {
if (!val) return fallback;
@ -246,11 +247,11 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
<BlogPostClient
blog={blog}

View File

@ -10,7 +10,7 @@ export async function generateMetadata(): Promise<Metadata> {
});
}
import { generateLocalBusinessSchema, generateBreadcrumbSchema } from "@/lib/schema";
import { generateLocalBusinessSchema, generateBreadcrumbSchema, safeJsonLd } from "@/lib/schema";
export default function ContactPage() {
const localBusinessSchema = generateLocalBusinessSchema({
@ -35,11 +35,11 @@ export default function ContactPage() {
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(localBusinessSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<div className="bg-medical-gray-50 py-16 px-4 sm:px-6 lg:px-8 font-vazir" dir="rtl">
<div className="max-w-6xl mx-auto space-y-16">

View File

@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import VideosPage from "../../components/VideosPage";
import { getPageMetadata } from "../../lib/seo";
import { generateVideoObjectSchema, generateBreadcrumbSchema } from "../../lib/schema";
import { generateVideoObjectSchema, generateBreadcrumbSchema, safeJsonLd } from "../../lib/schema";
export async function generateMetadata(): Promise<Metadata> {
return getPageMetadata('videos', {
@ -61,12 +61,12 @@ export default async function Videos() {
{videoSchemas.length > 0 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(itemListSchema) }}
/>
)}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<VideosPage />
</>

View File

@ -1,8 +1,9 @@
import Link from 'next/link';
import { ChevronRight, Sparkles, Package, ArrowLeft, ShieldCheck } from 'lucide-react';
import type { Metadata } from 'next';
import sanitizeHtml from 'sanitize-html';
import { getSeoConfig, formatPageTitle } from "../../../lib/seo";
import { generateMedicalWebPageSchema, generateBreadcrumbSchema } from "../../../lib/schema";
import { generateMedicalWebPageSchema, generateBreadcrumbSchema, safeJsonLd } from "../../../lib/schema";
import SafeImage from "../../../components/SafeImage";
const API_URL = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api';
@ -110,11 +111,11 @@ export default async function WikiTermPage({ params }: { params: Promise<{ slug:
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(medicalSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(medicalSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<div className="bg-medical-gray-50 min-h-screen py-12 px-4 sm:px-6 lg:px-8 font-vazir" dir="rtl">
@ -143,7 +144,18 @@ export default async function WikiTermPage({ params }: { params: Promise<{ slug:
<div
className="prose prose-lg max-w-none text-medical-gray-700 leading-relaxed prose-headings:font-black prose-headings:text-medical-gray-900 prose-p:leading-loose"
dangerouslySetInnerHTML={{ __html: term.definition }}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(term.definition || '', {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'span']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
span: ['class', 'style'],
div: ['class'],
p: ['class'],
},
}),
}}
/>
</article>

View File

@ -1,5 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import DOMPurify from 'dompurify';
import {
ChevronRight,
Calendar,
@ -175,7 +176,14 @@ export default function BlogPostClient({ blog, publishedDate, authorName }: Blog
{/* Main Rich Content HTML Renderer */}
<div
className="prose prose-base sm:prose-lg max-w-none text-medical-gray-700 leading-loose space-y-5 article-html-content"
dangerouslySetInnerHTML={{ __html: blog.content || '' }}
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(blog.content || '', {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'],
})
: blog.content || '',
}}
/>
{/* Tags Chip Footer */}

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

@ -2,6 +2,7 @@
'use client';
import React from 'react';
import DOMPurify from 'dompurify';
import { useSettingsStore } from '../lib/store/settingsStore';
const DEFAULT_ENAMAD_HTML = `<a referrerpolicy='origin' target='_blank' href='https://trustseal.enamad.ir/?id=765218&Code=dOdphRTqZwp3Kep5m07XwaqA0DTFh5ny'><img referrerpolicy='origin' src='https://trustseal.enamad.ir/logo.aspx?id=765218&Code=dOdphRTqZwp3Kep5m07XwaqA0DTFh5ny' alt='' style='cursor:pointer' code='dOdphRTqZwp3Kep5m07XwaqA0DTFh5ny'></a>`;
@ -10,6 +11,14 @@ export default function EnamadBadge() {
const getText = useSettingsStore((state) => state.getText);
const enamadHtml = getText('ENAMAD_HTML_CODE', DEFAULT_ENAMAD_HTML);
const cleanEnamadHtml =
typeof window !== 'undefined'
? DOMPurify.sanitize(enamadHtml, {
ALLOWED_TAGS: ['a', 'img'],
ALLOWED_ATTR: ['referrerpolicy', 'target', 'href', 'src', 'alt', 'style', 'code', 'width', 'height'],
})
: enamadHtml;
return (
<div className="relative flex flex-col items-center justify-center p-6 bg-white/90 backdrop-blur-md rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="text-xs font-semibold text-teal-600 bg-teal-50 px-3 py-1 rounded-full mb-3">
@ -18,7 +27,7 @@ export default function EnamadBadge() {
<div
className="relative min-w-[120px] min-h-[120px] flex items-center justify-center bg-white rounded-xl overflow-hidden border border-gray-100 p-2"
dangerouslySetInnerHTML={{ __html: enamadHtml }}
dangerouslySetInnerHTML={{ __html: cleanEnamadHtml }}
/>
<p className="text-xs text-gray-500 mt-4 text-center leading-relaxed font-medium">

View File

@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
import { HelpCircle, ChevronDown } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useSettingsStore } from "../lib/store/settingsStore";
import { safeJsonLd } from "../lib/schema";
import api from "../lib/services/api";
export interface FAQItem {
@ -59,7 +60,7 @@ export default function FAQSection() {
{faqs.length > 0 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqSchema) }}
/>
)}

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">
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.

View File

@ -20,6 +20,7 @@ import {
Loader2
} from "lucide-react";
import { getMediaUrl } from "../lib/media";
import DOMPurify from "dompurify";
interface VideoModalPlayerProps {
isOpen: boolean;
@ -306,7 +307,14 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
</button>
<div
className="w-full h-full [&>iframe]:w-full [&>iframe]:h-full"
dangerouslySetInnerHTML={{ __html: video.videoUrl || '' }}
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(video.videoUrl || '', {
ALLOWED_TAGS: ['iframe'],
ALLOWED_ATTR: ['src', 'width', 'height', 'frameborder', 'allow', 'allowfullscreen', 'title'],
})
: video.videoUrl || '',
}}
/>
</div>
) : (

View File

@ -1,6 +1,7 @@
"use client";
import React from "react";
import DOMPurify from "dompurify";
import SafeImage from "../SafeImage";
import { Product } from "../../lib/data/products";
import { X, ShieldCheck, CheckCircle2, Award, Stethoscope, Sparkles, Layers, Info } from "lucide-react";
@ -77,7 +78,11 @@ export default function ProductDetailModal({ product, categoryColor = "#0284C7",
</span>
{/<[a-z][\s\S]*>/i.test(product.description || '') ? (
<div
dangerouslySetInnerHTML={{ __html: product.description || '' }}
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(product.description || '')
: product.description || '',
}}
className="text-sm text-slate-600 leading-relaxed font-medium space-y-2 [&>h1]:font-black [&>h2]:font-black [&>h3]:font-black [&>p]:leading-relaxed [&>ul]:list-disc [&>ul]:pr-4 [&>ol]:list-decimal [&>ol]:pr-4 text-justify"
/>
) : (

View File

@ -231,3 +231,14 @@ export function generateBreadcrumbSchema(items: BreadcrumbItem[]) {
})),
};
}
/**
* SEC-017: Safely serialize schema data to JSON string for application/ld+json script tags,
* escaping any '</script>' or '<' characters that could break out of the script block.
*/
export function safeJsonLd(schema: unknown): string {
return JSON.stringify(schema)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}

View File

@ -50,29 +50,97 @@ api.interceptors.request.use(
(error) => Promise.reject(error)
);
// Interceptor to handle 401/403 and present standardized Farsi toasts
// Interceptor to handle 401/403, automatic refresh token rotation, and standardized toasts
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
const originalRequest = error.config;
// Handle 401 Unauthorized with token refresh retry
if (
typeof window !== 'undefined' &&
error.response?.status === 401 &&
!originalRequest?._retry &&
!originalRequest?.url?.includes('/auth/')
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
try {
const res = await axios.post(
`${baseURL}/auth/refresh-token`,
{ refreshToken },
{ headers: { 'Content-Type': 'application/json' } }
);
const { accessToken, refreshToken: newRefreshToken } = res.data?.data || {};
if (accessToken) {
localStorage.setItem('accessToken', accessToken);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
}
api.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
processQueue(null, accessToken);
isRefreshing = false;
return api(originalRequest);
}
} catch (refreshErr) {
processQueue(refreshErr, null);
isRefreshing = false;
}
} else {
isRefreshing = false;
}
// If refresh failed or no refresh token exists, clear state and logout
if (window.localStorage) {
try {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
} catch {}
}
try {
useUserStore.getState().logout();
} catch {}
}
if (typeof window !== 'undefined') {
const responseData = error.response?.data as ApiErrorPayload | undefined;
const status = error.response?.status;
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
// Handle 401 Unauthorized globally - trigger Auth Modal
if (status === 401) {
if (window.localStorage) {
try {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
} catch {}
}
try {
useUserStore.getState().logout();
} catch {
// Ignore if store not ready
}
} else if (status === 403) {
if (status === 403) {
if (window.localStorage) {
try {
localStorage.removeItem('accessToken');

View File

@ -23,6 +23,7 @@ export interface AuthResponse {
data: {
user: User;
accessToken: string;
refreshToken?: string;
};
}
@ -71,6 +72,9 @@ export class AuthService {
if (data?.accessToken) {
localStorage.setItem('accessToken', data.accessToken);
}
if (data?.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
return response.data;
} catch (error) {
const err = error as ApiErr;
@ -89,6 +93,9 @@ export class AuthService {
if (resData?.accessToken) {
localStorage.setItem('accessToken', resData.accessToken);
}
if (resData?.refreshToken) {
localStorage.setItem('refreshToken', resData.refreshToken);
}
return response.data;
} catch (error) {
const err = error as ApiErr;
@ -107,6 +114,9 @@ export class AuthService {
if (data?.accessToken) {
localStorage.setItem('accessToken', data.accessToken);
}
if (data?.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
return response.data;
} catch (error) {
const err = error as ApiErr;
@ -115,6 +125,29 @@ export class AuthService {
}
}
/**
* Refresh auth tokens using the stored refresh token
*/
public async refreshAuthToken(): Promise<string | null> {
try {
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null;
if (!refreshToken) return null;
const response = await api.post('/auth/refresh-token', { refreshToken }, { hideErrorToast: true } as unknown as import('axios').AxiosRequestConfig);
const { data } = response.data;
if (data?.accessToken) {
localStorage.setItem('accessToken', data.accessToken);
}
if (data?.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
return data?.accessToken || null;
} catch {
this.logout();
return null;
}
}
/**
* Get current user profile
*/
@ -150,7 +183,16 @@ export class AuthService {
* Logout user and clear tokens
*/
public logout(): void {
localStorage.removeItem('accessToken');
if (typeof window !== 'undefined') {
try {
const token = localStorage.getItem('accessToken');
if (token) {
api.post('/auth/logout').catch(() => {});
}
} catch {}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
}

View File

@ -8,13 +8,17 @@
"name": "application",
"version": "0.1.0",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@types/sanitize-html": "^2.16.1",
"axios": "^1.17.0",
"dompurify": "^3.4.16",
"lucide-react": "^0.475.0",
"motion": "^12.40.0",
"next": "16.2.9",
"nextjs-toploader": "^3.9.17",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.17.7",
"sonner": "^2.0.7",
"zustand": "^5.0.14"
},
@ -2315,6 +2319,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2366,6 +2379,21 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
@ -3854,6 +3882,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -3885,6 +3919,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@ -3972,6 +4015,82 @@
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -4018,7 +4137,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
@ -4221,7 +4339,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -5186,6 +5303,37 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@ -5531,6 +5679,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -5898,6 +6055,15 @@
"node": ">=0.10"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -6793,6 +6959,12 @@
"node": ">=6"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@ -6873,7 +7045,6 @@
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -7260,6 +7431,113 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sanitize-html": {
"version": "2.17.7",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/sanitize-html/node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",

View File

@ -9,13 +9,17 @@
"lint": "eslint"
},
"dependencies": {
"@types/dompurify": "^3.0.5",
"@types/sanitize-html": "^2.16.1",
"axios": "^1.17.0",
"dompurify": "^3.4.16",
"lucide-react": "^0.475.0",
"motion": "^12.40.0",
"next": "16.2.9",
"nextjs-toploader": "^3.9.17",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.17.7",
"sonner": "^2.0.7",
"zustand": "^5.0.14"
},