fix(security): remediate audit vulnerabilities SEC-001 through SEC-023
This commit is contained in:
parent
dc88677169
commit
bd49fbc4a0
51
.github/workflows/security-audit.yml
vendored
Normal file
51
.github/workflows/security-audit.yml
vendored
Normal 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
16
.gitleaks.toml
Normal 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/'''
|
||||
]
|
||||
@ -151,7 +151,8 @@ export class ApiKeysService {
|
||||
.catch(() => {});
|
||||
|
||||
const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase());
|
||||
const hasAdminScope = scopes.includes('*') || scopes.includes('admin') || scopes.includes('all');
|
||||
// SEC-016: Disallow wildcard '*' from granting admin role; require explicit 'admin' scope
|
||||
const hasAdminScope = scopes.includes('admin');
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: 'ورود با موبایل و رمز عبور' })
|
||||
@ -102,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')
|
||||
|
||||
@ -19,7 +19,7 @@ import { RedisModule } from '../redis/redis.module';
|
||||
JwtModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
secret: getJwtSecret(),
|
||||
signOptions: { expiresIn: '7d' },
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@ -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,15 +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: safeUser,
|
||||
accessToken,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -208,15 +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: safeUser,
|
||||
accessToken,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -250,21 +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: safeUser,
|
||||
accessToken,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async adminLogin(body: AdminLoginInput) {
|
||||
|
||||
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
|
||||
const dbAdmin = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
@ -281,18 +307,28 @@ 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: safeUser,
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to validated environment variable credentials only if configured securely
|
||||
// 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;
|
||||
|
||||
@ -322,6 +358,7 @@ export class AuthService {
|
||||
email: adminEmail,
|
||||
role: 'Admin',
|
||||
};
|
||||
const tokens = await this.generateTokens(payload);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@ -330,7 +367,8 @@ export class AuthService {
|
||||
email: body.email,
|
||||
role: 'Admin',
|
||||
},
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -342,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;
|
||||
@ -354,11 +468,13 @@ 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -366,7 +482,7 @@ export class AuthService {
|
||||
async blacklistToken(token: string) {
|
||||
try {
|
||||
const decoded: any = this.jwtService.decode(token);
|
||||
let ttl = 7 * 24 * 3600; // default 7 days
|
||||
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) {
|
||||
@ -374,9 +490,13 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// Fallback: cache for 7 days
|
||||
await this.redisService.set(`bl_token:${token}`, '1', 7 * 24 * 3600);
|
||||
await this.redisService.set(`bl_token:${token}`, '1', 15 * 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/auth/dto/refresh-token.dto.ts
Normal file
12
backend/src/auth/dto/refresh-token.dto.ts
Normal 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;
|
||||
}
|
||||
@ -36,8 +36,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('این توکن باطل شده است. لطفاً مجدداً وارد شوید');
|
||||
}
|
||||
}
|
||||
// Handle fallback admin ID only if ADMIN_EMAIL is configured and matches payload
|
||||
// Handle fallback admin ID only in non-production environments if ADMIN_EMAIL matches
|
||||
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست');
|
||||
}
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
if (adminEmail && payload.email === adminEmail) {
|
||||
return {
|
||||
|
||||
@ -38,10 +38,7 @@ export class RolesGuard implements CanActivate {
|
||||
// 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');
|
||||
const hasAdminScope = scopes.includes('admin');
|
||||
if (!hasAdminScope) {
|
||||
throw new ForbiddenException(
|
||||
'کلید API ارائهشده فاقد اسکوپ مدیریتی لازم برای این عملیات است',
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -49,12 +49,14 @@ async function bootstrap() {
|
||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
60
docs/SECURITY_PRACTICES.md
Normal file
60
docs/SECURITY_PRACTICES.md
Normal 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.
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
284
frontend/application/package-lock.json
generated
284
frontend/application/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user