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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 06:11:59 +07:00
parent 271ad76e6f
commit 811417d77d
6 changed files with 136 additions and 155 deletions

View File

@@ -10,21 +10,29 @@ import {
type RefundResult, type RefundResult,
} from './payment-gateway.interface'; } 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() @Injectable()
export class MomoService implements IPaymentGateway { export class MomoService implements IPaymentGateway {
private readonly logger = new Logger(MomoService.name); private readonly logger = new Logger(MomoService.name);
readonly provider: PaymentProvider = 'MOMO'; readonly provider: PaymentProvider = 'MOMO';
private get partnerCode(): string { private get partnerCode(): string {
return process.env['MOMO_PARTNER_CODE'] ?? ''; return requireEnv('MOMO_PARTNER_CODE');
} }
private get accessKey(): string { private get accessKey(): string {
return process.env['MOMO_ACCESS_KEY'] ?? ''; return requireEnv('MOMO_ACCESS_KEY');
} }
private get secretKey(): string { private get secretKey(): string {
return process.env['MOMO_SECRET_KEY'] ?? ''; return requireEnv('MOMO_SECRET_KEY');
} }
private get endpoint(): string { private get endpoint(): string {

View File

@@ -10,17 +10,25 @@ import {
type RefundResult, type RefundResult,
} from './payment-gateway.interface'; } 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() @Injectable()
export class VnpayService implements IPaymentGateway { export class VnpayService implements IPaymentGateway {
private readonly logger = new Logger(VnpayService.name); private readonly logger = new Logger(VnpayService.name);
readonly provider: PaymentProvider = 'VNPAY'; readonly provider: PaymentProvider = 'VNPAY';
private get tmnCode(): string { private get tmnCode(): string {
return process.env['VNPAY_TMN_CODE'] ?? ''; return requireEnv('VNPAY_TMN_CODE');
} }
private get hashSecret(): string { private get hashSecret(): string {
return process.env['VNPAY_HASH_SECRET'] ?? ''; return requireEnv('VNPAY_HASH_SECRET');
} }
private get baseUrl(): string { private get baseUrl(): string {

View File

@@ -10,21 +10,29 @@ import {
type RefundResult, type RefundResult,
} from './payment-gateway.interface'; } 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() @Injectable()
export class ZalopayService implements IPaymentGateway { export class ZalopayService implements IPaymentGateway {
private readonly logger = new Logger(ZalopayService.name); private readonly logger = new Logger(ZalopayService.name);
readonly provider: PaymentProvider = 'ZALOPAY'; readonly provider: PaymentProvider = 'ZALOPAY';
private get appId(): string { private get appId(): string {
return process.env['ZALOPAY_APP_ID'] ?? ''; return requireEnv('ZALOPAY_APP_ID');
} }
private get key1(): string { private get key1(): string {
return process.env['ZALOPAY_KEY1'] ?? ''; return requireEnv('ZALOPAY_KEY1');
} }
private get key2(): string { private get key2(): string {
return process.env['ZALOPAY_KEY2'] ?? ''; return requireEnv('ZALOPAY_KEY2');
} }
private get endpoint(): string { private get endpoint(): string {

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { LoggerService } from '@modules/shared/infrastructure/logger.service'; import { LoggerService } from '@modules/shared/infrastructure/logger.service';
import { import {
@@ -61,159 +62,103 @@ export class ListingIndexerService {
limit: number, limit: number,
offset: number, offset: number,
): Promise<ListingDocument[]> { ): Promise<ListingDocument[]> {
const rows = await this.prisma.$queryRaw< const listings = await this.prisma.listing.findMany({
Array<{ where: { status: 'ACTIVE' },
id: string; orderBy: { publishedAt: { sort: 'desc', nulls: 'last' } },
propertyId: string; skip: offset,
transactionType: string; take: limit,
priceVND: bigint; include: { property: true },
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}
`;
return rows.map((row) => ({ if (listings.length === 0) return [];
id: row.id,
listingId: row.id, const propertyIds = listings.map((l) => l.property.id);
propertyId: row.propertyId, const coords = await this.prisma.$queryRaw<
title: row.title, Array<{ id: string; lat: number | null; lng: number | null }>
description: row.description, >(
propertyType: row.propertyType, Prisma.sql`SELECT "id", ST_Y("location"::geometry) AS lat, ST_X("location"::geometry) AS lng FROM "Property" WHERE "id" IN (${Prisma.join(propertyIds)})`
transactionType: row.transactionType, );
priceVND: Number(row.priceVND), const coordMap = new Map(coords.map((c) => [c.id, c]));
pricePerM2: row.pricePerM2,
areaM2: row.areaM2, return listings.map((l) => {
bedrooms: row.bedrooms, const p = l.property;
bathrooms: row.bathrooms, const c = coordMap.get(p.id);
floors: row.floors, return {
direction: row.direction, id: l.id,
address: row.address, listingId: l.id,
ward: row.ward, propertyId: p.id,
district: row.district, title: p.title,
city: row.city, description: p.description,
location: [row.lat ?? 0, row.lng ?? 0] as [number, number], propertyType: p.propertyType,
agentId: row.agentId, transactionType: l.transactionType,
sellerId: row.sellerId, priceVND: Number(l.priceVND),
status: row.status, pricePerM2: l.pricePerM2,
publishedAt: row.publishedAt ? Math.floor(row.publishedAt.getTime() / 1000) : 0, areaM2: p.areaM2,
viewCount: row.viewCount, bedrooms: p.bedrooms,
saveCount: row.saveCount, bathrooms: p.bathrooms,
projectName: row.projectName, floors: p.floors,
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], 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<ListingDocument | null> { async fetchListingDocumentById(listingId: string): Promise<ListingDocument | null> {
const rows = await this.prisma.$queryRaw< const listing = await this.prisma.listing.findUnique({
Array<{ where: { id: listingId },
id: string; include: { property: true },
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}
`;
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 { return {
id: row.id, id: listing.id,
listingId: row.id, listingId: listing.id,
propertyId: row.propertyId, propertyId: p.id,
title: row.title, title: p.title,
description: row.description, description: p.description,
propertyType: row.propertyType, propertyType: p.propertyType,
transactionType: row.transactionType, transactionType: listing.transactionType,
priceVND: Number(row.priceVND), priceVND: Number(listing.priceVND),
pricePerM2: row.pricePerM2, pricePerM2: listing.pricePerM2,
areaM2: row.areaM2, areaM2: p.areaM2,
bedrooms: row.bedrooms, bedrooms: p.bedrooms,
bathrooms: row.bathrooms, bathrooms: p.bathrooms,
floors: row.floors, floors: p.floors,
direction: row.direction, direction: p.direction,
address: row.address, address: p.address,
ward: row.ward, ward: p.ward,
district: row.district, district: p.district,
city: row.city, city: p.city,
location: [row.lat ?? 0, row.lng ?? 0], location: [c?.lat ?? 0, c?.lng ?? 0],
agentId: row.agentId, agentId: listing.agentId,
sellerId: row.sellerId, sellerId: listing.sellerId,
status: row.status, status: listing.status,
publishedAt: row.publishedAt ? Math.floor(row.publishedAt.getTime() / 1000) : 0, publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : 0,
viewCount: row.viewCount, viewCount: listing.viewCount,
saveCount: row.saveCount, saveCount: listing.saveCount,
projectName: row.projectName, projectName: p.projectName,
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
}; };
} }

View File

@@ -6,8 +6,17 @@ class Settings(BaseSettings):
debug: bool = False debug: bool = False
model_path: str = "/app/models" model_path: str = "/app/models"
log_level: str = "info" log_level: str = "info"
cors_origins: str = ""
api_key: str = ""
rate_limit: str = "60/minute"
model_config = {"env_prefix": "AI_"} 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() settings = Settings()

View File

@@ -11,9 +11,12 @@ app = FastAPI(
redoc_url="/redoc", 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=settings.cors_origin_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],