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:
@@ -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<NodeDetail> {
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -22,10 +22,17 @@ interface Props {
|
||||
provider: string;
|
||||
fields: Field[];
|
||||
currentConfig?: Record<string, string>;
|
||||
secretConfigured?: Record<string, 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 [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
|
||||
<Input
|
||||
name={field.key}
|
||||
type={field.secret ? "password" : "text"}
|
||||
placeholder={field.placeholder}
|
||||
defaultValue={currentConfig?.[field.key] || ""}
|
||||
placeholder={field.secret && secretConfigured[field.key] ? "留空保持不变" : field.placeholder}
|
||||
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function PaymentsPage() {
|
||||
title="支付配置"
|
||||
/>
|
||||
<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">
|
||||
<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">
|
||||
@@ -37,7 +37,8 @@ export default async function PaymentsPage() {
|
||||
<PaymentConfigForm
|
||||
provider={provider.id}
|
||||
fields={provider.fields}
|
||||
currentConfig={config?.config as Record<string, string> | undefined}
|
||||
currentConfig={config?.config}
|
||||
secretConfigured={secretConfigured}
|
||||
enabled={config?.enabled ?? false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -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<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),
|
||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||
turnstileSiteKey: config.turnstileSiteKey,
|
||||
turnstileSecretKey: config.turnstileSecretKey,
|
||||
turnstileSecretConfigured: Boolean(config.turnstileSecretKey),
|
||||
smtpEnabled: config.smtpEnabled,
|
||||
smtpHost: config.smtpHost,
|
||||
smtpPort: config.smtpPort,
|
||||
|
||||
@@ -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:
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -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 (
|
||||
<article className="surface-card overflow-hidden rounded-xl">
|
||||
<div className="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(25rem,1.2fr)_minmax(18rem,0.65fr)]">
|
||||
<section className="space-y-5 p-5">
|
||||
<span className="min-w-0 rounded-lg border border-border/70 bg-muted/20 px-2.5 py-1.5">
|
||||
<span className="block text-[0.68rem] leading-none text-muted-foreground">{label}</span>
|
||||
<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">
|
||||
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
|
||||
{reasonLabel(event.reason)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold leading-6">{event.message}</p>
|
||||
<p className="break-all font-mono text-xs text-muted-foreground">最近 IP:{event.ip || "未知 IP"}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">关联用户</p>
|
||||
<UserBlock event={event} />
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="line-clamp-2 text-sm font-semibold leading-6">{event.message}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{userLabel} · {scopeLabel} · 最近 IP:{event.ip || "未知 IP"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">影响范围</p>
|
||||
<EventScope event={event} />
|
||||
<div className="grid grid-cols-4 gap-2 text-right sm:flex sm:justify-end">
|
||||
<RiskStat label="国家" value={summary.uniqueCountryCount} />
|
||||
<RiskStat label="省区" value={summary.uniqueRegionCount} />
|
||||
<RiskStat label="城市" value={summary.uniqueCityCount} />
|
||||
<RiskStat label="IP" value={summary.uniqueIpCount} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="border-y border-border/70 bg-muted/10 p-5 xl:border-x xl:border-y-0">
|
||||
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
|
||||
</section>
|
||||
<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">
|
||||
<span className="hidden sm:inline">详情</span>
|
||||
<ChevronDown className="size-4 transition-transform group-open:rotate-180" />
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<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 className="border-t border-border/70">
|
||||
<div className="grid xl:grid-cols-[minmax(0,0.85fr)_minmax(24rem,1.15fr)_minmax(18rem,0.7fr)]">
|
||||
<section className="space-y-5 p-5">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">关联用户</p>
|
||||
<UserBlock event={event} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">影响范围</p>
|
||||
<EventScope event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-y border-border/70 bg-muted/10 p-5 xl:border-x xl:border-y-0">
|
||||
<SubscriptionRiskGeoDetails summary={summary} />
|
||||
</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>
|
||||
</article>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user