feat: add subscription access risk controls

This commit is contained in:
JetSprow
2026-04-29 14:26:25 +10:00
parent a0c1a28f5a
commit 17163286a6
18 changed files with 1886 additions and 27 deletions

View File

@@ -51,6 +51,7 @@ export default async function AuditLogsPage({
{ label: "service.", value: "service." },
{ label: "node.", value: "node." },
{ label: "task.", value: "task." },
{ label: "risk.", value: "risk." },
],
},
]}

View File

@@ -0,0 +1,173 @@
import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserStatusBadge,
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { formatDate, formatDateShort } from "@/lib/utils";
import type { SubscriptionRiskEventRow } from "../risk-data";
function kindLabel(kind: SubscriptionRiskEvent["kind"]) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
}
}
function reviewStatusLabel(status: SubscriptionRiskEvent["reviewStatus"]) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.subscription) {
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="info">{kindLabel(event.kind)}</StatusBadge>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
);
}
return (
<div className="space-y-1">
<Link href={`/admin/subscriptions/${event.subscription.id}`} className="font-medium hover:underline">
{event.subscription.plan.name}
</Link>
<div className="flex flex-wrap items-center gap-2">
<SubscriptionTypeBadge type={event.subscription.plan.type} />
<SubscriptionStatusBadge status={event.subscription.status} />
</div>
<p className="text-xs text-muted-foreground">{formatDateShort(event.subscription.endDate)}</p>
</div>
);
}
function UserCell({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.user) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="space-y-1">
<p className="max-w-56 break-all font-medium">{event.user.email}</p>
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<UserStatusBadge status={event.user.status} />
</div>
);
}
export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) {
return (
<DataTableShell
isEmpty={events.length === 0}
emptyTitle="暂无订阅风控事件"
emptyDescription="订阅链接出现跨城市或跨省份访问异常后,会在这里进入人工跟进队列。"
>
<DataTable aria-label="订阅风控事件" className="min-w-[1180px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>/IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{events.map((event) => (
<DataTableRow key={event.id}>
<DataTableCell className="whitespace-nowrap text-muted-foreground">
{formatDate(event.createdAt)}
</DataTableCell>
<DataTableCell>
<UserCell event={event} />
</DataTableCell>
<DataTableCell>
<EventScope event={event} />
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
{reasonLabel(event.reason)}
</StatusBadge>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">{event.message}</p>
</div>
</DataTableCell>
<DataTableCell>
<div className="space-y-1 text-sm">
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
<p className="text-xs text-muted-foreground">
{event.cityCount} · / {event.regionCount} · {event.countryCount}
</p>
</div>
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
{reviewStatusLabel(event.reviewStatus)}
</StatusBadge>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="max-w-52 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
{event.reviewedAt && <p>{formatDate(event.reviewedAt)}</p>}
{event.reviewNote && <p className="mt-1 line-clamp-3 whitespace-pre-wrap text-foreground/70">{event.reviewNote}</p>}
</div>
)}
</div>
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={event.canRestoreSubscription}
/>
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
);
}

View File

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { AdminFilterBar } from "@/components/admin/filter-bar";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { Pagination } from "@/components/shared/pagination";
import { SubscriptionRiskTable } from "./_components/subscription-risk-table";
import { getSubscriptionRiskEvents } from "./risk-data";
export const metadata: Metadata = {
title: "订阅风控",
description: "查看订阅访问异常、关联用户和人工处理状态。",
};
export default async function AdminSubscriptionRiskPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { events, total, page, pageSize, filters } = await getSubscriptionRiskEvents(await searchParams);
return (
<PageShell>
<PageHeader
eyebrow="商品与订单"
title="订阅风控"
description="订阅链接跨城市或跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
/>
<AdminFilterBar
q={filters.q}
searchPlaceholder="搜索用户邮箱、昵称、套餐、IP、事件说明"
selects={[
{
name: "status",
value: filters.status,
options: [
{ label: "全部处理状态", value: "" },
{ label: "待处理", value: "OPEN" },
{ label: "已确认", value: "ACKNOWLEDGED" },
{ label: "已解决", value: "RESOLVED" },
],
},
{
name: "level",
value: filters.level,
options: [
{ label: "全部风险级别", value: "" },
{ label: "警告", value: "WARNING" },
{ label: "已暂停", value: "SUSPENDED" },
],
},
{
name: "kind",
value: filters.kind,
options: [
{ label: "全部订阅范围", value: "" },
{ label: "单订阅", value: "SINGLE" },
{ label: "总订阅", value: "AGGREGATE" },
],
},
]}
/>
<SubscriptionRiskTable events={events} />
<Pagination total={total} pageSize={pageSize} page={page} />
</PageShell>
);
}

