fix(security): resolve all critical, high, and medium security vulnerabilities
This commit is contained in:
parent
da26dd1c8d
commit
dc88677169
20
.opencode/.mcp.json
Normal file
20
.opencode/.mcp.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,12 @@ declare const process: any;
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
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...');
|
console.log('Wiping existing database records...');
|
||||||
await prisma.reminderCompletion.deleteMany();
|
await prisma.reminderCompletion.deleteMany();
|
||||||
await prisma.reminder.deleteMany();
|
await prisma.reminder.deleteMany();
|
||||||
@ -31,9 +37,10 @@ async function main() {
|
|||||||
console.log('Database successfully wiped.');
|
console.log('Database successfully wiped.');
|
||||||
|
|
||||||
console.log('Creating Admin User...');
|
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({
|
await prisma.user.upsert({
|
||||||
where: { email: 'admin@canina.ir' },
|
where: { email: process.env.ADMIN_EMAIL || 'admin@canina.ir' },
|
||||||
update: {
|
update: {
|
||||||
password: adminHashedPassword,
|
password: adminHashedPassword,
|
||||||
role: 'Admin',
|
role: 'Admin',
|
||||||
@ -42,7 +49,7 @@ async function main() {
|
|||||||
id: '12345678-1234-1234-1234-123456789012',
|
id: '12345678-1234-1234-1234-123456789012',
|
||||||
firstName: 'مدیر',
|
firstName: 'مدیر',
|
||||||
lastName: 'سیستم',
|
lastName: 'سیستم',
|
||||||
email: 'admin@canina.ir',
|
email: process.env.ADMIN_EMAIL || 'admin@canina.ir',
|
||||||
password: adminHashedPassword,
|
password: adminHashedPassword,
|
||||||
role: 'Admin',
|
role: 'Admin',
|
||||||
mobile: '09120000001',
|
mobile: '09120000001',
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AdminService, CouponInput } from './admin.service';
|
import { AdminService, CouponInput } from './admin.service';
|
||||||
import { ProductDto } from './dto/product.dto';
|
import { ProductDto } from './dto/product.dto';
|
||||||
@ -28,9 +29,13 @@ import {
|
|||||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||||
import { SettingsService } from '../settings/settings.service';
|
import { SettingsService } from '../settings/settings.service';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت سیستم')
|
@ApiTags('Admin - مدیریت سیستم')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
@ -109,9 +114,20 @@ export class AdminController {
|
|||||||
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
|
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
|
||||||
@Body('description') description?: string,
|
@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(
|
const result = await this.adminService.adjustUserWallet(
|
||||||
id,
|
id,
|
||||||
Number(amount),
|
numAmount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -45,6 +45,6 @@ import { SettingsModule } from '../settings/settings.module';
|
|||||||
SslService,
|
SslService,
|
||||||
ApiKeysService,
|
ApiKeysService,
|
||||||
],
|
],
|
||||||
exports: [ApiKeysService],
|
exports: [ApiKeysService, MediaService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@ -61,6 +61,16 @@ export class CouponInput {
|
|||||||
targets?: CouponTargetInput[];
|
targets?: CouponTargetInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALLOWED_USER_ROLES = [
|
||||||
|
'User_PetOwner',
|
||||||
|
'User_Vet',
|
||||||
|
'User_Clinic',
|
||||||
|
'User_PetShop',
|
||||||
|
'Customer',
|
||||||
|
'Admin',
|
||||||
|
'SUPER_ADMIN',
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -244,6 +254,10 @@ export class AdminService {
|
|||||||
? await bcrypt.hash(passwordToHash, 10)
|
? await bcrypt.hash(passwordToHash, 10)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
if (dto.role && !ALLOWED_USER_ROLES.includes(dto.role)) {
|
||||||
|
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
firstName: dto.firstName.trim(),
|
firstName: dto.firstName.trim(),
|
||||||
@ -307,7 +321,12 @@ export class AdminService {
|
|||||||
if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile;
|
if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile;
|
||||||
if (normalizedEmail !== undefined)
|
if (normalizedEmail !== undefined)
|
||||||
updateData.email = normalizedEmail || null;
|
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) {
|
if (dto.walletBalance !== undefined) {
|
||||||
updateData.walletBalance = new Prisma.Decimal(dto.walletBalance);
|
updateData.walletBalance = new Prisma.Decimal(dto.walletBalance);
|
||||||
}
|
}
|
||||||
@ -322,6 +341,9 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateUserRole(id: string, role: string) {
|
async updateUserRole(id: string, role: string) {
|
||||||
|
if (!ALLOWED_USER_ROLES.includes(role)) {
|
||||||
|
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
|
||||||
|
}
|
||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { role },
|
data: { role },
|
||||||
|
|||||||
@ -19,9 +19,13 @@ import {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت کلیدهای دسترسی (API Keys)')
|
@ApiTags('Admin - مدیریت کلیدهای دسترسی (API Keys)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/api-keys')
|
@Controller('admin/api-keys')
|
||||||
export class ApiKeysController {
|
export class ApiKeysController {
|
||||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||||
|
|||||||
@ -150,12 +150,15 @@ export class ApiKeysService {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase());
|
||||||
|
const hasAdminScope = scopes.includes('*') || scopes.includes('admin') || scopes.includes('all');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key.id,
|
id: key.id,
|
||||||
name: key.name,
|
name: key.name,
|
||||||
role: 'ADMIN', // Grants admin level access for n8n automation
|
role: hasAdminScope ? 'Admin' : 'USER',
|
||||||
email: 'apikey-system@canina.ir',
|
email: 'apikey-system@canina.ir',
|
||||||
scopes: key.scopes.split(',').map((s) => s.trim()),
|
scopes,
|
||||||
isApiKey: true,
|
isApiKey: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,9 +22,13 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/blogs')
|
@Controller('admin/blogs')
|
||||||
export class BlogsController {
|
export class BlogsController {
|
||||||
constructor(private readonly blogsService: BlogsService) {}
|
constructor(private readonly blogsService: BlogsService) {}
|
||||||
|
|||||||
@ -21,9 +21,13 @@ import {
|
|||||||
|
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت دستهبندیها')
|
@ApiTags('Admin - مدیریت دستهبندیها')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/categories')
|
@Controller('admin/categories')
|
||||||
export class CategoriesController {
|
export class CategoriesController {
|
||||||
constructor(private readonly categoriesService: CategoriesService) {}
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|||||||
@ -14,15 +14,26 @@ import {
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
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';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
'application/pdf',
|
||||||
|
];
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت رسانه (تصاویر)')
|
@ApiTags('Admin - مدیریت رسانه (تصاویر)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/media')
|
@Controller('admin/media')
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(private readonly mediaService: MediaService) {}
|
constructor(private readonly mediaService: MediaService) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'لیست فایلهای رسانه' })
|
@ApiOperation({ summary: 'لیست فایلهای رسانه' })
|
||||||
async getAllMedia() {
|
async getAllMedia() {
|
||||||
@ -31,15 +42,31 @@ export class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' })
|
@ApiOperation({ summary: 'آپلود فایل جدید توسط ادمین' })
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@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) {
|
async uploadFile(@UploadedFile() file: Express.Multer.File) {
|
||||||
if (!file) throw new BadRequestException('File is missing');
|
if (!file) throw new BadRequestException('File is missing');
|
||||||
const data = await this.mediaService.uploadFile(file);
|
const data = await this.mediaService.uploadFile(file);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Delete('bulk')
|
@Delete('bulk')
|
||||||
@ApiOperation({ summary: 'حذف دستهجمعی فایلها' })
|
@ApiOperation({ summary: 'حذف دستهجمعی فایلها' })
|
||||||
async deleteManyMedia(@Body('ids') ids: string[]) {
|
async deleteManyMedia(@Body('ids') ids: string[]) {
|
||||||
@ -50,7 +77,6 @@ export class MediaController {
|
|||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'حذف فایل' })
|
@ApiOperation({ summary: 'حذف فایل' })
|
||||||
async deleteMedia(@Param('id') id: string) {
|
async deleteMedia(@Param('id') id: string) {
|
||||||
@ -58,7 +84,6 @@ export class MediaController {
|
|||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',
|
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',
|
||||||
|
|||||||
@ -17,9 +17,13 @@ import {
|
|||||||
|
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/pets')
|
@Controller('admin/pets')
|
||||||
export class PetsController {
|
export class PetsController {
|
||||||
constructor(private readonly petsService: PetsService) {}
|
constructor(private readonly petsService: PetsService) {}
|
||||||
|
|||||||
@ -8,13 +8,17 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - گزارشات')
|
@ApiTags('Admin - گزارشات')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/reports')
|
@Controller('admin/reports')
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private readonly reportsService: ReportsService) {}
|
constructor(private readonly reportsService: ReportsService) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر',
|
summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر',
|
||||||
|
|||||||
@ -21,9 +21,13 @@ import {
|
|||||||
|
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('admin/wiki')
|
@Controller('admin/wiki')
|
||||||
export class WikiController {
|
export class WikiController {
|
||||||
constructor(private readonly wikiService: WikiService) {}
|
constructor(private readonly wikiService: WikiService) {}
|
||||||
|
|||||||
@ -1,11 +1,29 @@
|
|||||||
export const DEFAULT_JWT_SECRET =
|
import * as crypto from 'crypto';
|
||||||
'canina_jwt_fallback_access_secret_32_characters_minimum_len_2026';
|
|
||||||
|
|
||||||
export const DEFAULT_JWT_REFRESH_SECRET =
|
const devAccessSecret =
|
||||||
'canina_jwt_fallback_refresh_secret_32_characters_minimum_len_2026';
|
process.env.NODE_ENV !== 'production'
|
||||||
|
? crypto.randomBytes(32).toString('hex')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export const getJwtSecret = (): string =>
|
const devRefreshSecret =
|
||||||
process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || DEFAULT_JWT_SECRET;
|
process.env.NODE_ENV !== 'production'
|
||||||
|
? crypto.randomBytes(32).toString('hex')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export const getJwtRefreshSecret = (): string =>
|
export const getJwtSecret = (): string => {
|
||||||
process.env.JWT_REFRESH_SECRET || DEFAULT_JWT_REFRESH_SECRET;
|
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!;
|
||||||
|
};
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export class AuthController {
|
|||||||
return this.authService.login(loginDto);
|
return this.authService.login(loginDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 300000 } }) // 5 attempts per 5 minutes
|
||||||
@Post('admin-login')
|
@Post('admin-login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
|
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
|
||||||
@ -116,11 +117,18 @@ export class AuthController {
|
|||||||
return this.authService.refresh(req.user);
|
return this.authService.refresh(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'خروج از حساب کاربری' })
|
@ApiOperation({ summary: 'خروج از حساب کاربری و ابطال توکن' })
|
||||||
@ApiOkResponse({ description: 'با موفقیت خارج شدید' })
|
@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: 'با موفقیت خارج شدید' };
|
return { success: true, message: 'با موفقیت خارج شدید' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,11 +8,14 @@ import { UsersModule } from '../users/users.module';
|
|||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { getJwtSecret } from './auth.constants';
|
import { getJwtSecret } from './auth.constants';
|
||||||
|
|
||||||
|
import { RedisModule } from '../redis/redis.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
forwardRef(() => AdminModule),
|
forwardRef(() => AdminModule),
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
RedisModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
useFactory: () => ({
|
useFactory: () => ({
|
||||||
secret: getJwtSecret(),
|
secret: getJwtSecret(),
|
||||||
|
|||||||
@ -155,10 +155,11 @@ export class AuthService {
|
|||||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
const { password: _p, ...safeUser } = user;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
user,
|
user: safeUser,
|
||||||
accessToken,
|
accessToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -210,10 +211,11 @@ export class AuthService {
|
|||||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
const { password: _p, ...safeUser } = user;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
user,
|
user: safeUser,
|
||||||
accessToken,
|
accessToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -251,18 +253,17 @@ export class AuthService {
|
|||||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||||
const accessToken = this.jwtService.sign(payload);
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
const { password: _pw, ...safeUser } = user;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
user,
|
user: safeUser,
|
||||||
accessToken,
|
accessToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminLogin(body: AdminLoginInput) {
|
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'
|
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
|
||||||
const dbAdmin = await this.prisma.user.findFirst({
|
const dbAdmin = await this.prisma.user.findFirst({
|
||||||
@ -280,34 +281,59 @@ export class AuthService {
|
|||||||
email: dbAdmin.email,
|
email: dbAdmin.email,
|
||||||
role: dbAdmin.role,
|
role: dbAdmin.role,
|
||||||
};
|
};
|
||||||
|
const { password: _p, ...safeUser } = dbAdmin;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
user: dbAdmin,
|
user: safeUser,
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to validated environment variable credentials
|
// Fallback to validated environment variable credentials only if configured securely
|
||||||
if (body.email === adminEmail && body.password === adminPassword) {
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
const payload = {
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
sub: '12345678-1234-1234-1234-123456789012',
|
|
||||||
email: body.email,
|
if (
|
||||||
role: 'Admin',
|
adminEmail &&
|
||||||
};
|
adminPassword &&
|
||||||
return {
|
adminPassword.length >= 8 &&
|
||||||
success: true,
|
body.email &&
|
||||||
data: {
|
body.password
|
||||||
user: {
|
) {
|
||||||
id: '12345678-1234-1234-1234-123456789012',
|
const emailMatches =
|
||||||
email: body.email,
|
body.email.length === adminEmail.length &&
|
||||||
role: 'Admin',
|
crypto.timingSafeEqual(
|
||||||
|
Buffer.from(crypto.createHash('sha256').update(body.email).digest()),
|
||||||
|
Buffer.from(crypto.createHash('sha256').update(adminEmail).digest()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordMatches =
|
||||||
|
crypto.timingSafeEqual(
|
||||||
|
Buffer.from(crypto.createHash('sha256').update(body.password).digest()),
|
||||||
|
Buffer.from(crypto.createHash('sha256').update(adminPassword).digest()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (emailMatches && passwordMatches) {
|
||||||
|
const payload = {
|
||||||
|
sub: '12345678-1234-1234-1234-123456789012',
|
||||||
|
email: adminEmail,
|
||||||
|
role: 'Admin',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: '12345678-1234-1234-1234-123456789012',
|
||||||
|
email: body.email,
|
||||||
|
role: 'Admin',
|
||||||
|
},
|
||||||
|
accessToken: this.jwtService.sign(payload),
|
||||||
},
|
},
|
||||||
accessToken: this.jwtService.sign(payload),
|
};
|
||||||
},
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@ -336,4 +362,21 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async blacklistToken(token: string) {
|
||||||
|
try {
|
||||||
|
const decoded: any = this.jwtService.decode(token);
|
||||||
|
let ttl = 7 * 24 * 3600; // default 7 days
|
||||||
|
if (decoded && decoded.exp) {
|
||||||
|
const remaining = decoded.exp - Math.floor(Date.now() / 1000);
|
||||||
|
if (remaining > 0) {
|
||||||
|
ttl = remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.redisService.set(`bl_token:${token}`, '1', ttl);
|
||||||
|
} catch {
|
||||||
|
// Fallback: cache for 7 days
|
||||||
|
await this.redisService.set(`bl_token:${token}`, '1', 7 * 24 * 3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { getJwtSecret } from './auth.constants';
|
import { getJwtSecret } from './auth.constants';
|
||||||
|
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@ -13,18 +16,37 @@ export interface JwtPayload {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(private readonly usersService: UsersService) {
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly redisService: RedisService,
|
||||||
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: getJwtSecret(),
|
secretOrKey: getJwtSecret(),
|
||||||
|
passReqToCallback: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: JwtPayload) {
|
async validate(req: Request, payload: JwtPayload) {
|
||||||
// Bypass DB lookup for local admin user to prevent UUID casting errors
|
const rawToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||||
|
if (rawToken) {
|
||||||
|
const isBlacklisted = await this.redisService.get(`bl_token:${rawToken}`);
|
||||||
|
if (isBlacklisted) {
|
||||||
|
throw new UnauthorizedException('این توکن باطل شده است. لطفاً مجدداً وارد شوید');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle fallback admin ID only if ADMIN_EMAIL is configured and matches payload
|
||||||
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
|
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
|
||||||
return { id: payload.sub, email: payload.email, role: payload.role };
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
if (adminEmail && payload.email === adminEmail) {
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: adminEmail,
|
||||||
|
role: 'Admin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersService.findById(payload.sub);
|
const user = await this.usersService.findById(payload.sub);
|
||||||
|
|||||||
@ -52,5 +52,18 @@ export function validateEnv(config: Record<string, unknown>) {
|
|||||||
throw new Error(`Environment Validation Error: ${messages}`);
|
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;
|
return validatedConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { ROLES_KEY } from '../decorators/roles.decorator';
|
|||||||
interface RequestWithUser {
|
interface RequestWithUser {
|
||||||
user?: {
|
user?: {
|
||||||
role?: string;
|
role?: string;
|
||||||
|
isApiKey?: boolean;
|
||||||
|
scopes?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,11 +35,23 @@ export class RolesGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If request originates from an API key, verify it has admin scope for admin-guarded routes
|
||||||
|
if (user.isApiKey) {
|
||||||
|
const scopes = Array.isArray(user.scopes) ? user.scopes : [];
|
||||||
|
const hasAdminScope =
|
||||||
|
scopes.includes('*') ||
|
||||||
|
scopes.includes('admin') ||
|
||||||
|
scopes.includes('all');
|
||||||
|
if (!hasAdminScope) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'کلید API ارائهشده فاقد اسکوپ مدیریتی لازم برای این عملیات است',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userRoleLower = user.role.toLowerCase();
|
const userRoleLower = user.role.toLowerCase();
|
||||||
const hasRole = requiredRoles.some(
|
const hasRole = requiredRoles.some(
|
||||||
(r) =>
|
(r) => r.toLowerCase() === userRoleLower,
|
||||||
r.toLowerCase() === userRoleLower ||
|
|
||||||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
|
|
||||||
);
|
);
|
||||||
if (!hasRole) {
|
if (!hasRole) {
|
||||||
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
||||||
|
|||||||
@ -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 { ApiExcludeController } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@ApiExcludeController()
|
@ApiExcludeController()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin', 'SUPER_ADMIN')
|
||||||
@Controller('metrics')
|
@Controller('metrics')
|
||||||
export class MetricsController {
|
export class MetricsController {
|
||||||
private static requestCount = 0;
|
private static requestCount = 0;
|
||||||
|
|||||||
@ -19,18 +19,10 @@ import compression from 'compression';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
validateEnv(process.env);
|
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);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
// Trust reverse proxy (Nginx / Docker ingress) to resolve real client IP from X-Forwarded-For
|
// Trust 1 reverse proxy hop (Nginx / Cloudflare / Ingress) to prevent IP spoofing
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Serve static uploads folder with 1-year aggressive browser caching (immutable)
|
// Serve static uploads folder with 1-year aggressive browser caching (immutable)
|
||||||
const staticUploadOptions = {
|
const staticUploadOptions = {
|
||||||
@ -54,14 +46,44 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets
|
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
||||||
|
connectSrc: ["'self'", 'https:'],
|
||||||
|
fontSrc: ["'self'", 'data:'],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
upgradeInsecureRequests: [],
|
||||||
|
},
|
||||||
|
} : false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
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({
|
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,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,22 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
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 {
|
export interface UserReqPayload {
|
||||||
user: {
|
user: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -25,7 +41,36 @@ export interface UserReqPayload {
|
|||||||
@ApiTags('Prescriptions - نسخه و تاییدیه دارویی')
|
@ApiTags('Prescriptions - نسخه و تاییدیه دارویی')
|
||||||
@Controller('prescriptions')
|
@Controller('prescriptions')
|
||||||
export class PrescriptionsController {
|
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()
|
@Post()
|
||||||
@ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' })
|
@ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' })
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { PrescriptionsService } from './prescriptions.service';
|
|||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { SmsService } from '../common/services/sms.service';
|
import { SmsService } from '../common/services/sms.service';
|
||||||
|
|
||||||
|
import { AdminModule } from '../admin/admin.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, AdminModule],
|
||||||
controllers: [PrescriptionsController],
|
controllers: [PrescriptionsController],
|
||||||
providers: [PrescriptionsService, SmsService],
|
providers: [PrescriptionsService, SmsService],
|
||||||
exports: [PrescriptionsService],
|
exports: [PrescriptionsService],
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.client = new Redis({
|
this.client = new Redis({
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
port: Number(process.env.REDIS_PORT) || 16379,
|
port: Number(process.env.REDIS_PORT) || 16379,
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,13 @@ export const DEFAULT_UI_TEXTS: Record<string, string> = {
|
|||||||
contact_telegram: 'https://t.me/caninairan',
|
contact_telegram: 'https://t.me/caninairan',
|
||||||
contact_instagram: 'https://instagram.com/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 ===
|
// === Header / Navigation ===
|
||||||
shipping_notice:
|
shipping_notice:
|
||||||
'ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان',
|
'ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان',
|
||||||
|
|||||||
@ -4,9 +4,9 @@ services:
|
|||||||
container_name: canino_db_prod
|
container_name: canino_db_prod
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: canino_prod
|
POSTGRES_USER: ${POSTGRES_USER:-canino_prod}
|
||||||
POSTGRES_PASSWORD: caninopassword_prod
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: caninodb_prod
|
POSTGRES_DB: ${POSTGRES_DB:-caninodb_prod}
|
||||||
volumes:
|
volumes:
|
||||||
- canina_prod_db:/var/lib/postgresql/data
|
- canina_prod_db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
@ -15,6 +15,14 @@ services:
|
|||||||
redis_prod:
|
redis_prod:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: canino_redis_prod
|
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
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- traefik_public
|
- traefik_public
|
||||||
@ -29,9 +37,14 @@ services:
|
|||||||
- 217.218.127.127
|
- 217.218.127.127
|
||||||
- 217.218.155.155
|
- 217.218.155.155
|
||||||
environment:
|
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_HOST=redis_prod
|
||||||
- REDIS_PORT=6379
|
- 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
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
- canina_prod_uploads:/app/uploads
|
- canina_prod_uploads:/app/uploads
|
||||||
|
|||||||
@ -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 />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -137,6 +137,11 @@ export async function GET(
|
|||||||
responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
|
responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
|
||||||
responseHeaders.set('X-Content-Type-Options', 'nosniff');
|
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, {
|
return new Response(backendRes.body, {
|
||||||
status: backendRes.status,
|
status: backendRes.status,
|
||||||
statusText: backendRes.statusText,
|
statusText: backendRes.statusText,
|
||||||
|
|||||||
@ -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) {
|
if (!secret || secret !== expectedSecret) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: 'Invalid revalidation secret token' },
|
{ success: false, message: 'Invalid revalidation secret token' },
|
||||||
|
|||||||
@ -733,7 +733,7 @@ export default function CheckoutPage() {
|
|||||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
|
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] text-gray-500 block">صاحب حساب:</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
|||||||
const api = (await import("../lib/services/api")).default;
|
const api = (await import("../lib/services/api")).default;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
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" }
|
headers: { "Content-Type": "multipart/form-data" }
|
||||||
});
|
});
|
||||||
const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name;
|
const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name;
|
||||||
|
|||||||
@ -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 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>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</strong> (بانک سامان)</div>
|
||||||
<div>شبا: <strong>IR-65 0560 6118 2800 5725 1015 01</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>
|
</div>
|
||||||
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
|
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
|
||||||
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.
|
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user