Merge GitHub upstream into Gitea primary

This commit is contained in:
Jesse_Chen
2026-08-05 11:13:34 +08:00
105 changed files with 3612 additions and 886 deletions
+3 -8
View File
@@ -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" };
}
+1 -1
View File
@@ -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);
+12 -7
View File
@@ -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) {
+2 -2
View File
@@ -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 },
+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;
}
+26
View File
@@ -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;
});
}
+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;
}
+54
View File
@@ -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 });
}
};
}
+18
View File
@@ -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));
}
+37 -1
View File
@@ -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;
}
}
+3 -8
View File
@@ -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,