View File

@@ -0,0 +1,153 @@
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
type RiskUser = {
id: string;
email: string;
name: string | null;
status: "ACTIVE" | "DISABLED" | "BANNED";
};
type RiskSubscription = {
id: string;
status: "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED";
endDate: Date;
plan: {
name: string;
type: "PROXY" | "STREAMING";
};
user: RiskUser;
};
export type SubscriptionRiskEventRow = SubscriptionRiskEvent & {
user: RiskUser | null;
subscription: RiskSubscription | null;
canRestoreSubscription: boolean;
};
async function searchRelatedIds(q: string) {
if (!q) return { userIds: [] as string[], subscriptionIds: [] as string[] };
const [users, subscriptions] = await Promise.all([
prisma.user.findMany({
where: {
OR: [
{ email: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
],
},
select: { id: true },
take: 100,
}),
prisma.userSubscription.findMany({
where: {
OR: [
{ id: q },
{ user: { email: { contains: q, mode: "insensitive" } } },
{ user: { name: { contains: q, mode: "insensitive" } } },
{ plan: { name: { contains: q, mode: "insensitive" } } },
],
},
select: { id: true },
take: 100,
}),
]);
return {
userIds: users.map((user) => user.id),
subscriptionIds: subscriptions.map((subscription) => subscription.id),
};
}
export async function getSubscriptionRiskEvents(
searchParams: Record<string, string | string[] | undefined>,
) {
const { page, skip, pageSize } = parsePage(searchParams);
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
const level = typeof searchParams.level === "string" ? searchParams.level : "";
const status = typeof searchParams.status === "string" ? searchParams.status : "";
const kind = typeof searchParams.kind === "string" ? searchParams.kind : "";
const { userIds, subscriptionIds } = await searchRelatedIds(q);
const where = {
...(level ? { level: level as "WARNING" | "SUSPENDED" } : {}),
...(status ? { reviewStatus: status as "OPEN" | "ACKNOWLEDGED" | "RESOLVED" } : {}),
...(kind ? { kind: kind as "SINGLE" | "AGGREGATE" } : {}),
...(q
? {
OR: [
{ id: q },
{ userId: q },
{ subscriptionId: q },
{ ip: { contains: q, mode: "insensitive" as const } },
{ message: { contains: q, mode: "insensitive" as const } },
...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []),
...(subscriptionIds.length > 0 ? [{ subscriptionId: { in: subscriptionIds } }] : []),
],
}
: {}),
} satisfies Prisma.SubscriptionRiskEventWhereInput;
const [events, total] = await Promise.all([
prisma.subscriptionRiskEvent.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: pageSize,
}),
prisma.subscriptionRiskEvent.count({ where }),
]);
const eventUserIds = Array.from(new Set(events.map((event) => event.userId).filter(Boolean))) as string[];
const eventSubscriptionIds = Array.from(new Set(events.map((event) => event.subscriptionId).filter(Boolean))) as string[];
const [users, subscriptions] = await Promise.all([
eventUserIds.length > 0
? prisma.user.findMany({
where: { id: { in: eventUserIds } },
select: { id: true, email: true, name: true, status: true },
})
: Promise.resolve([]),
eventSubscriptionIds.length > 0
? prisma.userSubscription.findMany({
where: { id: { in: eventSubscriptionIds } },
select: {
id: true,
status: true,
endDate: true,
plan: { select: { name: true, type: true } },
user: { select: { id: true, email: true, name: true, status: true } },
},
})
: Promise.resolve([]),
]);
const userById = new Map(users.map((user) => [user.id, user]));
const subscriptionById = new Map(subscriptions.map((subscription) => [subscription.id, subscription]));
const now = new Date();
const rows: SubscriptionRiskEventRow[] = events.map((event) => {
const subscription = event.subscriptionId ? subscriptionById.get(event.subscriptionId) ?? null : null;
const user = subscription?.user ?? (event.userId ? userById.get(event.userId) ?? null : null);
return {
...event,
user,
subscription,
canRestoreSubscription: Boolean(
subscription
&& subscription.status === "SUSPENDED"
&& subscription.endDate > now
&& event.reviewStatus !== "RESOLVED",
),
};
});
return {
events: rows,
total,
page,
pageSize,
filters: { q, level, status, kind },
};
}

