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
+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;
}