diff --git a/src/actions/admin/nodes.ts b/src/actions/admin/nodes.ts index b55ba07..bae1afc 100644 --- a/src/actions/admin/nodes.ts +++ b/src/actions/admin/nodes.ts @@ -6,16 +6,23 @@ import { requireAdmin } from "@/lib/require-auth"; import { revalidatePath } from "next/cache"; import { z } from "zod"; 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"; -const nodeSchema = z.object({ +const nodeBaseSchema = z.object({ name: z.string().trim().optional(), panelUrl: 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 密码必填"), }); +const updateNodeSchema = nodeBaseSchema.extend({ + panelPassword: z.string().trim().optional(), +}); + function normalizePanelUrl(raw: string): string { try { let value = raw.trim(); @@ -34,24 +41,26 @@ function normalizePanelUrl(raw: string): string { } } -function parseNodeData(formData: FormData) { - const raw = nodeSchema.parse(Object.fromEntries(formData)); +function parseNodeData(formData: FormData, mode: "create" | "update") { + const raw = (mode === "create" ? createNodeSchema : updateNodeSchema) + .parse(Object.fromEntries(formData)); const panelUrl = normalizePanelUrl(raw.panelUrl); const panel = new URL(panelUrl); + const panelPassword = raw.panelPassword?.trim(); const name = (raw.name || "").trim() || `节点-${panel.hostname}`; return { name, panelUrl, panelUsername: raw.panelUsername, - panelPassword: raw.panelPassword, + ...(panelPassword ? { panelPassword: encrypt(panelPassword) } : {}), panelType: "3x-ui", }; } export async function createNode(formData: FormData) { const session = await requireAdmin(); - const data = parseNodeData(formData); + const data = parseNodeData(formData, "create"); const node = await prisma.nodeServer.create({ data }); const result = await testAndSyncNodeInbounds(node); @@ -72,7 +81,18 @@ export async function createNode(formData: FormData) { export async function updateNode(id: string, formData: FormData) { 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 result = await testAndSyncNodeInbounds(node); diff --git a/src/actions/admin/payments.ts b/src/actions/admin/payments.ts index 5fba743..2d42768 100644 --- a/src/actions/admin/payments.ts +++ b/src/actions/admin/payments.ts @@ -4,8 +4,10 @@ import { prisma } from "@/lib/prisma"; import { requireAdmin } from "@/lib/require-auth"; import { revalidatePath } from "next/cache"; import { + decryptPaymentConfigForUse, normalizePaymentConfig, parsePaymentConfig, + preparePaymentConfigForStorage, } from "@/services/payment/catalog"; import { actorFromSession, recordAuditLog } from "@/services/audit"; import { z } from "zod"; @@ -18,11 +20,19 @@ export async function savePaymentConfig( const session = await requireAdmin(); const normalizedConfig = normalizePaymentConfig(config); - let finalConfig = normalizedConfig as Record; + const current = await prisma.paymentConfig.findUnique({ + where: { provider }, + select: { config: true }, + }); + const storageConfig = preparePaymentConfigForStorage( + provider, + normalizedConfig, + current?.config as Record | undefined, + ); if (enabled) { try { - finalConfig = parsePaymentConfig(provider, normalizedConfig) as Record; + parsePaymentConfig(provider, decryptPaymentConfigForUse(provider, storageConfig)); } catch (error) { if (error instanceof z.ZodError) { 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({ where: { provider }, diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 174f20c..4eff8aa 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -8,7 +8,7 @@ import { requireAdmin } from "@/lib/require-auth"; import { actorFromSession, recordAuditLog } from "@/services/audit"; import { getAppConfig } from "@/services/app-config"; import { normalizeSiteUrl } from "@/services/site-url"; -import { encrypt } from "@/lib/crypto"; +import { encrypt, isEncryptedValue } from "@/lib/crypto"; import { getErrorMessage } from "@/lib/errors"; import { sendSmtpTestEmail } from "@/services/email"; @@ -100,6 +100,17 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw const smtpPassword = parsed.smtpPassword?.trim() ? encrypt(parsed.smtpPassword.trim()) : 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 = { siteName: parsed.siteName, @@ -150,8 +161,8 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled), inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate), inviteRewardCouponId: parsed.inviteRewardCouponId || null, - turnstileSiteKey: parsed.turnstileSiteKey || null, - turnstileSecretKey: parsed.turnstileSecretKey || null, + turnstileSiteKey, + turnstileSecretKey, smtpEnabled, smtpHost: parsed.smtpHost || null, smtpPort: parsed.smtpPort ?? current.smtpPort, diff --git a/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts b/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts index c86b30d..3b6eb53 100644 --- a/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts +++ b/src/app/(admin)/admin/nodes/[id]/node-detail-data.ts @@ -1,8 +1,13 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; 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: { where: { isActive: true }, orderBy: { updatedAt: "desc" }, @@ -12,17 +17,25 @@ const nodeDetailInclude = { }, }, }, -} satisfies Prisma.NodeServerInclude; +} satisfies Prisma.NodeServerSelect; export type NodeDetail = Prisma.NodeServerGetPayload<{ - include: typeof nodeDetailInclude; + select: typeof nodeDetailSelect; }>; export async function getNodeDetail(id: string): Promise { const node = await prisma.nodeServer.findUnique({ where: { id }, - include: nodeDetailInclude, + select: nodeDetailSelect, }); if (!node) notFound(); - return node; + + return { + ...node, + inbounds: node.inbounds.map((inbound) => ({ + ...inbound, + settings: sanitizeInboundSettings(inbound.settings), + streamSettings: sanitizeStreamSettings(inbound.streamSettings), + })), + }; } diff --git a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx index ec982fa..4aea6d2 100644 --- a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx +++ b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx @@ -60,7 +60,6 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu name: node.name, panelUrl: node.panelUrl, panelUsername: node.panelUsername, - panelPassword: node.panelPassword, }} triggerLabel="编辑" triggerVariant="outline" diff --git a/src/app/(admin)/admin/nodes/node-form.tsx b/src/app/(admin)/admin/nodes/node-form.tsx index e28eb8d..eaf3ae5 100644 --- a/src/app/(admin)/admin/nodes/node-form.tsx +++ b/src/app/(admin)/admin/nodes/node-form.tsx @@ -22,7 +22,6 @@ interface NodeFormValue { name: string; panelUrl: string | null; panelUsername: string | null; - panelPassword: string | null; } export function NodeForm({ @@ -92,7 +91,13 @@ export function NodeForm({
- +
diff --git a/src/app/(admin)/admin/nodes/nodes-data.ts b/src/app/(admin)/admin/nodes/nodes-data.ts index 067d23b..1b6d206 100644 --- a/src/app/(admin)/admin/nodes/nodes-data.ts +++ b/src/app/(admin)/admin/nodes/nodes-data.ts @@ -2,8 +2,15 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { parsePage } from "@/lib/utils"; 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 } }, inbounds: { where: { isActive: true }, @@ -16,10 +23,10 @@ const nodeInclude = { }, orderBy: { updatedAt: "desc" }, }, -} satisfies Prisma.NodeServerInclude; +} satisfies Prisma.NodeServerSelect; export type NodeServerRow = Prisma.NodeServerGetPayload<{ - include: typeof nodeInclude; + select: typeof nodeSelect; }>; export async function getNodeServers( @@ -44,7 +51,7 @@ export async function getNodeServers( const [nodes, total, siteUrl] = await Promise.all([ prisma.nodeServer.findMany({ where, - include: nodeInclude, + select: nodeSelect, orderBy: { createdAt: "desc" }, skip, take: pageSize, @@ -53,5 +60,14 @@ export async function getNodeServers( 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 }; } diff --git a/src/app/(admin)/admin/payments/config-form.tsx b/src/app/(admin)/admin/payments/config-form.tsx index 100e366..6adfef1 100644 --- a/src/app/(admin)/admin/payments/config-form.tsx +++ b/src/app/(admin)/admin/payments/config-form.tsx @@ -22,10 +22,17 @@ interface Props { provider: string; fields: Field[]; currentConfig?: Record; + secretConfigured?: Record; 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 [saving, setSaving] = useState(false); @@ -65,6 +72,13 @@ export function PaymentConfigForm({ provider, fields, currentConfig, enabled: in try { 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("保存成功"); } catch (error) { toast.error(getErrorMessage(error, "保存失败")); @@ -99,8 +113,8 @@ export function PaymentConfigForm({ provider, fields, currentConfig, enabled: in ), diff --git a/src/app/(admin)/admin/payments/page.tsx b/src/app/(admin)/admin/payments/page.tsx index 5c14af6..a64ad37 100644 --- a/src/app/(admin)/admin/payments/page.tsx +++ b/src/app/(admin)/admin/payments/page.tsx @@ -20,7 +20,7 @@ export default async function PaymentsPage() { title="支付配置" />
- {providerConfigs.map(({ provider, config }) => ( + {providerConfigs.map(({ provider, config, secretConfigured }) => (
@@ -37,7 +37,8 @@ export default async function PaymentsPage() { | undefined} + currentConfig={config?.config} + secretConfigured={secretConfigured} enabled={config?.enabled ?? false} />
diff --git a/src/app/(admin)/admin/payments/payments-data.ts b/src/app/(admin)/admin/payments/payments-data.ts index 21831d8..536b364 100644 --- a/src/app/(admin)/admin/payments/payments-data.ts +++ b/src/app/(admin)/admin/payments/payments-data.ts @@ -1,12 +1,29 @@ 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() { const configs = await prisma.paymentConfig.findMany(); const configMap = new Map(configs.map((config) => [config.provider, config])); - return PAYMENT_PROVIDER_DEFINITIONS.map((provider) => ({ - provider, - config: configMap.get(provider.id), - })); + return PAYMENT_PROVIDER_DEFINITIONS.map((provider) => { + const config = configMap.get(provider.id); + const configValue = config?.config as Record | undefined; + + return { + provider, + config: config + ? { + enabled: config.enabled, + config: redactPaymentConfigForClient(provider.id, configValue ?? {}), + } + : null, + secretConfigured: configValue + ? getPaymentSecretConfiguredState(provider.id, configValue) + : {}, + }; + }); } diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index 04ff2ca..d0efe09 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -52,7 +52,7 @@ export default async function AdminSettingsPage() { inviteRewardRate: Number(config.inviteRewardRate), inviteRewardCouponId: config.inviteRewardCouponId, turnstileSiteKey: config.turnstileSiteKey, - turnstileSecretKey: config.turnstileSecretKey, + turnstileSecretConfigured: Boolean(config.turnstileSecretKey), smtpEnabled: config.smtpEnabled, smtpHost: config.smtpHost, smtpPort: config.smtpPort, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index e904eb5..be9a366 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -41,7 +41,7 @@ interface AppConfig { inviteRewardRate: number; inviteRewardCouponId: string | null; turnstileSiteKey: string | null; - turnstileSecretKey: string | null; + turnstileSecretConfigured: boolean; smtpEnabled: boolean; smtpHost: string | null; smtpPort: number; @@ -123,6 +123,11 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: if (password instanceof HTMLInputElement) { password.value = ""; } + + const turnstileSecret = form.elements.namedItem("turnstileSecretKey"); + if (turnstileSecret instanceof HTMLInputElement) { + turnstileSecret.value = ""; + } } return ( @@ -553,7 +558,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
- + + {config.turnstileSecretConfigured && ( +

Secret Key 已配置;留空保持不变。清空 Site Key 后保存可停用 Turnstile。

+ )}
diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx index 1aef5e9..9cc65f7 100644 --- a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import type { SubscriptionRiskEvent } from "@prisma/client"; +import { ChevronDown } from "lucide-react"; import { SubscriptionStatusBadge, 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 ( -
-
-
+ + {label} + {value} + + ); +} + +function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) { + const summary = event.geoSummary; + const userLabel = event.user?.email ?? "未知用户"; + const scopeLabel = event.subscription?.plan.name ?? "总订阅"; + + return ( +
+ +
{reasonLabel(event.reason)} {kindLabel(event.kind)} + {reviewStatusLabel(event.reviewStatus)} + {event.userRestrictionActive && 用户端限制中} + {event.reportSentAt && 已发送报告} {formatDate(event.createdAt)}
-
-

{event.message}

-

最近 IP:{event.ip || "未知 IP"}

-
- -
-
-

关联用户

- +
+
+

{event.message}

+

+ {userLabel} · {scopeLabel} · 最近 IP:{event.ip || "未知 IP"} +

-
-

影响范围

- +
+ + + +
-
+
-
- -
+ + 详情 + + + - +
+
+
+
+
+

关联用户

+ +
+
+

影响范围

+ +
+
+
+ +
+ +
+ + +
-
+ ); } diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index 2535458..e4b9931 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; +import { getActiveSession } from "@/lib/require-auth"; import { AdminSidebar } from "@/components/admin/sidebar"; import { AdminMobileNav } from "@/components/admin/mobile-nav"; import { AnnouncementLoader } from "@/components/announcements/announcement-loader"; @@ -21,7 +20,7 @@ export default async function AdminLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { redirect("/login"); } diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 5298cbe..6170624 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; +import { getActiveSession } from "@/lib/require-auth"; import { AnnouncementLoader } from "@/components/announcements/announcement-loader"; import { PageTransition } from "@/components/shared/page-transition"; @@ -19,7 +18,7 @@ export default async function AuthLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (session) { redirect(session.user.role === "ADMIN" ? "/admin/dashboard" : "/dashboard"); } diff --git a/src/app/(payment)/layout.tsx b/src/app/(payment)/layout.tsx index 5462e8e..b0801df 100644 --- a/src/app/(payment)/layout.tsx +++ b/src/app/(payment)/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review"; export const metadata: Metadata = { @@ -17,7 +16,7 @@ export default async function PaymentLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { redirect("/login"); diff --git a/src/app/(user)/account/page.tsx b/src/app/(user)/account/page.tsx index 8965c83..ad10837 100644 --- a/src/app/(user)/account/page.tsx +++ b/src/app/(user)/account/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { AccountPanel } from "./account-panel"; import { getAccountPageData } from "./account-data"; @@ -12,7 +11,7 @@ export const metadata: Metadata = { }; export default async function AccountPage() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const { user, siteNotice } = await getAccountPageData(session!.user.id); return ( diff --git a/src/app/(user)/cart/page.tsx b/src/app/(user)/cart/page.tsx index 4ffa174..cac064b 100644 --- a/src/app/(user)/cart/page.tsx +++ b/src/app/(user)/cart/page.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; +import { getActiveSession } from "@/lib/require-auth"; import Link from "next/link"; -import { getServerSession } from "next-auth"; import { ShoppingBag, ShoppingCart } from "lucide-react"; -import { authOptions } from "@/lib/auth"; import { EmptyState, PageHeader, PageShell } from "@/components/shared/page-shell"; import { buttonVariants } from "@/components/ui/button"; import { CartClient } from "./cart-client"; @@ -14,7 +13,7 @@ export const metadata: Metadata = { }; export default async function CartPage() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const data = await getCartPageData(session!.user.id); return ( diff --git a/src/app/(user)/dashboard/page.tsx b/src/app/(user)/dashboard/page.tsx index dc8e8ab..fcfa807 100644 --- a/src/app/(user)/dashboard/page.tsx +++ b/src/app/(user)/dashboard/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { getDashboardData, getDashboardTrafficTrend } from "./dashboard-data"; import { @@ -24,7 +23,7 @@ export const metadata: Metadata = { }; export default async function UserDashboard() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const userId = session!.user.id; const { activeSubs, pendingOrderCount, paidOrderCount, config } = diff --git a/src/app/(user)/layout.tsx b/src/app/(user)/layout.tsx index 915df8c..3f06f83 100644 --- a/src/app/(user)/layout.tsx +++ b/src/app/(user)/layout.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; +import { getActiveSession } from "@/lib/require-auth"; import { UserSidebar } from "@/components/user/sidebar"; import { UserMobileNav } from "@/components/user/mobile-nav"; import { AnnouncementLoader } from "@/components/announcements/announcement-loader"; @@ -24,7 +23,7 @@ export default async function UserLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { redirect("/login"); } diff --git a/src/app/(user)/notifications/page.tsx b/src/app/(user)/notifications/page.tsx index b35c451..a6b1bad 100644 --- a/src/app/(user)/notifications/page.tsx +++ b/src/app/(user)/notifications/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { NotificationBulkAction } from "./notification-actions"; import { NotificationList } from "./_components/notification-list"; @@ -12,7 +11,7 @@ export const metadata: Metadata = { }; export default async function NotificationsPage() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const { notifications, unreadCount, readCount } = await getUserNotifications(session!.user.id); return ( diff --git a/src/app/(user)/orders/page.tsx b/src/app/(user)/orders/page.tsx index e846b35..356ef91 100644 --- a/src/app/(user)/orders/page.tsx +++ b/src/app/(user)/orders/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { Pagination } from "@/components/shared/pagination"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { UserOrdersTable } from "./_components/user-orders-table"; @@ -16,7 +15,7 @@ export default async function UserOrdersPage({ }: { searchParams: Promise>; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const { orders, total, page, pageSize } = await getUserOrders({ userId: session!.user.id, searchParams: await searchParams, diff --git a/src/app/(user)/store/page.tsx b/src/app/(user)/store/page.tsx index 2b5db72..4320fee 100644 --- a/src/app/(user)/store/page.tsx +++ b/src/app/(user)/store/page.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; +import { getActiveSession } from "@/lib/require-auth"; import Link from "next/link"; -import { getServerSession } from "next-auth"; import { Film, LifeBuoy, Radio } from "lucide-react"; -import { authOptions } from "@/lib/auth"; import { EmptyState, PageShell } from "@/components/shared/page-shell"; import { buttonVariants } from "@/components/ui/button"; import { PendingOrderBanner } from "./pending-order-banner"; @@ -30,7 +29,7 @@ export const metadata: Metadata = { }; 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 proxyPlans = getProxyPlans(plans); const streamingPlans = getStreamingPlans(plans); diff --git a/src/app/(user)/subscriptions/[id]/page.tsx b/src/app/(user)/subscriptions/[id]/page.tsx index 2d0e925..cfdc5b9 100644 --- a/src/app/(user)/subscriptions/[id]/page.tsx +++ b/src/app/(user)/subscriptions/[id]/page.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; +import { getActiveSession } from "@/lib/require-auth"; import { headers } from "next/headers"; 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 { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards"; import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section"; @@ -22,7 +21,7 @@ export default async function UserSubscriptionDetailPage({ }: { params: Promise<{ id: string }>; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const { id } = await params; const requestHeaders = await headers(); const [data, baseUrl] = await Promise.all([ diff --git a/src/app/(user)/subscriptions/page.tsx b/src/app/(user)/subscriptions/page.tsx index 9847d4a..9aef112 100644 --- a/src/app/(user)/subscriptions/page.tsx +++ b/src/app/(user)/subscriptions/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { getActiveSubscriptions, @@ -22,7 +21,7 @@ export const metadata: Metadata = { }; export default async function SubscriptionsPage() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const [subs, baseUrl] = await Promise.all([ getUserSubscriptions(session!.user.id), getSubscriptionBaseUrl(), diff --git a/src/app/(user)/support/[id]/page.tsx b/src/app/(user)/support/[id]/page.tsx index 90f0391..42c7da3 100644 --- a/src/app/(user)/support/[id]/page.tsx +++ b/src/app/(user)/support/[id]/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; +import { getActiveSession } from "@/lib/require-auth"; import { notFound } from "next/navigation"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { SupportTicketPriorityBadge, @@ -23,7 +22,7 @@ export default async function SupportTicketDetailPage({ }: { params: Promise<{ id: string }>; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const { id } = await params; const ticket = await getUserSupportTicketDetail({ ticketId: id, diff --git a/src/app/(user)/support/page.tsx b/src/app/(user)/support/page.tsx index ad45e20..fdb888d 100644 --- a/src/app/(user)/support/page.tsx +++ b/src/app/(user)/support/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { prisma } from "@/lib/prisma"; import { getAppConfig } from "@/services/app-config"; @@ -19,7 +18,7 @@ export default async function SupportPage({ }: { searchParams: Promise>; }) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); const resolvedSearchParams = await searchParams; const riskEventId = typeof resolvedSearchParams.riskEventId === "string" ? resolvedSearchParams.riskEventId : ""; const [tickets, openTicketCount, config, riskEvent] = await Promise.all([ diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index e582897..8343f20 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -7,6 +7,7 @@ import { verifyTurnstile } from "@/lib/turnstile"; import { rateLimit } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/request-context"; import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email"; +import { decryptIfEncrypted } from "@/lib/crypto"; const schema = z.object({ email: z.string().email("邮箱格式不正确"), @@ -58,8 +59,11 @@ export async function POST(req: Request) { ); } - if (config.turnstileSecretKey) { - if (!turnstileToken || !(await verifyTurnstile(turnstileToken, config.turnstileSecretKey))) { + const turnstileSecretKey = config.turnstileSecretKey + ? decryptIfEncrypted(config.turnstileSecretKey) + : ""; + if (turnstileSecretKey) { + if (!turnstileToken || !(await verifyTurnstile(turnstileToken, turnstileSecretKey))) { return NextResponse.json({ error: "人机验证失败:Turnstile token 缺失、已过期或校验未通过" }, { status: 403 }); } } diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index e870aad..217a6c6 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,10 +1,9 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { jsonError, jsonOk } from "@/lib/api-response"; import { getUserNotifications } from "@/app/(user)/notifications/notifications-data"; export async function GET() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) return jsonError("未登录", { status: 401 }); const data = await getUserNotifications(session.user.id); diff --git a/src/app/api/payment/create/route.ts b/src/app/api/payment/create/route.ts index 161a5a2..bc1473e 100644 --- a/src/app/api/payment/create/route.ts +++ b/src/app/api/payment/create/route.ts @@ -1,7 +1,6 @@ -import { getServerSession } from "next-auth"; import { z } from "zod"; -import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { getActiveSession } from "@/lib/require-auth"; import { jsonError, jsonOk } from "@/lib/api-response"; import { getPaymentAdapter } from "@/services/payment/factory"; import { rateLimit } from "@/lib/rate-limit"; @@ -28,7 +27,7 @@ function isSafePaymentUrl(value: string | undefined) { export async function POST(req: Request) { try { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { return jsonError("未登录", { status: 401 }); } diff --git a/src/app/api/payment/order/[orderId]/route.ts b/src/app/api/payment/order/[orderId]/route.ts index 468055b..299784a 100644 --- a/src/app/api/payment/order/[orderId]/route.ts +++ b/src/app/api/payment/order/[orderId]/route.ts @@ -1,13 +1,12 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { getActiveSession } from "@/lib/require-auth"; import { jsonError, jsonOk } from "@/lib/api-response"; export async function GET( _req: Request, { params }: { params: Promise<{ orderId: string }> }, ) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { return jsonError("未登录", { status: 401 }); } diff --git a/src/app/api/payment/query/[tradeNo]/route.ts b/src/app/api/payment/query/[tradeNo]/route.ts index 4572097..01717c2 100644 --- a/src/app/api/payment/query/[tradeNo]/route.ts +++ b/src/app/api/payment/query/[tradeNo]/route.ts @@ -1,6 +1,5 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { getActiveSession } from "@/lib/require-auth"; import { jsonError, jsonOk } from "@/lib/api-response"; import { getPaymentAdapter } from "@/services/payment/factory"; import { handleVerifiedPaymentSuccess } from "@/services/payment/process"; @@ -9,7 +8,7 @@ export async function GET( _req: Request, { params }: { params: Promise<{ tradeNo: string }> } ) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { return jsonError("未登录", { status: 401 }); } diff --git a/src/app/api/support/attachments/[id]/route.ts b/src/app/api/support/attachments/[id]/route.ts index 2df6740..1dfee47 100644 --- a/src/app/api/support/attachments/[id]/route.ts +++ b/src/app/api/support/attachments/[id]/route.ts @@ -1,12 +1,11 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; +import { getActiveSession } from "@/lib/require-auth"; import { prisma } from "@/lib/prisma"; export async function GET( req: Request, { params }: { params: Promise<{ id: string }> }, ) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { return new Response("附件访问失败:你尚未登录,请登录后重新打开附件", { status: 401 }); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d96535..d590e3a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; +import { getActiveSession } from "@/lib/require-auth"; export const metadata: Metadata = { title: "首页", @@ -9,7 +8,7 @@ export const metadata: Metadata = { }; export default async function Home() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) redirect("/login"); if (session.user.role === "ADMIN") redirect("/admin/dashboard"); redirect("/dashboard"); diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts index dfa5a95..1d4ec90 100644 --- a/src/lib/admin-api.ts +++ b/src/lib/admin-api.ts @@ -1,9 +1,8 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "./auth"; import { jsonError } from "./api-response"; +import { getActiveSession } from "./require-auth"; export async function requireAdminApiSession() { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session || session.user.role !== "ADMIN") { return { session: null, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 01f9fda..2b990a0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,7 @@ import CredentialsProvider from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { prisma } from "./prisma"; import { verifyTurnstile } from "./turnstile"; +import { decryptIfEncrypted } from "./crypto"; export const authOptions: NextAuthOptions = { providers: [ @@ -17,9 +18,12 @@ export const authOptions: NextAuthOptions = { if (!credentials?.email || !credentials?.password) return null; 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; - if (!token || !(await verifyTurnstile(token, config.turnstileSecretKey))) { + if (!token || !(await verifyTurnstile(token, turnstileSecretKey))) { return null; } } diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index c18d189..f52ca17 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -18,6 +18,22 @@ export function encrypt(text: string): string { 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 { const parts = data.split(":"); if (parts.length !== 3) { @@ -28,3 +44,7 @@ export function decrypt(data: string): string { decipher.setAuthTag(Buffer.from(authTagHex, "hex")); return decipher.update(Buffer.from(encryptedHex, "hex")) + decipher.final("utf8"); } + +export function decryptIfEncrypted(data: string): string { + return isEncryptedValue(data) ? decrypt(data) : data; +} diff --git a/src/lib/require-auth.ts b/src/lib/require-auth.ts index d1b83c9..149dffd 100644 --- a/src/lib/require-auth.ts +++ b/src/lib/require-auth.ts @@ -1,9 +1,29 @@ import { getServerSession } from "next-auth"; import { authOptions } from "./auth"; +import { prisma } from "./prisma"; import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review"; -export async function requireAdmin() { +export async function getActiveSession() { 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") { throw new Error("无权限"); } @@ -11,7 +31,7 @@ export async function requireAdmin() { } export async function requireAuth(options: { allowDuringRiskRestriction?: boolean } = {}) { - const session = await getServerSession(authOptions); + const session = await getActiveSession(); if (!session) { throw new Error("未登录"); } diff --git a/src/services/email.ts b/src/services/email.ts index 2d5513b..cb26ca3 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -242,13 +242,19 @@ export async function verifyEmailToken(token: string) { if (record.purpose === "REGISTRATION_VERIFY") { if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" }; - await prisma.user.update({ - where: { id: record.userId }, + const result = await prisma.user.updateMany({ + where: { + id: record.userId, + status: { in: ["PENDING_EMAIL", "ACTIVE"] }, + }, data: { emailVerifiedAt: new Date(), status: "ACTIVE", }, }); + if (result.count !== 1) { + return { ok: false as const, message: "账户当前状态不允许完成验证,请联系管理员" }; + } return { ok: true as const, message: "邮箱验证完成,现在可以登录账户。" }; } diff --git a/src/services/node-inbound-sanitize.ts b/src/services/node-inbound-sanitize.ts new file mode 100644 index 0000000..bc1acc7 --- /dev/null +++ b/src/services/node-inbound-sanitize.ts @@ -0,0 +1,28 @@ +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : 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 = {}; + if (network) sanitized.network = network; + if (security) sanitized.security = security; + + return Object.keys(sanitized).length > 0 ? sanitized : null; +} diff --git a/src/services/node-panel/factory.ts b/src/services/node-panel/factory.ts index 324f39f..b5f163a 100644 --- a/src/services/node-panel/factory.ts +++ b/src/services/node-panel/factory.ts @@ -1,8 +1,11 @@ import type { NodeServer } from "@prisma/client"; +import { decryptIfEncrypted } from "@/lib/crypto"; import type { NodePanelAdapter } from "./adapter"; import { ThreeXUIAdapter } from "./three-x-ui"; -export function createPanelAdapter(server: NodeServer): NodePanelAdapter { +type PanelServerConfig = Pick; + +export function createPanelAdapter(server: PanelServerConfig): NodePanelAdapter { const panelType = server.panelType ?? "3x-ui"; if (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) { 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), + ); } diff --git a/src/services/payment/catalog.ts b/src/services/payment/catalog.ts index 772c681..962fd9e 100644 --- a/src/services/payment/catalog.ts +++ b/src/services/payment/catalog.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { decryptIfEncrypted, encrypt, isEncryptedValue } from "@/lib/crypto"; export interface PaymentConfigField { key: string; @@ -107,6 +108,78 @@ function normalizeConfig(config: Record): Record field.secret) + .map((field) => field.key), + ); +} + +export function decryptPaymentConfigForUse( + provider: string, + config: Record, +): Record { + 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, + currentConfig?: Record | null, +): Record { + 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, +): Record { + const normalized = normalizeConfig(config); + for (const key of getSecretFieldKeys(provider)) { + normalized[key] = ""; + } + return normalized; +} + +export function getPaymentSecretConfiguredState( + provider: string, + config: Record, +): Record { + const normalized = normalizeConfig(config); + const result: Record = {}; + for (const key of getSecretFieldKeys(provider)) { + result[key] = Boolean(normalized[key]); + } + return result; +} + export function getPaymentProviderDefinition(provider: string) { return PAYMENT_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? null; } diff --git a/src/services/payment/factory.ts b/src/services/payment/factory.ts index 9136310..5a1e2a9 100644 --- a/src/services/payment/factory.ts +++ b/src/services/payment/factory.ts @@ -4,6 +4,7 @@ import { EasyPayAdapter, type EasyPayConfig } from "./epay"; import { AlipayF2FAdapter, type AlipayF2FConfig } from "./alipay-f2f"; import { UsdtTrc20Adapter, type UsdtTrc20Config } from "./usdt-trc20"; import { + decryptPaymentConfigForUse, getPaymentProviderName, parsePaymentConfig, } from "./catalog"; @@ -22,7 +23,7 @@ export async function getPaymentAdapter(provider: string): Promise, + decryptPaymentConfigForUse(realProvider, config.config as Record), ); switch (realProvider) { diff --git a/src/services/subscription.ts b/src/services/subscription.ts index 1243e4f..cfdebd6 100644 --- a/src/services/subscription.ts +++ b/src/services/subscription.ts @@ -98,9 +98,9 @@ export function buildSubscriptionUserInfo(stats: SubscriptionTrafficStats | null } 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) { - throw new Error("缺少订阅链接签名密钥,请配置 NEXTAUTH_SECRET"); + throw new Error("缺少订阅链接签名密钥,请配置 NEXTAUTH_SECRET 或 AUTH_SECRET"); } return secret; }