View File

@@ -0,0 +1,224 @@
import Link from "next/link";
import type {
SubscriptionAccessLog,
SubscriptionRiskEvent,
SubscriptionStatus,
SubscriptionType,
UserStatus,
} from "@prisma/client";
import { AlertTriangle, ShieldCheck, UserRound } from "lucide-react";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserStatusBadge,
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { formatDate, formatDateShort } from "@/lib/utils";
interface RiskOwner {
id: string;
email: string;
name: string | null;
status: UserStatus;
}
interface RiskSubscription {
id: string;
status: SubscriptionStatus;
endDate: Date;
plan: {
name: string;
type: SubscriptionType;
};
}
function formatLocation(item: Pick<SubscriptionAccessLog, "country" | "region" | "regionCode" | "city">) {
const parts = [item.country, item.region || item.regionCode, item.city].filter(Boolean);
return parts.length > 0 ? parts.join(" / ") : "未知";
}
function kindLabel(kind: SubscriptionAccessLog["kind"] | SubscriptionRiskEvent["kind"]) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
}
}
function reviewStatusLabel(status: SubscriptionRiskEvent["reviewStatus"]) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
function canRestoreFromEvent(event: SubscriptionRiskEvent, subscription: RiskSubscription) {
return event.subscriptionId === subscription.id
&& subscription.status === "SUSPENDED"
&& subscription.endDate > new Date();
}
export function SubscriptionAccessRiskSection({
accessLogs,
riskEvents,
owner,
subscription,
}: {
accessLogs: SubscriptionAccessLog[];
riskEvents: SubscriptionRiskEvent[];
owner: RiskOwner;
subscription: RiskSubscription;
}) {
return (
<section className="surface-card space-y-5 rounded-xl p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-5" />
</span>
<div>
<h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
</div>
</div>
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
</Link>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/70 bg-muted/25 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<UserRound className="size-4 text-primary" />
</div>
<div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="break-all font-medium">{owner.email}</span>
<UserStatusBadge status={owner.status} />
</div>
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{owner.id}</p>
</div>
</div>
<div className="rounded-lg border border-border/70 bg-muted/25 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-primary" />
</div>
<div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{subscription.plan.name}</span>
<SubscriptionTypeBadge type={subscription.plan.type} />
<SubscriptionStatusBadge status={subscription.status} />
</div>
<p className="text-muted-foreground">{formatDateShort(subscription.endDate)}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{subscription.id}</p>
</div>
</div>
</div>
{riskEvents.length > 0 && (
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/8 p-3 text-sm">
<div className="flex items-center gap-2 font-semibold text-amber-700 dark:text-amber-300">
<AlertTriangle className="size-4" />
</div>
<div className="grid gap-2">
{riskEvents.map((event) => (
<div key={event.id} className="rounded-md border border-border/50 bg-background/55 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>{reasonLabel(event.reason)}</StatusBadge>
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)} · {kindLabel(event.kind)}</span>
</div>
<p className="text-sm leading-6 text-foreground/85">{event.message}</p>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="mt-2 rounded-md bg-muted/45 p-2 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p>{event.reviewedByEmail}{event.reviewedAt ? ` · ${formatDate(event.reviewedAt)}` : ""}</p>}
{event.reviewNote && <p className="mt-1 whitespace-pre-wrap text-foreground/75">{event.reviewNote}</p>}
</div>
)}
<div className="mt-3">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
/>
</div>
</div>
))}
</div>
</div>
)}
<DataTableShell
isEmpty={accessLogs.length === 0}
emptyTitle="暂无订阅访问记录"
emptyDescription="用户客户端拉取订阅后,这里会显示最近访问 IP 与地区。"
>
<DataTable aria-label="订阅访问记录" className="min-w-[980px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>User-Agent</DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{accessLogs.map((log) => (
<DataTableRow key={log.id}>
<DataTableCell className="text-muted-foreground">{formatDate(log.createdAt)}</DataTableCell>
<DataTableCell>{kindLabel(log.kind)}</DataTableCell>
<DataTableCell className="font-mono text-xs">{log.ip}</DataTableCell>
<DataTableCell>{formatLocation(log)}</DataTableCell>
<DataTableCell>
<StatusBadge tone={log.allowed ? "success" : "warning"}>
{log.allowed ? "已放行" : log.reason || "已拦截"}
</StatusBadge>
</DataTableCell>
<DataTableCell className="max-w-sm truncate text-muted-foreground">
{log.userAgent || "—"}
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</section>
);
}

