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:
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[]) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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=["*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user