feat: manage epay settings securely
Deploy staging to test server / deploy (push) Failing after 5m31s

This commit is contained in:
linmeng
2026-07-29 16:36:35 +08:00
parent 9a20b748e0
commit d956da2993
22 changed files with 921 additions and 52 deletions
@@ -0,0 +1,121 @@
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { adminErrorResponse } from "@/lib/admin/http";
import { suggestedEpayUrls } from "@/lib/epay/config";
import { encryptEpayKey } from "@/lib/epay/encryption";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
const httpUrl = z.string().trim().min(1).max(2048).url().refine((value) => /^https?:\/\//i.test(value), "必须使用 HTTP(S)");
const settingsSchema = z.object({
gatewayUrl: httpUrl,
pid: z.string().trim().min(1).max(200),
notifyUrl: httpUrl,
returnUrl: httpUrl,
siteName: z.string().trim().min(1).max(100),
chatEnabled: z.boolean(),
newKey: z.string().min(1).max(1000).optional(),
}).strict();
type SettingsRow = {
gateway_url: string;
pid: string;
encrypted_key: string;
notify_url: string;
return_url: string;
site_name: string;
chat_enabled: boolean;
updated_at?: string;
};
function publicSettings(row: SettingsRow, source: "database" | "environment") {
return {
gatewayUrl: row.gateway_url,
pid: row.pid,
notifyUrl: row.notify_url,
returnUrl: row.return_url,
siteName: row.site_name,
keyConfigured: Boolean(row.encrypted_key),
complete: Boolean(row.gateway_url && row.pid && row.encrypted_key && row.notify_url && row.return_url && row.site_name),
source,
updatedAt: source === "database" ? row.updated_at ?? null : null,
};
}
function environmentSettings() {
const defaults = suggestedEpayUrls();
const row: SettingsRow = {
gateway_url: process.env.EPAY_GATEWAY_URL?.trim() || "",
pid: process.env.EPAY_PID?.trim() || "",
encrypted_key: process.env.EPAY_KEY?.trim() ? "configured" : "",
notify_url: process.env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl,
return_url: process.env.EPAY_RETURN_URL?.trim() || defaults.returnUrl,
site_name: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha",
chat_enabled: ["true", "1"].includes(process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase() || ""),
};
return publicSettings(row, "environment");
}
async function databaseRow() {
const { data, error } = await createAdminSupabaseClient()
.from("epay_settings")
.select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,updated_at")
.eq("id", true)
.maybeSingle();
if (error?.code === "42P01") return null;
if (error) throw new Error();
return data as SettingsRow | null;
}
export async function GET() {
try {
await requireAdminSession("read");
const row = await databaseRow();
if (row) return NextResponse.json(publicSettings(row, "database"));
const settings = environmentSettings();
return NextResponse.json(settings.complete || settings.keyConfigured
? settings
: { ...settings, source: "unconfigured" });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireAdminSession("write");
const parsed = settingsSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 });
const existing = await databaseRow();
if (!existing && !parsed.data.newKey) {
return NextResponse.json({ error: "首次保存数据库配置时必须输入新的商户密钥" }, { status: 400 });
}
const encryptedKey = parsed.data.newKey
? encryptEpayKey(parsed.data.newKey)
: existing!.encrypted_key;
const { error } = await createAdminSupabaseClient().rpc("admin_save_epay_settings", {
p_actor_user_id: session.user.id,
p_actor_email: session.user.email,
p_actor_role: session.role,
p_request_id: crypto.randomUUID(),
p_gateway_url: parsed.data.gatewayUrl.replace(/\/+$/, ""),
p_pid: parsed.data.pid,
p_encrypted_key: encryptedKey,
p_notify_url: parsed.data.notifyUrl,
p_return_url: parsed.data.returnUrl,
p_site_name: parsed.data.siteName,
p_chat_enabled: parsed.data.chatEnabled,
p_key_changed: Boolean(parsed.data.newKey),
});
if (error) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 });
const saved = await databaseRow();
if (!saved) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 });
return NextResponse.json(publicSettings(saved, "database"));
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
import { adminErrorResponse } from "@/lib/admin/http";
import { epaySubmitUrl, readEpayConfig } from "@/lib/epay/config";
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
export const runtime = "nodejs";
function reachableStatus(status: number) {
return status >= 200 && status < 500;
}
export async function POST() {
try {
await requireAdminSession("write");
const config = await readEpayConfig();
const submitUrl = epaySubmitUrl(config.gatewayUrl);
await assertPublicGatewayUrl(submitUrl);
const startedAt = performance.now();
let response = await fetch(submitUrl, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(8_000),
});
if (response.status === 405 || response.status === 501) {
response = await fetch(submitUrl, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(8_000),
});
}
const available = reachableStatus(response.status);
return NextResponse.json({
available,
message: available ? "当前已保存的易支付配置可访问" : "当前已保存的易支付配置暂不可用",
latencyMs: Math.round(performance.now() - startedAt),
status: response.status,
});
} catch (error) {
if (error instanceof AdminAuthorizationError) return adminErrorResponse(error);
return NextResponse.json({
available: false,
message: "当前已保存的易支付配置暂不可用",
});
}
}
@@ -4,7 +4,9 @@ import { z } from "zod";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { epaySign } from "@/lib/epay/sign";
import { readEpayAvailability } from "@/lib/epay/availability";
import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config";
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
export const runtime = "nodejs";
const schema = z.object({ packageId: z.string().uuid() });
@@ -14,14 +16,19 @@ export async function POST(request: Request) {
const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 });
const availability = await readEpayAvailability();
if (!availability.enabled) return NextResponse.json({ error: "在线支付暂未开放", code: "EPAY_DISABLED" }, { status: 403 });
const config = await readEpayConfig();
const submitUrl = epaySubmitUrl(config.gatewayUrl);
await assertPublicGatewayUrl(submitUrl);
const admin = createAdminSupabaseClient(); const { data: pack, error: packError } = await admin.from("payment_packages").select("id,name,price_cents,credits,enabled").eq("id", parsed.data.packageId).eq("enabled", true).maybeSingle();
if (packError || !pack) return NextResponse.json({ error: "套餐不存在或已下架" }, { status: 404 });
const config = readEpayConfig(); const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`;
const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`;
const { error: orderError } = await admin.from("payment_orders").insert({ order_no: orderNo, user_id: user.id, package_id: pack.id, money_cents: pack.price_cents, credits: pack.credits });
if (orderError) return NextResponse.json({ error: "创建订单失败" }, { status: 500 });
const params = { money: (pack.price_cents / 100).toFixed(2), name: pack.name, notify_url: config.notifyUrl, out_trade_no: orderNo, pid: config.pid, return_url: config.returnUrl, sitename: config.siteName, type: "alipay" };
const body = new URLSearchParams({ ...params, sign: epaySign(params, config.key), sign_type: "MD5" });
const upstream = await fetch(epaySubmitUrl(config.gatewayUrl), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) });
const upstream = await fetch(submitUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) });
if (!upstream.ok) return NextResponse.json({ error: "支付网关暂时不可用" }, { status: 502 });
const text = await upstream.text(); let payload: Record<string, unknown> = {}; try { const json = JSON.parse(text); if (json && typeof json === "object") payload = json; } catch { /* gateway may return HTML */ }
const payUrl = safeUpstreamUrl(payload.payurl ?? payload.pay_url ?? payload.url ?? (text.trim().startsWith("http") ? text.trim() : null), config.gatewayUrl);
@@ -6,7 +6,7 @@ import { readEpayConfig } from "@/lib/epay/config";
export const runtime = "nodejs";
async function notify(request: Request) {
try {
const config = readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record<string, string> = {}; params.forEach((value, key) => { values[key] = value; });
const config = await readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record<string, string> = {}; params.forEach((value, key) => { values[key] = value; });
if (!timingSafeSignEqual(values.sign, epaySign(values, config.key)) || values.pid !== config.pid || values.trade_status !== "TRADE_SUCCESS" || !values.out_trade_no || !values.money) return new NextResponse("success", { status: 200 });
const moneyCents = Math.round(Number(values.money) * 100); if (!Number.isSafeInteger(moneyCents) || moneyCents <= 0) return new NextResponse("success", { status: 200 });
const hash = crypto.createHash("sha256").update(raw).digest("hex");
+25 -1
View File
@@ -1,4 +1,28 @@
import { NextResponse } from "next/server";
import { readEpayAvailability } from "@/lib/epay/availability";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
export async function GET() { const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("id,name,description,price_cents,credits,sort_order").eq("enabled", true).order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取充值套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map((p) => ({ id: p.id, name: p.name, description: p.description, priceCents: p.price_cents, credits: p.credits })) }); }
export async function GET() {
const availability = await readEpayAvailability();
if (!availability.enabled) return NextResponse.json({ enabled: false, packages: [] });
const { data, error } = await createAdminSupabaseClient()
.from("payment_packages")
.select("id,name,description,price_cents,credits,sort_order")
.eq("enabled", true)
.order("sort_order")
.order("created_at");
if (error) return NextResponse.json({ enabled: false, packages: [] });
return NextResponse.json({
enabled: true,
packages: (data || []).map((item) => ({
id: item.id,
name: item.name,
description: item.description,
priceCents: item.price_cents,
credits: item.credits,
})),
});
}
+11 -3
View File
@@ -925,6 +925,7 @@ export default function Home() {
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [paymentPackages, setPaymentPackages] = useState<Array<{ id: string; name: string; description: string; priceCents: number; credits: number }>>([]);
const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null);
const [paymentError, setPaymentError] = useState("");
@@ -1675,6 +1676,10 @@ export default function Home() {
case "redeem":
setRedeemError("");
setRedeemMessage("");
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
break;
case "logout":
break;
@@ -1951,7 +1956,10 @@ export default function Home() {
if (activeAccountDialog !== "redeem") return;
void fetch("/api/payment/packages", { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (response.ok) setPaymentPackages(payload.packages || []);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
}
});
}, [activeAccountDialog]);
@@ -3269,12 +3277,12 @@ export default function Home() {
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
</form>
<div className="payment-packages">
{paymentEnabled && <div className="payment-packages">
<h3></h3>
{paymentPackages.map((item) => <div className="payment-package" key={item.id}><div><strong>{item.name}</strong><span>{item.description || `${item.credits}`}</span></div><b>¥{(item.priceCents / 100).toFixed(2)}</b><button className="button-secondary" type="button" onClick={() => void createPayment(item.id)} disabled={Boolean(payingPackageId)}>{payingPackageId === item.id ? "创建中" : "支付宝支付"}</button></div>)}
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
{paymentOrder && <div className="payment-status" role="status"><p> {paymentOrder.orderNo}{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}</p>{paymentOrder.qrCode && <div className="payment-qr-wrap"><img src={paymentOrder.qrCode} alt="支付宝支付二维码" /><span className="payment-qr-badge" aria-label="支付宝"><svg viewBox="0 0 48 48" role="img" aria-hidden="true"><rect width="48" height="48" rx="10" fill="#1677ff" /><path d="M13 16.5h15.8c2.3 0 4.2 1.9 4.2 4.2v6.1c0 2.3-1.9 4.2-4.2 4.2H22l-5.9 4.4v-4.4h-1.1c-2.3 0-4.2-1.9-4.2-4.2v-6.1c0-2.3 1.9-4.2 4.2-4.2Z" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinejoin="round" /><path d="M18.5 22.2h8.8M18.5 26h5.4" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" /></svg></span></div>}{paymentOrder.payUrl && <a href={paymentOrder.payUrl} target="_blank" rel="noreferrer"></a>}</div>}
</div>
</div>}
</>
)}
@@ -62,6 +62,18 @@ type PaymentPackage = {
type PackageFormValues = Omit<PaymentPackage, "id" | "priceCents"> & { priceYuan: number };
type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] };
type EpaySettings = {
gatewayUrl: string;
pid: string;
notifyUrl: string;
returnUrl: string;
siteName: string;
chatEnabled: boolean;
keyConfigured: boolean;
complete: boolean;
source: "database" | "environment" | "unconfigured";
};
type EpaySettingsForm = Pick<EpaySettings, "gatewayUrl" | "pid" | "notifyUrl" | "returnUrl" | "siteName" | "chatEnabled"> & { newKey?: string };
const initialStats: PaymentStats = {
totalOrders: 0,
@@ -94,6 +106,7 @@ export default function PaymentManagement() {
const { message } = App.useApp();
const [filterForm] = Form.useForm<PaymentFilters>();
const [packageForm] = Form.useForm<PackageFormValues>();
const [epayForm] = Form.useForm<EpaySettingsForm>();
const [orders, setOrders] = useState<Order[]>([]);
const [stats, setStats] = useState(initialStats);
const [paymentLoading, setPaymentLoading] = useState(true);
@@ -108,6 +121,11 @@ export default function PaymentManagement() {
const [editingPackage, setEditingPackage] = useState<PaymentPackage | null>(null);
const [saving, setSaving] = useState(false);
const [disablingId, setDisablingId] = useState<string | null>(null);
const [epaySettings, setEpaySettings] = useState<EpaySettings | null>(null);
const [epayLoading, setEpayLoading] = useState(true);
const [epaySaving, setEpaySaving] = useState(false);
const [epayTesting, setEpayTesting] = useState(false);
const [epayError, setEpayError] = useState("");
const loadPayments = useCallback(async () => {
setPaymentLoading(true);
@@ -141,6 +159,28 @@ export default function PaymentManagement() {
}
}, []);
const loadEpaySettings = useCallback(async () => {
setEpayLoading(true);
setEpayError("");
try {
const payload: EpaySettings = await responsePayload(await fetch("/api/admin/epay-settings", { cache: "no-store" }));
setEpaySettings(payload);
epayForm.setFieldsValue({
gatewayUrl: payload.gatewayUrl,
pid: payload.pid,
notifyUrl: payload.notifyUrl,
returnUrl: payload.returnUrl,
siteName: payload.siteName,
chatEnabled: payload.chatEnabled,
newKey: "",
});
} catch (error) {
setEpayError(error instanceof Error ? error.message : "读取易支付配置失败");
} finally {
setEpayLoading(false);
}
}, [epayForm]);
useEffect(() => {
const timer = window.setTimeout(() => void loadPayments(), 0);
return () => window.clearTimeout(timer);
@@ -149,6 +189,41 @@ export default function PaymentManagement() {
const timer = window.setTimeout(() => void loadPackages(), 0);
return () => window.clearTimeout(timer);
}, [loadPackages]);
useEffect(() => {
const timer = window.setTimeout(() => void loadEpaySettings(), 0);
return () => window.clearTimeout(timer);
}, [loadEpaySettings]);
async function testEpayAvailability() {
setEpayTesting(true);
try {
const payload = await responsePayload(await fetch("/api/admin/epay-settings/test", { method: "POST" }));
if (payload.available) message.success(`${payload.message}${payload.status}${payload.latencyMs}ms`);
else message.error(payload.message || "当前已保存的易支付配置暂不可用");
} catch (error) {
message.error(error instanceof Error ? error.message : "当前已保存的易支付配置暂不可用");
} finally {
setEpayTesting(false);
}
}
async function saveEpaySettings(values: EpaySettingsForm) {
setEpaySaving(true);
try {
await responsePayload(await fetch("/api/admin/epay-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
}));
epayForm.setFieldValue("newKey", "");
message.success("易支付配置已保存");
await loadEpaySettings();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
} finally {
setEpaySaving(false);
}
}
function openCreateModal() {
setEditingPackage(null);
@@ -255,6 +330,31 @@ export default function PaymentManagement() {
</Row>
</Card>
<Card
title="易支付系统配置"
loading={epayLoading}
extra={<Space><Button disabled={!epaySettings?.keyConfigured || !epaySettings.complete} loading={epayTesting} onClick={() => void testEpayAvailability()}></Button><Button type="primary" loading={epaySaving} onClick={() => epayForm.submit()}></Button></Space>}
>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{epaySettings && <Space wrap><Tag color={epaySettings.source === "database" ? "blue" : "default"}>{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}</Tag><Tag color={epaySettings.complete ? "green" : "orange"}>{epaySettings.complete ? "配置完整" : "配置不完整"}</Tag><Tag color={epaySettings.keyConfigured ? "green" : "orange"}>{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}</Tag><Tag color={epaySettings.chatEnabled ? "green" : "default"}>{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Tag></Space>}
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}></Button>} />}
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
</Row>
<Form.Item name="newKey" label="商户密钥" extra="留空保持当前数据库密钥;系统绝不预填或回显密钥。"><Input.Password autoComplete="new-password" placeholder="留空保持原密钥" /></Form.Item>
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="notifyUrl" label="异步通知地址" rules={[{ required: true, message: "请输入异步通知地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="returnUrl" label="支付完成返回地址" rules={[{ required: true, message: "请输入支付完成返回地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
</Row>
<Form.Item name="siteName" label="网站名称" rules={[{ required: true, message: "请输入网站名称" }, { max: 100 }]}><Input /></Form.Item>
<Form.Item name="chatEnabled" label="在对话页开放支付" valuePropName="checked"><Switch checkedChildren="开放" unCheckedChildren="关闭" /></Form.Item>
</Form>
</Space>
</Card>
<Card title="支付记录" extra={<Text type="secondary"> {total} </Text>}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form form={filterForm} layout="inline" onValuesChange={(_, values) => { setOffset(0); setFilters(values); }}>
+43
View File
@@ -0,0 +1,43 @@
import "server-only";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
function strictEnvironmentChatEnabled() {
const value = process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase();
return value === "true" || value === "1";
}
function environmentConfigComplete() {
return Boolean(
process.env.EPAY_GATEWAY_URL?.trim()
&& process.env.EPAY_PID?.trim()
&& process.env.EPAY_KEY?.trim(),
);
}
export async function readEpayAvailability() {
try {
const { data, error } = await createAdminSupabaseClient()
.from("epay_settings")
.select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled")
.eq("id", true)
.maybeSingle();
if (error?.code === "42P01") {
return { enabled: strictEnvironmentChatEnabled() && environmentConfigComplete() };
}
if (error || !data) return { enabled: false };
return {
enabled: Boolean(
data.chat_enabled
&& data.gateway_url
&& data.pid
&& data.encrypted_key
&& data.notify_url
&& data.return_url
&& data.site_name,
),
};
} catch {
return { enabled: false };
}
}
+106
View File
@@ -0,0 +1,106 @@
import { decryptEpayKey } from "./encryption-core";
export type EpaySettingsRow = {
gateway_url: string;
pid: string;
encrypted_key: string;
notify_url: string;
return_url: string;
site_name: string;
chat_enabled: boolean;
};
export class EpayConfigurationError extends Error {
constructor(message = "易支付配置不可用") {
super(message);
this.name = "EpayConfigurationError";
}
}
function validHttpUrl(value: string, label: string) {
try {
const url = new URL(value);
if (!/^https?:$/.test(url.protocol)) throw new Error();
return url;
} catch {
throw new EpayConfigurationError(`${label} 无效`);
}
}
export function suggestedEpayUrls(siteAddress = process.env.SITE_ADDRESS) {
let base: URL;
try {
base = new URL(siteAddress?.trim() || "http://localhost:3000");
if (!/^https?:$/.test(base.protocol)) throw new Error();
} catch {
base = new URL("http://localhost:3000");
}
return {
notifyUrl: new URL("/api/payment/epay/notify", base).toString(),
returnUrl: new URL("/", base).toString(),
};
}
function environmentChatEnabled(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "true" || normalized === "1";
}
function completeConfig(values: {
gateway: string;
pid: string;
key: string;
notifyUrl: string;
returnUrl: string;
siteName: string;
chatEnabled: boolean;
}) {
if (!values.gateway || !values.pid || !values.key || !values.notifyUrl || !values.returnUrl || !values.siteName) {
throw new EpayConfigurationError();
}
const gatewayUrl = validHttpUrl(values.gateway.replace(/\/+$/, ""), "易支付网关地址");
validHttpUrl(values.notifyUrl, "异步通知地址");
validHttpUrl(values.returnUrl, "支付返回地址");
return { gatewayUrl, pid: values.pid, key: values.key, notifyUrl: values.notifyUrl, returnUrl: values.returnUrl, siteName: values.siteName, chatEnabled: values.chatEnabled };
}
export async function resolveEpayConfig(
loadDatabaseRow: () => Promise<EpaySettingsRow | null>,
env: NodeJS.ProcessEnv = process.env,
) {
let row: EpaySettingsRow | null;
try {
row = await loadDatabaseRow();
} catch {
throw new EpayConfigurationError();
}
if (row) {
return completeConfig({
gateway: row.gateway_url.trim(),
pid: row.pid.trim(),
key: decryptEpayKey(row.encrypted_key, env.EPAY_CONFIG_ENCRYPTION_KEY),
notifyUrl: row.notify_url.trim(),
returnUrl: row.return_url.trim(),
siteName: row.site_name.trim(),
chatEnabled: row.chat_enabled,
});
}
const defaults = suggestedEpayUrls(env.SITE_ADDRESS);
return completeConfig({
gateway: env.EPAY_GATEWAY_URL?.trim() || "",
pid: env.EPAY_PID?.trim() || "",
key: env.EPAY_KEY?.trim() || "",
notifyUrl: env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl,
returnUrl: env.EPAY_RETURN_URL?.trim() || defaults.returnUrl,
siteName: env.EPAY_SITE_NAME?.trim() || "Jyotisha",
chatEnabled: environmentChatEnabled(env.EPAY_CHAT_ENABLED),
});
}
export function epaySubmitUrl(gatewayUrl: URL) {
const url = new URL(gatewayUrl.toString());
url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`;
url.search = "";
return url;
}
+22 -40
View File
@@ -1,44 +1,26 @@
import "server-only";
const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import {
epaySubmitUrl,
EpayConfigurationError,
resolveEpayConfig,
suggestedEpayUrls,
type EpaySettingsRow,
} from "./config-core";
export class EpayConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = "EpayConfigurationError";
}
export { epaySubmitUrl, EpayConfigurationError, resolveEpayConfig, suggestedEpayUrls };
export type { EpaySettingsRow };
export async function readEpayConfig() {
return resolveEpayConfig(async () => {
const { data, error } = await createAdminSupabaseClient()
.from("epay_settings")
.select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled")
.eq("id", true)
.maybeSingle();
if (error?.code === "42P01") return null;
if (error) throw new Error();
return data as EpaySettingsRow | null;
});
}
function required(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new EpayConfigurationError(`${name} 未配置`);
return value;
}
export function readEpayConfig() {
const gateway = required("EPAY_GATEWAY_URL").replace(/\/+$/, "");
let gatewayUrl: URL;
try {
gatewayUrl = new URL(gateway);
} catch {
throw new EpayConfigurationError("EPAY_GATEWAY_URL 无效");
}
if (!/^https?:$/.test(gatewayUrl.protocol)) throw new EpayConfigurationError("EPAY_GATEWAY_URL 必须使用 HTTP(S)");
return {
gatewayUrl,
pid: required("EPAY_PID"),
key: required("EPAY_KEY"),
notifyUrl: process.env.EPAY_NOTIFY_URL?.trim() || DEFAULT_NOTIFY_URL,
returnUrl: process.env.EPAY_RETURN_URL?.trim() || "https://jyotisha.chat/",
siteName: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha",
};
}
export function epaySubmitUrl(gatewayUrl: URL) {
const url = new URL(gatewayUrl.toString());
url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`;
url.search = "";
return url;
}
export { DEFAULT_NOTIFY_URL };
+46
View File
@@ -0,0 +1,46 @@
import crypto from "node:crypto";
const VERSION = "v1";
export class EpayEncryptionError extends Error {
constructor() {
super("易支付配置不可用");
this.name = "EpayEncryptionError";
}
}
function encryptionKey(value = process.env.EPAY_CONFIG_ENCRYPTION_KEY) {
if (!value?.trim()) throw new EpayEncryptionError();
try {
const key = Buffer.from(value.trim(), "base64");
if (key.length !== 32 || key.toString("base64") !== value.trim()) throw new Error();
return key;
} catch {
throw new EpayEncryptionError();
}
}
export function encryptEpayKey(plaintext: string, masterKey?: string) {
if (!plaintext) throw new EpayEncryptionError();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(".");
}
export function decryptEpayKey(payload: string, masterKey?: string) {
try {
const [version, ivValue, tagValue, ciphertextValue, extra] = payload.split(".");
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error();
const decipher = crypto.createDecipheriv("aes-256-gcm", encryptionKey(masterKey), Buffer.from(ivValue, "base64url"));
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextValue, "base64url")),
decipher.final(),
]).toString("utf8");
if (!plaintext) throw new Error();
return plaintext;
} catch {
throw new EpayEncryptionError();
}
}
+3
View File
@@ -0,0 +1,3 @@
import "server-only";
export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core";
+64
View File
@@ -0,0 +1,64 @@
import { promises as dns } from "node:dns";
import { isIP } from "node:net";
const blockedHostnames = new Set([
"localhost",
"localhost.localdomain",
"metadata.google.internal",
]);
function blockedIpv4(address: string) {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
const [a, b] = parts;
return a === 0
|| a === 10
|| a === 127
|| (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 0)
|| (a === 192 && b === 168)
|| (a === 198 && (b === 18 || b === 19))
|| a >= 224;
}
export function isPublicEpayAddress(address: string) {
const version = isIP(address);
if (version === 4) return !blockedIpv4(address);
if (version !== 6) return false;
const normalized = address.toLowerCase().split("%")[0];
if (normalized.startsWith("::ffff:")) return isPublicEpayAddress(normalized.slice(7));
return normalized !== "::"
&& normalized !== "::1"
&& !normalized.startsWith("fc")
&& !normalized.startsWith("fd")
&& !/^fe[89ab]/.test(normalized)
&& !normalized.startsWith("2001:db8:");
}
export function assertPublicEpayGateway(value: URL | string) {
const url = value instanceof URL ? value : new URL(value);
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
if (!/^https?:$/.test(url.protocol)
|| url.username
|| url.password
|| blockedHostnames.has(hostname)
|| hostname.endsWith(".localhost")
|| hostname.endsWith(".local")
|| (isIP(hostname) && !isPublicEpayAddress(hostname))) {
throw new Error("易支付网关地址不允许指向本机或内网");
}
return url;
}
export async function assertPublicGatewayUrl(value: URL | string) {
const url = assertPublicEpayGateway(value);
if (!isIP(url.hostname)) {
const addresses = await dns.lookup(url.hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some(({ address }) => !isPublicEpayAddress(address))) {
throw new Error("易支付网关地址不允许解析到本机或内网");
}
}
return url;
}