View File

@@ -5,6 +5,8 @@ import { SubscriptionDetailCards } from "@/components/subscriptions/subscription
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
import { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
import { AdminSubscriptionActions } from "../subscription-actions";
import { SubscriptionAccessRiskSection } from "./_components/subscription-access-risk-section";
export const metadata: Metadata = {
title: "订阅详情",
@@ -23,7 +25,7 @@ export default async function AdminSubscriptionDetailPage({
notFound();
}
const { subscription, auditLogs, trafficLogs } = data;
const { subscription, auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices } = data;
return (
<PageShell>
@@ -31,8 +33,22 @@ export default async function AdminSubscriptionDetailPage({
eyebrow="订阅详情"
title={subscription.plan.name}
description={subscription.user.email}
actions={
<AdminSubscriptionActions
subscriptionId={subscription.id}
status={subscription.status}
type={subscription.plan.type}
streamingServices={streamingServices}
/>
}
/>
<SubscriptionDetailCards subscription={subscription} showClientEmail />
<SubscriptionAccessRiskSection
accessLogs={accessLogs}
riskEvents={riskEvents}
owner={subscription.user}
subscription={subscription}
/>
<SubscriptionTimelineSection logs={auditLogs} />
<TrafficLogList logs={trafficLogs} />
</PageShell>

View File

@@ -34,7 +34,7 @@ export async function getAdminSubscriptionDetail(subscriptionId: string) {
return null;
}
const [auditLogs, trafficLogs] = await Promise.all([
const [auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices] = await Promise.all([
prisma.auditLog.findMany({
where: {
targetType: "UserSubscription",
@@ -52,7 +52,37 @@ export async function getAdminSubscriptionDetail(subscriptionId: string) {
take: 30,
})
: Promise.resolve([]),
prisma.subscriptionAccessLog.findMany({
where: {
OR: [
{ subscriptionId: subscription.id },
{ userId: subscription.userId, kind: "AGGREGATE" },
],
},
orderBy: { createdAt: "desc" },
take: 50,
}),
prisma.subscriptionRiskEvent.findMany({
where: {
OR: [
{ subscriptionId: subscription.id },
{ userId: subscription.userId, kind: "AGGREGATE" },
],
},
orderBy: { createdAt: "desc" },
take: 10,
}),
prisma.streamingService.findMany({
where: { isActive: true },
select: {
id: true,
name: true,
usedSlots: true,
maxSlots: true,
},
orderBy: { name: "asc" },
}),
]);
return { subscription, auditLogs, trafficLogs };
return { subscription, auditLogs, trafficLogs, accessLogs, riskEvents, streamingServices };
}

View File

@@ -5,6 +5,7 @@ import { z } from "zod";
import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context";
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
const schema = z.object({
@@ -16,9 +17,7 @@ const schema = z.object({
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("x-real-ip")?.trim()
|| "unknown";
const ip = getClientIp(req.headers);
const { success, remaining } = await rateLimit(`ratelimit:register:${ip}`, 5, 60);
if (!success) {
return NextResponse.json(

View File

@@ -1,6 +1,17 @@
import { NextResponse } from "next/server";
import { generateSubscriptionContent } from "@/services/subscription";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
const SUBSCRIPTION_TOKEN_LIMIT = 60;
const SUBSCRIPTION_IP_LIMIT = 180;
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
function jsonError(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
export async function GET(
req: Request,
@@ -9,21 +20,101 @@ export async function GET(
const { id } = await params;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 401 });
}
const context = getClientRequestContext(req.headers);
const sub = await prisma.userSubscription.findUnique({
where: { id },
select: {
id: true,
userId: true,
downloadToken: true,
status: true,
plan: { select: { type: true } },
},
});
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
SUBSCRIPTION_IP_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (!token) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "missing_token",
});
return jsonError("Missing token", 401);
}
if (!sub || sub.downloadToken !== token) {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "invalid_token",
});
return jsonError("Invalid token", 401);
}
const tokenLimit = await rateLimit(
`ratelimit:subscription:single:${sub.id}`,
SUBSCRIPTION_TOKEN_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (sub.status !== "ACTIVE") {
return NextResponse.json({ error: "Subscription inactive" }, { status: 403 });
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
allowed: false,
reason: "subscription_inactive",
});
return jsonError("Subscription inactive", 403);
}
const risk = await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
evaluateRisk: sub.plan.type === "PROXY",
});
if (risk.suspended) {
return jsonError("Subscription suspended by risk control", 403);
}
const content = await generateSubscriptionContent(id);
@@ -31,6 +122,7 @@ export async function GET(
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="jboard-sub.txt"`,
"Cache-Control": "no-store",
},
});
}

View File

@@ -3,18 +3,105 @@ import {
generateAggregateSubscriptionContent,
verifyAggregateSubscriptionToken,
} from "@/services/subscription";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
const AGGREGATE_TOKEN_LIMIT = 60;
const SUBSCRIPTION_IP_LIMIT = 180;
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
function jsonError(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
export async function GET(req: Request) {
const url = new URL(req.url);
const userId = url.searchParams.get("userId") ?? url.searchParams.get("user");
const token = url.searchParams.get("token");
const context = getClientRequestContext(req.headers);
if (!userId || !token) {
return NextResponse.json({ error: "Missing subscription token" }, { status: 401 });
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
SUBSCRIPTION_IP_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (!verifyAggregateSubscriptionToken(userId, token)) {
return NextResponse.json({ error: "Invalid subscription token" }, { status: 401 });
if (!userId || !token) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "missing_subscription_token",
});
return jsonError("Missing subscription token", 401);
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, status: true },
});
if (!user || !verifyAggregateSubscriptionToken(userId, token)) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "invalid_subscription_token",
});
return jsonError("Invalid subscription token", 401);
}
const tokenLimit = await rateLimit(
`ratelimit:subscription:aggregate:${userId}`,
AGGREGATE_TOKEN_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
if (user.status !== "ACTIVE") {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "user_inactive",
});
return jsonError("User inactive", 403);
}
const risk = await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
});
if (risk.suspended) {
return jsonError("Subscriptions suspended by risk control", 403);
}
const content = await generateAggregateSubscriptionContent(userId);