From 811417d77dd40ed6a57c22ad9ae39cdc07b628ac Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 06:11:59 +0700 Subject: [PATCH] fix: restrict CORS origins, require payment env vars, replace raw SQL with Prisma findMany - AI service: replace allow_origins=["*"] with env-configured AI_CORS_ORIGINS - Payment services (VNPay, MoMo, ZaloPay): use requireEnv() instead of empty string defaults for credentials - Search indexer: replace raw SQL template literals with Prisma findMany + parameterized PostGIS queries Co-Authored-By: Paperclip --- .../infrastructure/services/momo.service.ts | 14 +- .../infrastructure/services/vnpay.service.ts | 12 +- .../services/zalopay.service.ts | 14 +- .../services/listing-indexer.service.ts | 237 +++++++----------- libs/ai-services/app/config.py | 9 + libs/ai-services/app/main.py | 5 +- 6 files changed, 136 insertions(+), 155 deletions(-) diff --git a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts index b62e219..da85a6a 100644 --- a/apps/api/src/modules/payments/infrastructure/services/momo.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/momo.service.ts @@ -10,21 +10,29 @@ import { type RefundResult, } from './payment-gateway.interface'; +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + @Injectable() export class MomoService implements IPaymentGateway { private readonly logger = new Logger(MomoService.name); readonly provider: PaymentProvider = 'MOMO'; private get partnerCode(): string { - return process.env['MOMO_PARTNER_CODE'] ?? ''; + return requireEnv('MOMO_PARTNER_CODE'); } private get accessKey(): string { - return process.env['MOMO_ACCESS_KEY'] ?? ''; + return requireEnv('MOMO_ACCESS_KEY'); } private get secretKey(): string { - return process.env['MOMO_SECRET_KEY'] ?? ''; + return requireEnv('MOMO_SECRET_KEY'); } private get endpoint(): string { diff --git a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts index eaa4a66..f25a12e 100644 --- a/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/vnpay.service.ts @@ -10,17 +10,25 @@ import { type RefundResult, } from './payment-gateway.interface'; +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + @Injectable() export class VnpayService implements IPaymentGateway { private readonly logger = new Logger(VnpayService.name); readonly provider: PaymentProvider = 'VNPAY'; private get tmnCode(): string { - return process.env['VNPAY_TMN_CODE'] ?? ''; + return requireEnv('VNPAY_TMN_CODE'); } private get hashSecret(): string { - return process.env['VNPAY_HASH_SECRET'] ?? ''; + return requireEnv('VNPAY_HASH_SECRET'); } private get baseUrl(): string { diff --git a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts index 2b019cb..d2f8381 100644 --- a/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts +++ b/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts @@ -10,21 +10,29 @@ import { type RefundResult, } from './payment-gateway.interface'; +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + @Injectable() export class ZalopayService implements IPaymentGateway { private readonly logger = new Logger(ZalopayService.name); readonly provider: PaymentProvider = 'ZALOPAY'; private get appId(): string { - return process.env['ZALOPAY_APP_ID'] ?? ''; + return requireEnv('ZALOPAY_APP_ID'); } private get key1(): string { - return process.env['ZALOPAY_KEY1'] ?? ''; + return requireEnv('ZALOPAY_KEY1'); } private get key2(): string { - return process.env['ZALOPAY_KEY2'] ?? ''; + return requireEnv('ZALOPAY_KEY2'); } private get endpoint(): string { diff --git a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts index 71d10eb..1b34fcc 100644 --- a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts +++ b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; import { LoggerService } from '@modules/shared/infrastructure/logger.service'; import { @@ -61,159 +62,103 @@ export class ListingIndexerService { limit: number, offset: number, ): Promise { - const rows = await this.prisma.$queryRaw< - Array<{ - id: string; - propertyId: string; - transactionType: string; - priceVND: bigint; - pricePerM2: number | null; - agentId: string | null; - sellerId: string; - status: string; - viewCount: number; - saveCount: number; - publishedAt: Date | null; - title: string; - description: string; - propertyType: string; - areaM2: number; - bedrooms: number | null; - bathrooms: number | null; - floors: number | null; - direction: string | null; - address: string; - ward: string; - district: string; - city: string; - projectName: string | null; - amenities: unknown; - lat: number | null; - lng: number | null; - }> - >` - SELECT - l."id", l."propertyId", l."transactionType", l."priceVND", l."pricePerM2", - l."agentId", l."sellerId", l."status", l."viewCount", l."saveCount", l."publishedAt", - p."title", p."description", p."propertyType", p."areaM2", - p."bedrooms", p."bathrooms", p."floors", p."direction", - p."address", p."ward", p."district", p."city", p."projectName", p."amenities", - ST_Y(p."location"::geometry) AS lat, - ST_X(p."location"::geometry) AS lng - FROM "Listing" l - JOIN "Property" p ON l."propertyId" = p."id" - WHERE l."status" = 'ACTIVE' - ORDER BY l."publishedAt" DESC NULLS LAST - LIMIT ${limit} OFFSET ${offset} - `; + const listings = await this.prisma.listing.findMany({ + where: { status: 'ACTIVE' }, + orderBy: { publishedAt: { sort: 'desc', nulls: 'last' } }, + skip: offset, + take: limit, + include: { property: true }, + }); - return rows.map((row) => ({ - id: row.id, - listingId: row.id, - propertyId: row.propertyId, - title: row.title, - description: row.description, - propertyType: row.propertyType, - transactionType: row.transactionType, - priceVND: Number(row.priceVND), - pricePerM2: row.pricePerM2, - areaM2: row.areaM2, - bedrooms: row.bedrooms, - bathrooms: row.bathrooms, - floors: row.floors, - direction: row.direction, - address: row.address, - ward: row.ward, - district: row.district, - city: row.city, - location: [row.lat ?? 0, row.lng ?? 0] as [number, number], - agentId: row.agentId, - sellerId: row.sellerId, - status: row.status, - publishedAt: row.publishedAt ? Math.floor(row.publishedAt.getTime() / 1000) : 0, - viewCount: row.viewCount, - saveCount: row.saveCount, - projectName: row.projectName, - amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], - })); + if (listings.length === 0) return []; + + const propertyIds = listings.map((l) => l.property.id); + const coords = await this.prisma.$queryRaw< + Array<{ id: string; lat: number | null; lng: number | null }> + >( + Prisma.sql`SELECT "id", ST_Y("location"::geometry) AS lat, ST_X("location"::geometry) AS lng FROM "Property" WHERE "id" IN (${Prisma.join(propertyIds)})` + ); + const coordMap = new Map(coords.map((c) => [c.id, c])); + + return listings.map((l) => { + const p = l.property; + const c = coordMap.get(p.id); + return { + id: l.id, + listingId: l.id, + propertyId: p.id, + title: p.title, + description: p.description, + propertyType: p.propertyType, + transactionType: l.transactionType, + priceVND: Number(l.priceVND), + pricePerM2: l.pricePerM2, + areaM2: p.areaM2, + bedrooms: p.bedrooms, + bathrooms: p.bathrooms, + floors: p.floors, + direction: p.direction, + address: p.address, + ward: p.ward, + district: p.district, + city: p.city, + location: [c?.lat ?? 0, c?.lng ?? 0] as [number, number], + agentId: l.agentId, + sellerId: l.sellerId, + status: l.status, + publishedAt: l.publishedAt ? Math.floor(l.publishedAt.getTime() / 1000) : 0, + viewCount: l.viewCount, + saveCount: l.saveCount, + projectName: p.projectName, + amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [], + }; + }); } async fetchListingDocumentById(listingId: string): Promise { - const rows = await this.prisma.$queryRaw< - Array<{ - id: string; - propertyId: string; - transactionType: string; - priceVND: bigint; - pricePerM2: number | null; - agentId: string | null; - sellerId: string; - status: string; - viewCount: number; - saveCount: number; - publishedAt: Date | null; - title: string; - description: string; - propertyType: string; - areaM2: number; - bedrooms: number | null; - bathrooms: number | null; - floors: number | null; - direction: string | null; - address: string; - ward: string; - district: string; - city: string; - projectName: string | null; - amenities: unknown; - lat: number | null; - lng: number | null; - }> - >` - SELECT - l."id", l."propertyId", l."transactionType", l."priceVND", l."pricePerM2", - l."agentId", l."sellerId", l."status", l."viewCount", l."saveCount", l."publishedAt", - p."title", p."description", p."propertyType", p."areaM2", - p."bedrooms", p."bathrooms", p."floors", p."direction", - p."address", p."ward", p."district", p."city", p."projectName", p."amenities", - ST_Y(p."location"::geometry) AS lat, - ST_X(p."location"::geometry) AS lng - FROM "Listing" l - JOIN "Property" p ON l."propertyId" = p."id" - WHERE l."id" = ${listingId} - `; + const listing = await this.prisma.listing.findUnique({ + where: { id: listingId }, + include: { property: true }, + }); - if (rows.length === 0) return null; + if (!listing) return null; + + const p = listing.property; + const coords = await this.prisma.$queryRaw< + Array<{ lat: number | null; lng: number | null }> + >( + Prisma.sql`SELECT ST_Y("location"::geometry) AS lat, ST_X("location"::geometry) AS lng FROM "Property" WHERE "id" = ${p.id}` + ); + const c = coords[0]; - const row = rows[0]!; return { - id: row.id, - listingId: row.id, - propertyId: row.propertyId, - title: row.title, - description: row.description, - propertyType: row.propertyType, - transactionType: row.transactionType, - priceVND: Number(row.priceVND), - pricePerM2: row.pricePerM2, - areaM2: row.areaM2, - bedrooms: row.bedrooms, - bathrooms: row.bathrooms, - floors: row.floors, - direction: row.direction, - address: row.address, - ward: row.ward, - district: row.district, - city: row.city, - location: [row.lat ?? 0, row.lng ?? 0], - agentId: row.agentId, - sellerId: row.sellerId, - status: row.status, - publishedAt: row.publishedAt ? Math.floor(row.publishedAt.getTime() / 1000) : 0, - viewCount: row.viewCount, - saveCount: row.saveCount, - projectName: row.projectName, - amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], + id: listing.id, + listingId: listing.id, + propertyId: p.id, + title: p.title, + description: p.description, + propertyType: p.propertyType, + transactionType: listing.transactionType, + priceVND: Number(listing.priceVND), + pricePerM2: listing.pricePerM2, + areaM2: p.areaM2, + bedrooms: p.bedrooms, + bathrooms: p.bathrooms, + floors: p.floors, + direction: p.direction, + address: p.address, + ward: p.ward, + district: p.district, + city: p.city, + location: [c?.lat ?? 0, c?.lng ?? 0], + agentId: listing.agentId, + sellerId: listing.sellerId, + status: listing.status, + publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : 0, + viewCount: listing.viewCount, + saveCount: listing.saveCount, + projectName: p.projectName, + amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [], }; } diff --git a/libs/ai-services/app/config.py b/libs/ai-services/app/config.py index 8dd42e8..e91fff6 100644 --- a/libs/ai-services/app/config.py +++ b/libs/ai-services/app/config.py @@ -6,8 +6,17 @@ class Settings(BaseSettings): debug: bool = False model_path: str = "/app/models" log_level: str = "info" + cors_origins: str = "" + api_key: str = "" + rate_limit: str = "60/minute" model_config = {"env_prefix": "AI_"} + @property + def cors_origin_list(self) -> list[str]: + if not self.cors_origins: + return [] + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + settings = Settings() diff --git a/libs/ai-services/app/main.py b/libs/ai-services/app/main.py index fb332ce..6243ed6 100644 --- a/libs/ai-services/app/main.py +++ b/libs/ai-services/app/main.py @@ -11,9 +11,12 @@ app = FastAPI( redoc_url="/redoc", ) +if not settings.cors_origin_list: + raise RuntimeError("AI_CORS_ORIGINS must be set (comma-separated list of allowed origins)") + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=settings.cors_origin_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"],