mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
fix: harden secrets and session checks
This commit is contained in:
@@ -6,16 +6,23 @@ import { requireAdmin } from "@/lib/require-auth";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
import { encrypt } from "@/lib/crypto";
|
import { encrypt, isEncryptedValue } from "@/lib/crypto";
|
||||||
import { testAndSyncNodeInbounds } from "@/services/node-panel/sync-inbounds";
|
import { testAndSyncNodeInbounds } from "@/services/node-panel/sync-inbounds";
|
||||||
|
|
||||||
const nodeSchema = z.object({
|
const nodeBaseSchema = z.object({
|
||||||
name: z.string().trim().optional(),
|
name: z.string().trim().optional(),
|
||||||
panelUrl: z.string().trim().min(1, "3x-ui 面板地址必填"),
|
panelUrl: z.string().trim().min(1, "3x-ui 面板地址必填"),
|
||||||
panelUsername: z.string().trim().min(1, "3x-ui 用户名必填"),
|
panelUsername: z.string().trim().min(1, "3x-ui 用户名必填"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createNodeSchema = nodeBaseSchema.extend({
|
||||||
panelPassword: z.string().trim().min(1, "3x-ui 密码必填"),
|
panelPassword: z.string().trim().min(1, "3x-ui 密码必填"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateNodeSchema = nodeBaseSchema.extend({
|
||||||
|
panelPassword: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function normalizePanelUrl(raw: string): string {
|
function normalizePanelUrl(raw: string): string {
|
||||||
try {
|
try {
|
||||||
let value = raw.trim();
|
let value = raw.trim();
|
||||||
@@ -34,24 +41,26 @@ function normalizePanelUrl(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNodeData(formData: FormData) {
|
function parseNodeData(formData: FormData, mode: "create" | "update") {
|
||||||
const raw = nodeSchema.parse(Object.fromEntries(formData));
|
const raw = (mode === "create" ? createNodeSchema : updateNodeSchema)
|
||||||
|
.parse(Object.fromEntries(formData));
|
||||||
const panelUrl = normalizePanelUrl(raw.panelUrl);
|
const panelUrl = normalizePanelUrl(raw.panelUrl);
|
||||||
const panel = new URL(panelUrl);
|
const panel = new URL(panelUrl);
|
||||||
|
const panelPassword = raw.panelPassword?.trim();
|
||||||
|
|
||||||
const name = (raw.name || "").trim() || `节点-${panel.hostname}`;
|
const name = (raw.name || "").trim() || `节点-${panel.hostname}`;
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
panelUrl,
|
panelUrl,
|
||||||
panelUsername: raw.panelUsername,
|
panelUsername: raw.panelUsername,
|
||||||
panelPassword: raw.panelPassword,
|
...(panelPassword ? { panelPassword: encrypt(panelPassword) } : {}),
|
||||||
panelType: "3x-ui",
|
panelType: "3x-ui",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNode(formData: FormData) {
|
export async function createNode(formData: FormData) {
|
||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
const data = parseNodeData(formData);
|
const data = parseNodeData(formData, "create");
|
||||||
const node = await prisma.nodeServer.create({ data });
|
const node = await prisma.nodeServer.create({ data });
|
||||||
const result = await testAndSyncNodeInbounds(node);
|
const result = await testAndSyncNodeInbounds(node);
|
||||||
|
|
||||||
@@ -72,7 +81,18 @@ export async function createNode(formData: FormData) {
|
|||||||
|
|
||||||
export async function updateNode(id: string, formData: FormData) {
|
export async function updateNode(id: string, formData: FormData) {
|
||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
const data = parseNodeData(formData);
|
const data = parseNodeData(formData, "update");
|
||||||
|
|
||||||
|
if (!data.panelPassword) {
|
||||||
|
const existing = await prisma.nodeServer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { panelPassword: true },
|
||||||
|
});
|
||||||
|
if (existing?.panelPassword && !isEncryptedValue(existing.panelPassword)) {
|
||||||
|
data.panelPassword = encrypt(existing.panelPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const node = await prisma.nodeServer.update({ where: { id }, data });
|
const node = await prisma.nodeServer.update({ where: { id }, data });
|
||||||
const result = await testAndSyncNodeInbounds(node);
|
const result = await testAndSyncNodeInbounds(node);
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { requireAdmin } from "@/lib/require-auth";
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
|
decryptPaymentConfigForUse,
|
||||||
normalizePaymentConfig,
|
normalizePaymentConfig,
|
||||||
parsePaymentConfig,
|
parsePaymentConfig,
|
||||||
|
preparePaymentConfigForStorage,
|
||||||
} from "@/services/payment/catalog";
|
} from "@/services/payment/catalog";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -18,11 +20,19 @@ export async function savePaymentConfig(
|
|||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
|
|
||||||
const normalizedConfig = normalizePaymentConfig(config);
|
const normalizedConfig = normalizePaymentConfig(config);
|
||||||
let finalConfig = normalizedConfig as Record<string, string | number>;
|
const current = await prisma.paymentConfig.findUnique({
|
||||||
|
where: { provider },
|
||||||
|
select: { config: true },
|
||||||
|
});
|
||||||
|
const storageConfig = preparePaymentConfigForStorage(
|
||||||
|
provider,
|
||||||
|
normalizedConfig,
|
||||||
|
current?.config as Record<string, unknown> | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
try {
|
try {
|
||||||
finalConfig = parsePaymentConfig(provider, normalizedConfig) as Record<string, string | number>;
|
parsePaymentConfig(provider, decryptPaymentConfigForUse(provider, storageConfig));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
const messages = error.issues.map((e) => e.message).join(";");
|
const messages = error.issues.map((e) => e.message).join(";");
|
||||||
@@ -32,7 +42,7 @@ export async function savePaymentConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonConfig = JSON.parse(JSON.stringify(finalConfig));
|
const jsonConfig = JSON.parse(JSON.stringify(storageConfig));
|
||||||
|
|
||||||
await prisma.paymentConfig.upsert({
|
await prisma.paymentConfig.upsert({
|
||||||
where: { provider },
|
where: { provider },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { requireAdmin } from "@/lib/require-auth";
|
|||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
import { normalizeSiteUrl } from "@/services/site-url";
|
import { normalizeSiteUrl } from "@/services/site-url";
|
||||||
import { encrypt } from "@/lib/crypto";
|
import { encrypt, isEncryptedValue } from "@/lib/crypto";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
import { sendSmtpTestEmail } from "@/services/email";
|
import { sendSmtpTestEmail } from "@/services/email";
|
||||||
|
|
||||||
@@ -100,6 +100,17 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
const smtpPassword = parsed.smtpPassword?.trim()
|
const smtpPassword = parsed.smtpPassword?.trim()
|
||||||
? encrypt(parsed.smtpPassword.trim())
|
? encrypt(parsed.smtpPassword.trim())
|
||||||
: current.smtpPassword;
|
: current.smtpPassword;
|
||||||
|
const turnstileSiteKey = parsed.turnstileSiteKey || null;
|
||||||
|
const currentTurnstileSecret = current.turnstileSecretKey
|
||||||
|
? isEncryptedValue(current.turnstileSecretKey)
|
||||||
|
? current.turnstileSecretKey
|
||||||
|
: encrypt(current.turnstileSecretKey)
|
||||||
|
: null;
|
||||||
|
const turnstileSecretKey = parsed.turnstileSecretKey?.trim()
|
||||||
|
? encrypt(parsed.turnstileSecretKey.trim())
|
||||||
|
: turnstileSiteKey
|
||||||
|
? currentTurnstileSecret
|
||||||
|
: null;
|
||||||
|
|
||||||
const next = {
|
const next = {
|
||||||
siteName: parsed.siteName,
|
siteName: parsed.siteName,
|
||||||
@@ -150,8 +161,8 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
|
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
|
||||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||||
turnstileSiteKey: parsed.turnstileSiteKey || null,
|
turnstileSiteKey,
|
||||||
turnstileSecretKey: parsed.turnstileSecretKey || null,
|
turnstileSecretKey,
|
||||||
smtpEnabled,
|
smtpEnabled,
|
||||||
smtpHost: parsed.smtpHost || null,
|
smtpHost: parsed.smtpHost || null,
|
||||||
smtpPort: parsed.smtpPort ?? current.smtpPort,
|
smtpPort: parsed.smtpPort ?? current.smtpPort,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { sanitizeInboundSettings, sanitizeStreamSettings } from "@/services/node-inbound-sanitize";
|
||||||
|
|
||||||
const nodeDetailInclude = {
|
const nodeDetailSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
panelUrl: true,
|
||||||
|
status: true,
|
||||||
inbounds: {
|
inbounds: {
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
@@ -12,17 +17,25 @@ const nodeDetailInclude = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Prisma.NodeServerInclude;
|
} satisfies Prisma.NodeServerSelect;
|
||||||
|
|
||||||
export type NodeDetail = Prisma.NodeServerGetPayload<{
|
export type NodeDetail = Prisma.NodeServerGetPayload<{
|
||||||
include: typeof nodeDetailInclude;
|
select: typeof nodeDetailSelect;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function getNodeDetail(id: string): Promise<NodeDetail> {
|
export async function getNodeDetail(id: string): Promise<NodeDetail> {
|
||||||
const node = await prisma.nodeServer.findUnique({
|
const node = await prisma.nodeServer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: nodeDetailInclude,
|
select: nodeDetailSelect,
|
||||||
});
|
});
|
||||||
if (!node) notFound();
|
if (!node) notFound();
|
||||||
return node;
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
inbounds: node.inbounds.map((inbound) => ({
|
||||||
|
...inbound,
|
||||||
|
settings: sanitizeInboundSettings(inbound.settings),
|
||||||
|
streamSettings: sanitizeStreamSettings(inbound.streamSettings),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
|
|||||||
name: node.name,
|
name: node.name,
|
||||||
panelUrl: node.panelUrl,
|
panelUrl: node.panelUrl,
|
||||||
panelUsername: node.panelUsername,
|
panelUsername: node.panelUsername,
|
||||||
panelPassword: node.panelPassword,
|
|
||||||
}}
|
}}
|
||||||
triggerLabel="编辑"
|
triggerLabel="编辑"
|
||||||
triggerVariant="outline"
|
triggerVariant="outline"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface NodeFormValue {
|
|||||||
name: string;
|
name: string;
|
||||||
panelUrl: string | null;
|
panelUrl: string | null;
|
||||||
panelUsername: string | null;
|
panelUsername: string | null;
|
||||||
panelPassword: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeForm({
|
export function NodeForm({
|
||||||
@@ -92,7 +91,13 @@ export function NodeForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>面板密码</Label>
|
<Label>面板密码</Label>
|
||||||
<Input name="panelPassword" type="password" defaultValue={node?.panelPassword ?? ""} required />
|
<Input
|
||||||
|
name="panelPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder={isEdit ? "留空则沿用当前密码" : "请输入面板密码"}
|
||||||
|
required={!isEdit}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import type { Prisma } from "@prisma/client";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { parsePage } from "@/lib/utils";
|
import { parsePage } from "@/lib/utils";
|
||||||
import { getConfiguredSiteUrl } from "@/services/site-url";
|
import { getConfiguredSiteUrl } from "@/services/site-url";
|
||||||
|
import { sanitizeInboundSettings } from "@/services/node-inbound-sanitize";
|
||||||
|
|
||||||
const nodeInclude = {
|
const nodeSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
panelUrl: true,
|
||||||
|
panelUsername: true,
|
||||||
|
status: true,
|
||||||
|
agentToken: true,
|
||||||
_count: { select: { inbounds: true } },
|
_count: { select: { inbounds: true } },
|
||||||
inbounds: {
|
inbounds: {
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
@@ -16,10 +23,10 @@ const nodeInclude = {
|
|||||||
},
|
},
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
},
|
},
|
||||||
} satisfies Prisma.NodeServerInclude;
|
} satisfies Prisma.NodeServerSelect;
|
||||||
|
|
||||||
export type NodeServerRow = Prisma.NodeServerGetPayload<{
|
export type NodeServerRow = Prisma.NodeServerGetPayload<{
|
||||||
include: typeof nodeInclude;
|
select: typeof nodeSelect;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export async function getNodeServers(
|
export async function getNodeServers(
|
||||||
@@ -44,7 +51,7 @@ export async function getNodeServers(
|
|||||||
const [nodes, total, siteUrl] = await Promise.all([
|
const [nodes, total, siteUrl] = await Promise.all([
|
||||||
prisma.nodeServer.findMany({
|
prisma.nodeServer.findMany({
|
||||||
where,
|
where,
|
||||||
include: nodeInclude,
|
select: nodeSelect,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -53,5 +60,14 @@ export async function getNodeServers(
|
|||||||
getConfiguredSiteUrl(),
|
getConfiguredSiteUrl(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { nodes, total, page, pageSize, filters: { q, status }, siteUrl };
|
const safeNodes = nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
agentToken: node.agentToken ? "configured" : null,
|
||||||
|
inbounds: node.inbounds.map((inbound) => ({
|
||||||
|
...inbound,
|
||||||
|
settings: sanitizeInboundSettings(inbound.settings),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { nodes: safeNodes, total, page, pageSize, filters: { q, status }, siteUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,17 @@ interface Props {
|
|||||||
provider: string;
|
provider: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
currentConfig?: Record<string, string>;
|
currentConfig?: Record<string, string>;
|
||||||
|
secretConfigured?: Record<string, boolean>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentConfigForm({ provider, fields, currentConfig, enabled: initialEnabled }: Props) {
|
export function PaymentConfigForm({
|
||||||
|
provider,
|
||||||
|
fields,
|
||||||
|
currentConfig,
|
||||||
|
secretConfigured = {},
|
||||||
|
enabled: initialEnabled,
|
||||||
|
}: Props) {
|
||||||
const [enabled, setEnabled] = useState(initialEnabled);
|
const [enabled, setEnabled] = useState(initialEnabled);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -65,6 +72,13 @@ export function PaymentConfigForm({ provider, fields, currentConfig, enabled: in
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await savePaymentConfig(provider, config, enabled);
|
await savePaymentConfig(provider, config, enabled);
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!field.secret) continue;
|
||||||
|
const input = e.currentTarget.elements.namedItem(field.key);
|
||||||
|
if (input instanceof HTMLInputElement) {
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success("保存成功");
|
toast.success("保存成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, "保存失败"));
|
toast.error(getErrorMessage(error, "保存失败"));
|
||||||
@@ -99,8 +113,8 @@ export function PaymentConfigForm({ provider, fields, currentConfig, enabled: in
|
|||||||
<Input
|
<Input
|
||||||
name={field.key}
|
name={field.key}
|
||||||
type={field.secret ? "password" : "text"}
|
type={field.secret ? "password" : "text"}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.secret && secretConfigured[field.key] ? "留空保持不变" : field.placeholder}
|
||||||
defaultValue={currentConfig?.[field.key] || ""}
|
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function PaymentsPage() {
|
|||||||
title="支付配置"
|
title="支付配置"
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-5">
|
<div className="grid gap-5">
|
||||||
{providerConfigs.map(({ provider, config }) => (
|
{providerConfigs.map(({ provider, config, secretConfigured }) => (
|
||||||
<section key={provider.id} className="surface-card overflow-hidden rounded-xl p-4">
|
<section key={provider.id} className="surface-card overflow-hidden rounded-xl p-4">
|
||||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -37,7 +37,8 @@ export default async function PaymentsPage() {
|
|||||||
<PaymentConfigForm
|
<PaymentConfigForm
|
||||||
provider={provider.id}
|
provider={provider.id}
|
||||||
fields={provider.fields}
|
fields={provider.fields}
|
||||||
currentConfig={config?.config as Record<string, string> | undefined}
|
currentConfig={config?.config}
|
||||||
|
secretConfigured={secretConfigured}
|
||||||
enabled={config?.enabled ?? false}
|
enabled={config?.enabled ?? false}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { PAYMENT_PROVIDER_DEFINITIONS } from "@/services/payment/catalog";
|
import {
|
||||||
|
getPaymentSecretConfiguredState,
|
||||||
|
PAYMENT_PROVIDER_DEFINITIONS,
|
||||||
|
redactPaymentConfigForClient,
|
||||||
|
} from "@/services/payment/catalog";
|
||||||
|
|
||||||
export async function getPaymentProviderConfigs() {
|
export async function getPaymentProviderConfigs() {
|
||||||
const configs = await prisma.paymentConfig.findMany();
|
const configs = await prisma.paymentConfig.findMany();
|
||||||
const configMap = new Map(configs.map((config) => [config.provider, config]));
|
const configMap = new Map(configs.map((config) => [config.provider, config]));
|
||||||
|
|
||||||
return PAYMENT_PROVIDER_DEFINITIONS.map((provider) => ({
|
return PAYMENT_PROVIDER_DEFINITIONS.map((provider) => {
|
||||||
provider,
|
const config = configMap.get(provider.id);
|
||||||
config: configMap.get(provider.id),
|
const configValue = config?.config as Record<string, unknown> | undefined;
|
||||||
}));
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
config: config
|
||||||
|
? {
|
||||||
|
enabled: config.enabled,
|
||||||
|
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
secretConfigured: configValue
|
||||||
|
? getPaymentSecretConfiguredState(provider.id, configValue)
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default async function AdminSettingsPage() {
|
|||||||
inviteRewardRate: Number(config.inviteRewardRate),
|
inviteRewardRate: Number(config.inviteRewardRate),
|
||||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||||
turnstileSiteKey: config.turnstileSiteKey,
|
turnstileSiteKey: config.turnstileSiteKey,
|
||||||
turnstileSecretKey: config.turnstileSecretKey,
|
turnstileSecretConfigured: Boolean(config.turnstileSecretKey),
|
||||||
smtpEnabled: config.smtpEnabled,
|
smtpEnabled: config.smtpEnabled,
|
||||||
smtpHost: config.smtpHost,
|
smtpHost: config.smtpHost,
|
||||||
smtpPort: config.smtpPort,
|
smtpPort: config.smtpPort,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ interface AppConfig {
|
|||||||
inviteRewardRate: number;
|
inviteRewardRate: number;
|
||||||
inviteRewardCouponId: string | null;
|
inviteRewardCouponId: string | null;
|
||||||
turnstileSiteKey: string | null;
|
turnstileSiteKey: string | null;
|
||||||
turnstileSecretKey: string | null;
|
turnstileSecretConfigured: boolean;
|
||||||
smtpEnabled: boolean;
|
smtpEnabled: boolean;
|
||||||
smtpHost: string | null;
|
smtpHost: string | null;
|
||||||
smtpPort: number;
|
smtpPort: number;
|
||||||
@@ -123,6 +123,11 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
if (password instanceof HTMLInputElement) {
|
if (password instanceof HTMLInputElement) {
|
||||||
password.value = "";
|
password.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const turnstileSecret = form.elements.namedItem("turnstileSecretKey");
|
||||||
|
if (turnstileSecret instanceof HTMLInputElement) {
|
||||||
|
turnstileSecret.value = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -553,7 +558,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="turnstileSecretKey">Secret Key</Label>
|
<Label htmlFor="turnstileSecretKey">Secret Key</Label>
|
||||||
<Input id="turnstileSecretKey" name="turnstileSecretKey" type="password" defaultValue={config.turnstileSecretKey ?? ""} placeholder="0x4AAAAAAA..." />
|
<Input
|
||||||
|
id="turnstileSecretKey"
|
||||||
|
name="turnstileSecretKey"
|
||||||
|
type="password"
|
||||||
|
placeholder={config.turnstileSecretConfigured ? "留空保持不变" : "0x4AAAAAAA..."}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{config.turnstileSecretConfigured && (
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">Secret Key 已配置;留空保持不变。清空 Site Key 后保存可停用 Turnstile。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { SubscriptionRiskEvent } from "@prisma/client";
|
import type { SubscriptionRiskEvent } from "@prisma/client";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
SubscriptionStatusBadge,
|
SubscriptionStatusBadge,
|
||||||
SubscriptionTypeBadge,
|
SubscriptionTypeBadge,
|
||||||
@@ -155,60 +156,97 @@ function ReviewState({ event }: { event: SubscriptionRiskEventRow }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
|
function RiskStat({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
<article className="surface-card overflow-hidden rounded-xl">
|
<span className="min-w-0 rounded-lg border border-border/70 bg-muted/20 px-2.5 py-1.5">
|
||||||
<div className="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(25rem,1.2fr)_minmax(18rem,0.65fr)]">
|
<span className="block text-[0.68rem] leading-none text-muted-foreground">{label}</span>
|
||||||
<section className="space-y-5 p-5">
|
<span className="mt-1 block truncate font-mono text-sm font-semibold leading-none">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
|
||||||
|
const summary = event.geoSummary;
|
||||||
|
const userLabel = event.user?.email ?? "未知用户";
|
||||||
|
const scopeLabel = event.subscription?.plan.name ?? "总订阅";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="surface-card group overflow-hidden rounded-xl">
|
||||||
|
<summary className="flex cursor-pointer list-none items-start gap-4 p-4 text-left [&::-webkit-details-marker]:hidden sm:p-5">
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
|
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
|
||||||
{reasonLabel(event.reason)}
|
{reasonLabel(event.reason)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<StatusBadge tone="neutral">{kindLabel(event.kind)}</StatusBadge>
|
<StatusBadge tone="neutral">{kindLabel(event.kind)}</StatusBadge>
|
||||||
|
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
|
||||||
|
{event.userRestrictionActive && <StatusBadge tone="danger">用户端限制中</StatusBadge>}
|
||||||
|
{event.reportSentAt && <StatusBadge tone="info">已发送报告</StatusBadge>}
|
||||||
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)}</span>
|
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||||
<p className="text-sm font-semibold leading-6">{event.message}</p>
|
<div className="min-w-0 space-y-1.5">
|
||||||
<p className="break-all font-mono text-xs text-muted-foreground">最近 IP:{event.ip || "未知 IP"}</p>
|
<p className="line-clamp-2 text-sm font-semibold leading-6">{event.message}</p>
|
||||||
</div>
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{userLabel} · {scopeLabel} · 最近 IP:{event.ip || "未知 IP"}
|
||||||
<div className="grid gap-4 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
</p>
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">关联用户</p>
|
|
||||||
<UserBlock event={event} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="grid grid-cols-4 gap-2 text-right sm:flex sm:justify-end">
|
||||||
<p className="text-xs font-medium text-muted-foreground">影响范围</p>
|
<RiskStat label="国家" value={summary.uniqueCountryCount} />
|
||||||
<EventScope event={event} />
|
<RiskStat label="省区" value={summary.uniqueRegionCount} />
|
||||||
|
<RiskStat label="城市" value={summary.uniqueCityCount} />
|
||||||
|
<RiskStat label="IP" value={summary.uniqueIpCount} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="border-y border-border/70 bg-muted/10 p-5 xl:border-x xl:border-y-0">
|
<span className="mt-1 flex shrink-0 items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
|
||||||
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
|
<span className="hidden sm:inline">详情</span>
|
||||||
</section>
|
<ChevronDown className="size-4 transition-transform group-open:rotate-180" />
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
<aside className="space-y-5 p-5">
|
<div className="border-t border-border/70">
|
||||||
<div className="space-y-2">
|
<div className="grid xl:grid-cols-[minmax(0,0.85fr)_minmax(24rem,1.15fr)_minmax(18rem,0.7fr)]">
|
||||||
<p className="text-xs font-medium text-muted-foreground">处理状态</p>
|
<section className="space-y-5 p-5">
|
||||||
<ReviewState event={event} />
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
<div className="border-t border-border/60 pt-4">
|
<p className="text-xs font-medium text-muted-foreground">关联用户</p>
|
||||||
<SubscriptionRiskReviewActions
|
<UserBlock event={event} />
|
||||||
eventId={event.id}
|
</div>
|
||||||
reviewStatus={event.reviewStatus}
|
<div className="space-y-1">
|
||||||
canRestoreSubscription={event.canRestoreSubscription}
|
<p className="text-xs font-medium text-muted-foreground">影响范围</p>
|
||||||
restorableSubscriptionCount={event.restorableSubscriptionCount}
|
<EventScope event={event} />
|
||||||
riskReport={event.riskReport}
|
</div>
|
||||||
reportSentAt={event.reportSentAt}
|
</div>
|
||||||
userRestrictionActive={event.userRestrictionActive}
|
</section>
|
||||||
finalAction={event.finalAction}
|
|
||||||
/>
|
<section className="border-y border-border/70 bg-muted/10 p-5 xl:border-x xl:border-y-0">
|
||||||
</div>
|
<SubscriptionRiskGeoDetails summary={summary} />
|
||||||
</aside>
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-5 p-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">处理状态</p>
|
||||||
|
<ReviewState event={event} />
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-4">
|
||||||
|
<SubscriptionRiskReviewActions
|
||||||
|
eventId={event.id}
|
||||||
|
reviewStatus={event.reviewStatus}
|
||||||
|
canRestoreSubscription={event.canRestoreSubscription}
|
||||||
|
restorableSubscriptionCount={event.restorableSubscriptionCount}
|
||||||
|
riskReport={event.riskReport}
|
||||||
|
reportSentAt={event.reportSentAt}
|
||||||
|
userRestrictionActive={event.userRestrictionActive}
|
||||||
|
finalAction={event.finalAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { AdminSidebar } from "@/components/admin/sidebar";
|
import { AdminSidebar } from "@/components/admin/sidebar";
|
||||||
import { AdminMobileNav } from "@/components/admin/mobile-nav";
|
import { AdminMobileNav } from "@/components/admin/mobile-nav";
|
||||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||||
@@ -21,7 +20,7 @@ export default async function AdminLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||||
import { PageTransition } from "@/components/shared/page-transition";
|
import { PageTransition } from "@/components/shared/page-transition";
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ export default async function AuthLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
redirect(session.user.role === "ADMIN" ? "/admin/dashboard" : "/dashboard");
|
redirect(session.user.role === "ADMIN" ? "/admin/dashboard" : "/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -17,7 +16,7 @@ export default async function PaymentLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { AccountPanel } from "./account-panel";
|
import { AccountPanel } from "./account-panel";
|
||||||
import { getAccountPageData } from "./account-data";
|
import { getAccountPageData } from "./account-data";
|
||||||
@@ -12,7 +11,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AccountPage() {
|
export default async function AccountPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const { user, siteNotice } = await getAccountPageData(session!.user.id);
|
const { user, siteNotice } = await getAccountPageData(session!.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { ShoppingBag, ShoppingCart } from "lucide-react";
|
import { ShoppingBag, ShoppingCart } from "lucide-react";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { EmptyState, PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { EmptyState, PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { CartClient } from "./cart-client";
|
import { CartClient } from "./cart-client";
|
||||||
@@ -14,7 +13,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function CartPage() {
|
export default async function CartPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const data = await getCartPageData(session!.user.id);
|
const data = await getCartPageData(session!.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { getDashboardData, getDashboardTrafficTrend } from "./dashboard-data";
|
import { getDashboardData, getDashboardTrafficTrend } from "./dashboard-data";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +23,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function UserDashboard() {
|
export default async function UserDashboard() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
|
|
||||||
const { activeSubs, pendingOrderCount, paidOrderCount, config } =
|
const { activeSubs, pendingOrderCount, paidOrderCount, config } =
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { UserSidebar } from "@/components/user/sidebar";
|
import { UserSidebar } from "@/components/user/sidebar";
|
||||||
import { UserMobileNav } from "@/components/user/mobile-nav";
|
import { UserMobileNav } from "@/components/user/mobile-nav";
|
||||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||||
@@ -24,7 +23,7 @@ export default async function UserLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { NotificationBulkAction } from "./notification-actions";
|
import { NotificationBulkAction } from "./notification-actions";
|
||||||
import { NotificationList } from "./_components/notification-list";
|
import { NotificationList } from "./_components/notification-list";
|
||||||
@@ -12,7 +11,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function NotificationsPage() {
|
export default async function NotificationsPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const { notifications, unreadCount, readCount } = await getUserNotifications(session!.user.id);
|
const { notifications, unreadCount, readCount } = await getUserNotifications(session!.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { Pagination } from "@/components/shared/pagination";
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { UserOrdersTable } from "./_components/user-orders-table";
|
import { UserOrdersTable } from "./_components/user-orders-table";
|
||||||
@@ -16,7 +15,7 @@ export default async function UserOrdersPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const { orders, total, page, pageSize } = await getUserOrders({
|
const { orders, total, page, pageSize } = await getUserOrders({
|
||||||
userId: session!.user.id,
|
userId: session!.user.id,
|
||||||
searchParams: await searchParams,
|
searchParams: await searchParams,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { Film, LifeBuoy, Radio } from "lucide-react";
|
import { Film, LifeBuoy, Radio } from "lucide-react";
|
||||||
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { EmptyState, PageShell } from "@/components/shared/page-shell";
|
import { EmptyState, PageShell } from "@/components/shared/page-shell";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { PendingOrderBanner } from "./pending-order-banner";
|
import { PendingOrderBanner } from "./pending-order-banner";
|
||||||
@@ -30,7 +29,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function StorePage() {
|
export default async function StorePage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const { plans, availabilityMap, pendingOrder, latencyRecommendations } = await getStorePageData(session?.user.id);
|
const { plans, availabilityMap, pendingOrder, latencyRecommendations } = await getStorePageData(session?.user.id);
|
||||||
const proxyPlans = getProxyPlans(plans);
|
const proxyPlans = getProxyPlans(plans);
|
||||||
const streamingPlans = getStreamingPlans(plans);
|
const streamingPlans = getStreamingPlans(plans);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards";
|
import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards";
|
||||||
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
|
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
|
||||||
@@ -22,7 +21,7 @@ export default async function UserSubscriptionDetailPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const [data, baseUrl] = await Promise.all([
|
const [data, baseUrl] = await Promise.all([
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import {
|
import {
|
||||||
getActiveSubscriptions,
|
getActiveSubscriptions,
|
||||||
@@ -22,7 +21,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SubscriptionsPage() {
|
export default async function SubscriptionsPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const [subs, baseUrl] = await Promise.all([
|
const [subs, baseUrl] = await Promise.all([
|
||||||
getUserSubscriptions(session!.user.id),
|
getUserSubscriptions(session!.user.id),
|
||||||
getSubscriptionBaseUrl(),
|
getSubscriptionBaseUrl(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import {
|
import {
|
||||||
SupportTicketPriorityBadge,
|
SupportTicketPriorityBadge,
|
||||||
@@ -23,7 +22,7 @@ export default async function SupportTicketDetailPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const ticket = await getUserSupportTicketDetail({
|
const ticket = await getUserSupportTicketDetail({
|
||||||
ticketId: id,
|
ticketId: id,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
@@ -19,7 +18,7 @@ export default async function SupportPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const riskEventId = typeof resolvedSearchParams.riskEventId === "string" ? resolvedSearchParams.riskEventId : "";
|
const riskEventId = typeof resolvedSearchParams.riskEventId === "string" ? resolvedSearchParams.riskEventId : "";
|
||||||
const [tickets, openTicketCount, config, riskEvent] = await Promise.all([
|
const [tickets, openTicketCount, config, riskEvent] = await Promise.all([
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { verifyTurnstile } from "@/lib/turnstile";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getClientIp } from "@/lib/request-context";
|
import { getClientIp } from "@/lib/request-context";
|
||||||
import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
||||||
|
import { decryptIfEncrypted } from "@/lib/crypto";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email("邮箱格式不正确"),
|
email: z.string().email("邮箱格式不正确"),
|
||||||
@@ -58,8 +59,11 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.turnstileSecretKey) {
|
const turnstileSecretKey = config.turnstileSecretKey
|
||||||
if (!turnstileToken || !(await verifyTurnstile(turnstileToken, config.turnstileSecretKey))) {
|
? decryptIfEncrypted(config.turnstileSecretKey)
|
||||||
|
: "";
|
||||||
|
if (turnstileSecretKey) {
|
||||||
|
if (!turnstileToken || !(await verifyTurnstile(turnstileToken, turnstileSecretKey))) {
|
||||||
return NextResponse.json({ error: "人机验证失败:Turnstile token 缺失、已过期或校验未通过" }, { status: 403 });
|
return NextResponse.json({ error: "人机验证失败:Turnstile token 缺失、已过期或校验未通过" }, { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { jsonError, jsonOk } from "@/lib/api-response";
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
import { getUserNotifications } from "@/app/(user)/notifications/notifications-data";
|
import { getUserNotifications } from "@/app/(user)/notifications/notifications-data";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) return jsonError("未登录", { status: 401 });
|
if (!session) return jsonError("未登录", { status: 401 });
|
||||||
|
|
||||||
const data = await getUserNotifications(session.user.id);
|
const data = await getUserNotifications(session.user.id);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { jsonError, jsonOk } from "@/lib/api-response";
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
import { getPaymentAdapter } from "@/services/payment/factory";
|
import { getPaymentAdapter } from "@/services/payment/factory";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
@@ -28,7 +27,7 @@ function isSafePaymentUrl(value: string | undefined) {
|
|||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return jsonError("未登录", { status: 401 });
|
return jsonError("未登录", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { jsonError, jsonOk } from "@/lib/api-response";
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ orderId: string }> },
|
{ params }: { params: Promise<{ orderId: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return jsonError("未登录", { status: 401 });
|
return jsonError("未登录", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { jsonError, jsonOk } from "@/lib/api-response";
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
import { getPaymentAdapter } from "@/services/payment/factory";
|
import { getPaymentAdapter } from "@/services/payment/factory";
|
||||||
import { handleVerifiedPaymentSuccess } from "@/services/payment/process";
|
import { handleVerifiedPaymentSuccess } from "@/services/payment/process";
|
||||||
@@ -9,7 +8,7 @@ export async function GET(
|
|||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ tradeNo: string }> }
|
{ params }: { params: Promise<{ tradeNo: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return jsonError("未登录", { status: 401 });
|
return jsonError("未登录", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return new Response("附件访问失败:你尚未登录,请登录后重新打开附件", { status: 401 });
|
return new Response("附件访问失败:你尚未登录,请登录后重新打开附件", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "首页",
|
title: "首页",
|
||||||
@@ -9,7 +8,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
if (session.user.role === "ADMIN") redirect("/admin/dashboard");
|
if (session.user.role === "ADMIN") redirect("/admin/dashboard");
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "./auth";
|
|
||||||
import { jsonError } from "./api-response";
|
import { jsonError } from "./api-response";
|
||||||
|
import { getActiveSession } from "./require-auth";
|
||||||
|
|
||||||
export async function requireAdminApiSession() {
|
export async function requireAdminApiSession() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session || session.user.role !== "ADMIN") {
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import CredentialsProvider from "next-auth/providers/credentials";
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { verifyTurnstile } from "./turnstile";
|
import { verifyTurnstile } from "./turnstile";
|
||||||
|
import { decryptIfEncrypted } from "./crypto";
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -17,9 +18,12 @@ export const authOptions: NextAuthOptions = {
|
|||||||
if (!credentials?.email || !credentials?.password) return null;
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
|
||||||
const config = await prisma.appConfig.findUnique({ where: { id: "default" } });
|
const config = await prisma.appConfig.findUnique({ where: { id: "default" } });
|
||||||
if (config?.turnstileSecretKey) {
|
const turnstileSecretKey = config?.turnstileSecretKey
|
||||||
|
? decryptIfEncrypted(config.turnstileSecretKey)
|
||||||
|
: "";
|
||||||
|
if (turnstileSecretKey) {
|
||||||
const token = credentials.turnstileToken;
|
const token = credentials.turnstileToken;
|
||||||
if (!token || !(await verifyTurnstile(token, config.turnstileSecretKey))) {
|
if (!token || !(await verifyTurnstile(token, turnstileSecretKey))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ export function encrypt(text: string): string {
|
|||||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHexBytes(value: string) {
|
||||||
|
return value.length > 0 && value.length % 2 === 0 && /^[0-9a-f]+$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEncryptedValue(data: string): boolean {
|
||||||
|
const parts = data.split(":");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
|
||||||
|
const [ivHex, authTagHex, encryptedHex] = parts;
|
||||||
|
return ivHex.length === 32
|
||||||
|
&& authTagHex.length === 32
|
||||||
|
&& isHexBytes(ivHex)
|
||||||
|
&& isHexBytes(authTagHex)
|
||||||
|
&& isHexBytes(encryptedHex);
|
||||||
|
}
|
||||||
|
|
||||||
export function decrypt(data: string): string {
|
export function decrypt(data: string): string {
|
||||||
const parts = data.split(":");
|
const parts = data.split(":");
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
@@ -28,3 +44,7 @@ export function decrypt(data: string): string {
|
|||||||
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
||||||
return decipher.update(Buffer.from(encryptedHex, "hex")) + decipher.final("utf8");
|
return decipher.update(Buffer.from(encryptedHex, "hex")) + decipher.final("utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decryptIfEncrypted(data: string): string {
|
||||||
|
return isEncryptedValue(data) ? decrypt(data) : data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "./auth";
|
import { authOptions } from "./auth";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
export async function requireAdmin() {
|
export async function getActiveSession() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { id: true, email: true, name: true, role: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.status !== "ACTIVE") return null;
|
||||||
|
|
||||||
|
session.user.id = user.id;
|
||||||
|
session.user.email = user.email;
|
||||||
|
session.user.name = user.name;
|
||||||
|
session.user.role = user.role;
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const session = await getActiveSession();
|
||||||
if (!session || session.user.role !== "ADMIN") {
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
throw new Error("无权限");
|
throw new Error("无权限");
|
||||||
}
|
}
|
||||||
@@ -11,7 +31,7 @@ export async function requireAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(options: { allowDuringRiskRestriction?: boolean } = {}) {
|
export async function requireAuth(options: { allowDuringRiskRestriction?: boolean } = {}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getActiveSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("未登录");
|
throw new Error("未登录");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,13 +242,19 @@ export async function verifyEmailToken(token: string) {
|
|||||||
|
|
||||||
if (record.purpose === "REGISTRATION_VERIFY") {
|
if (record.purpose === "REGISTRATION_VERIFY") {
|
||||||
if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" };
|
if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" };
|
||||||
await prisma.user.update({
|
const result = await prisma.user.updateMany({
|
||||||
where: { id: record.userId },
|
where: {
|
||||||
|
id: record.userId,
|
||||||
|
status: { in: ["PENDING_EMAIL", "ACTIVE"] },
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (result.count !== 1) {
|
||||||
|
return { ok: false as const, message: "账户当前状态不允许完成验证,请联系管理员" };
|
||||||
|
}
|
||||||
return { ok: true as const, message: "邮箱验证完成,现在可以登录账户。" };
|
return { ok: true as const, message: "邮箱验证完成,现在可以登录账户。" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/services/node-inbound-sanitize.ts
Normal file
28
src/services/node-inbound-sanitize.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeInboundSettings(settings: unknown) {
|
||||||
|
const record = asRecord(settings);
|
||||||
|
const displayName = stringValue(record?.displayName);
|
||||||
|
return displayName ? { displayName } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeStreamSettings(streamSettings: unknown) {
|
||||||
|
const record = asRecord(streamSettings);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const network = stringValue(record.network);
|
||||||
|
const security = stringValue(record.security);
|
||||||
|
const sanitized: Record<string, string> = {};
|
||||||
|
if (network) sanitized.network = network;
|
||||||
|
if (security) sanitized.security = security;
|
||||||
|
|
||||||
|
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { NodeServer } from "@prisma/client";
|
import type { NodeServer } from "@prisma/client";
|
||||||
|
import { decryptIfEncrypted } from "@/lib/crypto";
|
||||||
import type { NodePanelAdapter } from "./adapter";
|
import type { NodePanelAdapter } from "./adapter";
|
||||||
import { ThreeXUIAdapter } from "./three-x-ui";
|
import { ThreeXUIAdapter } from "./three-x-ui";
|
||||||
|
|
||||||
export function createPanelAdapter(server: NodeServer): NodePanelAdapter {
|
type PanelServerConfig = Pick<NodeServer, "name" | "panelType" | "panelUrl" | "panelUsername" | "panelPassword">;
|
||||||
|
|
||||||
|
export function createPanelAdapter(server: PanelServerConfig): NodePanelAdapter {
|
||||||
const panelType = server.panelType ?? "3x-ui";
|
const panelType = server.panelType ?? "3x-ui";
|
||||||
if (panelType !== "3x-ui") {
|
if (panelType !== "3x-ui") {
|
||||||
throw new Error(`节点 ${server.name} 面板类型不支持:${panelType},当前仅支持 3x-ui`);
|
throw new Error(`节点 ${server.name} 面板类型不支持:${panelType},当前仅支持 3x-ui`);
|
||||||
@@ -10,5 +13,9 @@ export function createPanelAdapter(server: NodeServer): NodePanelAdapter {
|
|||||||
if (!server.panelUrl || !server.panelUsername || !server.panelPassword) {
|
if (!server.panelUrl || !server.panelUsername || !server.panelPassword) {
|
||||||
throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`);
|
throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`);
|
||||||
}
|
}
|
||||||
return new ThreeXUIAdapter(server.panelUrl, server.panelUsername, server.panelPassword);
|
return new ThreeXUIAdapter(
|
||||||
|
server.panelUrl,
|
||||||
|
server.panelUsername,
|
||||||
|
decryptIfEncrypted(server.panelPassword),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { decryptIfEncrypted, encrypt, isEncryptedValue } from "@/lib/crypto";
|
||||||
|
|
||||||
export interface PaymentConfigField {
|
export interface PaymentConfigField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -107,6 +108,78 @@ function normalizeConfig(config: Record<string, unknown>): Record<string, string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSecretFieldKeys(provider: string) {
|
||||||
|
return new Set(
|
||||||
|
(getPaymentProviderDefinition(provider)?.fields ?? [])
|
||||||
|
.filter((field) => field.secret)
|
||||||
|
.map((field) => field.key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptPaymentConfigForUse(
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalized = normalizeConfig(config);
|
||||||
|
const secretKeys = getSecretFieldKeys(provider);
|
||||||
|
|
||||||
|
for (const key of secretKeys) {
|
||||||
|
if (normalized[key]) {
|
||||||
|
normalized[key] = decryptIfEncrypted(normalized[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePaymentConfigForStorage(
|
||||||
|
provider: string,
|
||||||
|
incomingConfig: Record<string, unknown>,
|
||||||
|
currentConfig?: Record<string, unknown> | null,
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalized = normalizeConfig(incomingConfig);
|
||||||
|
const current = currentConfig ? normalizeConfig(currentConfig) : {};
|
||||||
|
const secretKeys = getSecretFieldKeys(provider);
|
||||||
|
|
||||||
|
for (const key of secretKeys) {
|
||||||
|
const nextSecret = normalized[key]?.trim();
|
||||||
|
const currentSecret = current[key]?.trim();
|
||||||
|
|
||||||
|
if (nextSecret) {
|
||||||
|
normalized[key] = encrypt(nextSecret);
|
||||||
|
} else if (currentSecret) {
|
||||||
|
normalized[key] = isEncryptedValue(currentSecret) ? currentSecret : encrypt(currentSecret);
|
||||||
|
} else {
|
||||||
|
normalized[key] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactPaymentConfigForClient(
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalized = normalizeConfig(config);
|
||||||
|
for (const key of getSecretFieldKeys(provider)) {
|
||||||
|
normalized[key] = "";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentSecretConfiguredState(
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, boolean> {
|
||||||
|
const normalized = normalizeConfig(config);
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
for (const key of getSecretFieldKeys(provider)) {
|
||||||
|
result[key] = Boolean(normalized[key]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function getPaymentProviderDefinition(provider: string) {
|
export function getPaymentProviderDefinition(provider: string) {
|
||||||
return PAYMENT_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? null;
|
return PAYMENT_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { EasyPayAdapter, type EasyPayConfig } from "./epay";
|
|||||||
import { AlipayF2FAdapter, type AlipayF2FConfig } from "./alipay-f2f";
|
import { AlipayF2FAdapter, type AlipayF2FConfig } from "./alipay-f2f";
|
||||||
import { UsdtTrc20Adapter, type UsdtTrc20Config } from "./usdt-trc20";
|
import { UsdtTrc20Adapter, type UsdtTrc20Config } from "./usdt-trc20";
|
||||||
import {
|
import {
|
||||||
|
decryptPaymentConfigForUse,
|
||||||
getPaymentProviderName,
|
getPaymentProviderName,
|
||||||
parsePaymentConfig,
|
parsePaymentConfig,
|
||||||
} from "./catalog";
|
} from "./catalog";
|
||||||
@@ -22,7 +23,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
|
|||||||
|
|
||||||
const cfg = parsePaymentConfig(
|
const cfg = parsePaymentConfig(
|
||||||
realProvider,
|
realProvider,
|
||||||
config.config as Record<string, string>,
|
decryptPaymentConfigForUse(realProvider, config.config as Record<string, unknown>),
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (realProvider) {
|
switch (realProvider) {
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ export function buildSubscriptionUserInfo(stats: SubscriptionTrafficStats | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAggregateSubscriptionSecret() {
|
function getAggregateSubscriptionSecret() {
|
||||||
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL;
|
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error("缺少订阅链接签名密钥,请配置 NEXTAUTH_SECRET");
|
throw new Error("缺少订阅链接签名密钥,请配置 NEXTAUTH_SECRET 或 AUTH_SECRET");
|
||||||
}
|
}
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user