Merge GitHub upstream into Gitea primary
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { IdentityUser } from "@/modules/identity/contracts";
|
||||
|
||||
export type AdminRole = "admin" | "viewer";
|
||||
export type AdminRole = "admin";
|
||||
|
||||
export type AdminAccessResult =
|
||||
| { allowed: true; role: AdminRole }
|
||||
@@ -11,13 +11,8 @@ export function authorizeAdminAccess(
|
||||
access: "read" | "write",
|
||||
): AdminAccessResult {
|
||||
if (!user) return { allowed: false, status: 401 };
|
||||
const role: AdminRole | null = user.role.includes("admin")
|
||||
? "admin"
|
||||
: user.role.includes("viewer")
|
||||
? "viewer"
|
||||
: null;
|
||||
if (!role || (access === "write" && role !== "admin")) {
|
||||
if (!user.role.includes("admin")) {
|
||||
return { allowed: false, status: 403 };
|
||||
}
|
||||
return { allowed: true, role };
|
||||
return { allowed: true, role: "admin" };
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function requireAdminSession(
|
||||
|
||||
try {
|
||||
const user = await requireIdentityUser(
|
||||
getIdentityAuthServices().admin.api,
|
||||
getIdentityAuthServices().user.api,
|
||||
new Headers(await headers()),
|
||||
);
|
||||
const authorization = authorizeAdminAccess(user, access);
|
||||
|
||||
@@ -5,35 +5,40 @@ import { Pool, type QueryResultRow } from "pg";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
|
||||
const poolGlobal = globalThis as typeof globalThis & {
|
||||
jyotishaAdminReadPool?: Pool;
|
||||
jyotishaAdminDatabasePool?: Pool;
|
||||
};
|
||||
|
||||
export function adminReadPool(): Pool {
|
||||
export function adminDatabasePool(): Pool {
|
||||
if (
|
||||
process.env.AUTH_PROVIDER?.trim() !== "self-hosted"
|
||||
|| process.env.APP_ENV?.trim() === "production"
|
||||
) {
|
||||
throw new Error("admin reads require the staging self-hosted identity service");
|
||||
throw new Error("admin database requests require the staging self-hosted identity service");
|
||||
}
|
||||
poolGlobal.jyotishaAdminReadPool ??= new Pool({
|
||||
poolGlobal.jyotishaAdminDatabasePool ??= new Pool({
|
||||
connectionString: readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
allowExitOnIdle: true,
|
||||
application_name: "jyotisha-admin-read",
|
||||
application_name: "jyotisha-admin-database",
|
||||
});
|
||||
return poolGlobal.jyotishaAdminReadPool;
|
||||
return poolGlobal.jyotishaAdminDatabasePool;
|
||||
}
|
||||
|
||||
export async function queryAdminRows<T extends QueryResultRow>(
|
||||
sql: string,
|
||||
values: readonly unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const result = await adminReadPool().query<T>(sql, [...values]);
|
||||
const result = await adminDatabasePool().query<T>(sql, [...values]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export function isPostgresError(error: unknown): error is { code: string } {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
&& typeof (error as { code?: unknown }).code === "string";
|
||||
}
|
||||
|
||||
export type PageResult<T> = { data: T[]; total: number };
|
||||
|
||||
export function pageOffset(page: number, pageSize: number) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export type AdminIdentity = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "viewer";
|
||||
role: "admin";
|
||||
};
|
||||
|
||||
const apiBase = "/api/admin";
|
||||
@@ -165,7 +165,7 @@ export const adminAccessControlProvider: AccessControlProvider = {
|
||||
}
|
||||
return role === "admin"
|
||||
? { can: true }
|
||||
: { can: false, reason: "viewer 仅可查看" };
|
||||
: { can: false, reason: "无管理员权限" };
|
||||
},
|
||||
options: {
|
||||
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import "server-only";
|
||||
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import {
|
||||
epaySubmitUrl,
|
||||
EpayConfigurationError,
|
||||
resolveEpayConfig,
|
||||
suggestedEpayUrls,
|
||||
type EpaySettingsRow,
|
||||
} from "./config-core";
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import crypto from "node:crypto";
|
||||
import { epaySign, timingSafeSignEqual } from "./sign";
|
||||
|
||||
type EpayNotifyConfig = { key: string; pid: string };
|
||||
type SettlementResponse = { data: unknown; error: unknown };
|
||||
|
||||
type EpayNotifyDependencies = {
|
||||
readConfig: () => Promise<EpayNotifyConfig>;
|
||||
settle: (args: {
|
||||
p_order_no: string;
|
||||
p_trade_no: string;
|
||||
p_money_cents: number;
|
||||
p_payload_hash: string;
|
||||
}) => Promise<SettlementResponse>;
|
||||
};
|
||||
|
||||
function settlementSucceeded(data: unknown) {
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
return Boolean(row && typeof row === "object" && "success" in row && row.success === true);
|
||||
}
|
||||
|
||||
export function createEpayNotifyHandler(dependencies: EpayNotifyDependencies) {
|
||||
return async function notify(request: Request) {
|
||||
try {
|
||||
const config = await dependencies.readConfig();
|
||||
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 Response("fail", { status: 400 });
|
||||
}
|
||||
|
||||
const moneyCents = Math.round(Number(values.money) * 100);
|
||||
if (!Number.isSafeInteger(moneyCents) || moneyCents <= 0) return new Response("fail", { status: 400 });
|
||||
|
||||
const result = await dependencies.settle({
|
||||
p_order_no: values.out_trade_no,
|
||||
p_trade_no: values.trade_no || values.transaction_id || values.out_trade_no,
|
||||
p_money_cents: moneyCents,
|
||||
p_payload_hash: crypto.createHash("sha256").update(raw).digest("hex"),
|
||||
});
|
||||
if (result.error || !settlementSucceeded(result.data)) return new Response("fail", { status: 500 });
|
||||
return new Response("success", { status: 200 });
|
||||
} catch {
|
||||
return new Response("fail", { status: 500 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function epayCanonical(params: Record<string, string | number | null | undefined>) {
|
||||
return Object.entries(params)
|
||||
.filter(([key, value]) => key !== "sign" && key !== "sign_type" && value !== null && value !== undefined && String(value) !== "")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export function epaySign(params: Record<string, string | number | null | undefined>, key: string) {
|
||||
return crypto.createHash("md5").update(`${epayCanonical(params)}${key}`).digest("hex");
|
||||
}
|
||||
|
||||
export function timingSafeSignEqual(actual: string | null | undefined, expected: string) {
|
||||
if (!actual || actual.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
|
||||
}
|
||||
@@ -1,6 +1,42 @@
|
||||
import "server-only";
|
||||
|
||||
export {
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import {
|
||||
createAdminSupabaseClient,
|
||||
isAdminEmail,
|
||||
} from "./admin-client-core";
|
||||
|
||||
export { createAdminSupabaseClient, isAdminEmail };
|
||||
|
||||
export async function isAdminUser(user: { id?: string; email?: string | null }) {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
if (!user.id) return false;
|
||||
try {
|
||||
const rows = await queryAdminRows<{ role: string }>(
|
||||
"select role from identity.users where id = $1 limit 1",
|
||||
[user.id],
|
||||
);
|
||||
return rows[0]?.role
|
||||
.split(",")
|
||||
.map((role) => role.trim())
|
||||
.some((role) => role === "admin") ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isAdminEmail(user.email)) return true;
|
||||
if (!user.id) return false;
|
||||
|
||||
try {
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data, error } = await admin
|
||||
.from("admin_users")
|
||||
.select("user_id")
|
||||
.eq("user_id", user.id)
|
||||
.is("revoked_at", null)
|
||||
.maybeSingle();
|
||||
return !error && Boolean(data);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,15 @@ import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
import { getIdentityAuthServices } from "@/modules/identity/auth";
|
||||
import { readIdentitySession } from "@/modules/identity/session";
|
||||
import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
|
||||
import { resolveIdentitySurface } from "@/modules/identity/host";
|
||||
import { getSupabasePublicConfig } from "./config";
|
||||
|
||||
export async function createServerSupabaseClient() {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const services = getIdentityAuthServices();
|
||||
const surface = resolveIdentitySurface(
|
||||
requestHeaders.get("host"),
|
||||
readSelfHostedIdentityConfig(process.env),
|
||||
const session = await readIdentitySession(
|
||||
getIdentityAuthServices().user.api,
|
||||
requestHeaders,
|
||||
);
|
||||
const auth = surface === "admin" ? services.admin : services.user;
|
||||
const session = await readIdentitySession(auth.api, requestHeaders);
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
|
||||
session ? { id: session.user.id, email: session.user.email } : null,
|
||||
|
||||
Reference in New Issue
Block a user