761 lines
24 KiB
TypeScript
761 lines
24 KiB
TypeScript
import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server";
|
||
import { getToken } from "next-auth/jwt";
|
||
import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist";
|
||
import {
|
||
checkRateLimit,
|
||
checkRateLimitByIdentifier,
|
||
rateLimiters,
|
||
} from "@/lib/security/rate-limiter";
|
||
import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor";
|
||
|
||
type AccessLogPayload = {
|
||
eventType?: string;
|
||
ip?: string | null;
|
||
userId?: string | null;
|
||
userEmail?: string | null;
|
||
userRole?: string | null;
|
||
method?: string | null;
|
||
path?: string | null;
|
||
routeGroup?: string | null;
|
||
statusCode?: number | null;
|
||
outcome?: string | null;
|
||
severity?: "info" | "watch" | "high" | "critical";
|
||
reasons?: string[];
|
||
userAgent?: string | null;
|
||
referer?: string | null;
|
||
metadata?: Record<string, unknown>;
|
||
occurredAt?: string;
|
||
};
|
||
|
||
type ManualIpBlockCheckResult = {
|
||
blocked: boolean;
|
||
entry?: {
|
||
id: string;
|
||
ip: string;
|
||
reason?: string | null;
|
||
expiresAt?: string | null;
|
||
} | null;
|
||
};
|
||
|
||
const manualIpBlockCache = new Map<
|
||
string,
|
||
{ expiresAt: number; result: ManualIpBlockCheckResult }
|
||
>();
|
||
|
||
function isHighFrequencyReadApi(pathname: string, method: string) {
|
||
if (method !== "GET") return false;
|
||
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
|
||
if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true;
|
||
|
||
return [
|
||
"/api/models",
|
||
"/api/canvas",
|
||
"/api/canvas-skill-tags",
|
||
"/api/nav-links",
|
||
"/api/site-settings",
|
||
"/api/messages",
|
||
"/api/history",
|
||
"/api/vote",
|
||
"/api/video-tasks",
|
||
"/api/user/image-stats",
|
||
"/api/tutorial-videos",
|
||
"/api/user/membership-summary",
|
||
"/api/user/account-center",
|
||
].includes(pathname);
|
||
}
|
||
|
||
function getAccessRouteGroup(pathname: string) {
|
||
if (pathname.startsWith("/api/auth")) return "auth";
|
||
if (pathname.startsWith("/api/admin")) return "admin-api";
|
||
if (pathname.startsWith("/api/")) return "api";
|
||
if (pathname.startsWith("/settings")) return "admin-page";
|
||
if (pathname === "/login" || pathname === "/register") return "auth-page";
|
||
if (pathname.startsWith("/canvas")) return "canvas-page";
|
||
return "page";
|
||
}
|
||
|
||
function shouldRecordAccessLog(
|
||
pathname: string,
|
||
method: string,
|
||
payload: AccessLogPayload
|
||
) {
|
||
if (process.env.ACCESS_LOG_ENABLED === "false") return false;
|
||
if (pathname === "/api/internal/access-log") return false;
|
||
if (payload.severity && payload.severity !== "info") return true;
|
||
if (payload.outcome && payload.outcome !== "allowed") return true;
|
||
if (isRoutineAuthRead(pathname, method)) return false;
|
||
if (isHighFrequencyReadApi(pathname, method)) return false;
|
||
if (pathname.startsWith("/api/auth")) return true;
|
||
if (pathname === "/login" || pathname === "/register") return true;
|
||
if (pathname.startsWith("/settings")) return true;
|
||
if (pathname.startsWith("/api/admin")) return true;
|
||
if (pathname.startsWith("/api/")) return method !== "GET";
|
||
return false;
|
||
}
|
||
|
||
function isInternalAccessPath(pathname: string) {
|
||
return (
|
||
pathname === "/api/internal/access-log" ||
|
||
pathname === "/api/internal/ip-blocklist/check"
|
||
);
|
||
}
|
||
|
||
function isLoopbackIp(ip: string | null) {
|
||
return (
|
||
ip === "::1" ||
|
||
ip === "127.0.0.1" ||
|
||
ip === "::ffff:127.0.0.1" ||
|
||
ip === "localhost"
|
||
);
|
||
}
|
||
|
||
function isRoutineAuthRead(pathname: string, method: string) {
|
||
if (method !== "GET") return false;
|
||
return (
|
||
pathname === "/api/auth/session" ||
|
||
pathname === "/api/auth/validate-session" ||
|
||
pathname === "/api/auth/providers" ||
|
||
pathname === "/api/auth/csrf"
|
||
);
|
||
}
|
||
|
||
function shouldBypassLocalAuthSessionRateLimit(pathname: string, ip: string | null) {
|
||
return (
|
||
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||
isLoopbackIp(ip) &&
|
||
(pathname === "/api/auth/session" ||
|
||
pathname === "/api/auth/validate-session")
|
||
);
|
||
}
|
||
|
||
function shouldBypassLocalCanvasPageAuth(pathname: string, ip: string | null) {
|
||
return (
|
||
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||
isLoopbackIp(ip) &&
|
||
pathname.startsWith("/canvas")
|
||
);
|
||
}
|
||
|
||
function shouldBypassLocalCanvasE2E(pathname: string, ip: string | null) {
|
||
if (process.env.CANVAS_E2E_LOCAL !== "true" || !isLoopbackIp(ip)) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
pathname.startsWith("/canvas") ||
|
||
/^\/api\/canvas(?:\/|$)/.test(pathname) ||
|
||
pathname === "/api/models" ||
|
||
pathname === "/api/site-settings" ||
|
||
pathname === "/api/user/membership-summary" ||
|
||
pathname === "/api/video-canvas/config" ||
|
||
pathname === "/api/favicon" ||
|
||
pathname === "/api/auth/session" ||
|
||
pathname === "/api/auth/validate-session"
|
||
);
|
||
}
|
||
|
||
async function checkManualIpBlock(
|
||
request: NextRequest,
|
||
ip: string | null
|
||
): Promise<ManualIpBlockCheckResult> {
|
||
if (!ip || ip === "unknown") return { blocked: false };
|
||
|
||
const now = Date.now();
|
||
const cached = manualIpBlockCache.get(ip);
|
||
if (cached && cached.expiresAt > now) {
|
||
return cached.result;
|
||
}
|
||
|
||
const secret = process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||
if (!secret) return { blocked: false };
|
||
|
||
try {
|
||
const response = await fetch(
|
||
new URL("/api/internal/ip-blocklist/check", request.url),
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-access-log-internal-secret": secret,
|
||
},
|
||
body: JSON.stringify({ ip }),
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
return { blocked: false };
|
||
}
|
||
|
||
const result = (await response.json().catch(() => null)) as
|
||
| ManualIpBlockCheckResult
|
||
| null;
|
||
const normalizedResult = result?.blocked
|
||
? result
|
||
: { blocked: false };
|
||
manualIpBlockCache.set(ip, {
|
||
expiresAt: now + (normalizedResult.blocked ? 10_000 : 30_000),
|
||
result: normalizedResult,
|
||
});
|
||
return normalizedResult;
|
||
} catch (error) {
|
||
console.warn("[AccessIpBlocklist] manual block check failed", error);
|
||
manualIpBlockCache.set(ip, {
|
||
expiresAt: now + 5_000,
|
||
result: { blocked: false },
|
||
});
|
||
return { blocked: false };
|
||
}
|
||
}
|
||
|
||
function queueAccessLog(
|
||
request: NextRequest,
|
||
event: NextFetchEvent | undefined,
|
||
payload: AccessLogPayload
|
||
) {
|
||
const secret = process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||
if (!secret) return;
|
||
|
||
const { pathname } = request.nextUrl;
|
||
if (shouldBypassLocalCanvasE2E(pathname, getClientIP(request))) return;
|
||
if (!shouldRecordAccessLog(pathname, request.method, payload)) return;
|
||
|
||
const body = JSON.stringify({
|
||
eventType: "request",
|
||
ip: getClientIP(request),
|
||
method: request.method,
|
||
path: pathname,
|
||
routeGroup: getAccessRouteGroup(pathname),
|
||
statusCode: payload.statusCode ?? null,
|
||
outcome: payload.outcome || "allowed",
|
||
severity: payload.severity || "info",
|
||
reasons: payload.reasons || [],
|
||
userAgent: request.headers.get("user-agent"),
|
||
referer: request.headers.get("referer"),
|
||
occurredAt: new Date().toISOString(),
|
||
...payload,
|
||
});
|
||
|
||
const writePromise = fetch(new URL("/api/internal/access-log", request.url), {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
"x-access-log-internal-secret": secret,
|
||
},
|
||
body,
|
||
}).catch((error) => {
|
||
console.warn("[AccessIpEvent] async proxy write failed", error);
|
||
});
|
||
|
||
if (event) {
|
||
event.waitUntil(writePromise);
|
||
return;
|
||
}
|
||
|
||
void writePromise;
|
||
}
|
||
|
||
export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
||
const { pathname } = request.nextUrl;
|
||
const isSecure =
|
||
process.env.AUTH_URL?.startsWith("https://") ||
|
||
request.nextUrl.protocol === "https:";
|
||
let cachedToken: Awaited<ReturnType<typeof getToken>> | null | undefined;
|
||
const resolveToken = async () => {
|
||
if (cachedToken !== undefined) {
|
||
return cachedToken;
|
||
}
|
||
cachedToken = await getToken({
|
||
req: request,
|
||
secret: process.env.AUTH_SECRET,
|
||
secureCookie: isSecure,
|
||
});
|
||
return cachedToken;
|
||
};
|
||
|
||
if (isInternalAccessPath(pathname)) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 🛡️ 安全检查1:标记历史黑名单IP,但不封禁
|
||
const clientIP = getClientIP(request);
|
||
const bypassLocalCanvasE2E = shouldBypassLocalCanvasE2E(pathname, clientIP);
|
||
const staticBlocklistHit = isBlockedIP(clientIP);
|
||
let suspiciousCheck: ReturnType<typeof isSuspiciousRequest> = {
|
||
suspicious: false,
|
||
};
|
||
const accessLogIdentity = () => {
|
||
const token =
|
||
cachedToken && typeof cachedToken === "object" ? cachedToken : null;
|
||
return {
|
||
userId: typeof token?.sub === "string" ? token.sub : null,
|
||
userEmail: typeof token?.email === "string" ? token.email : null,
|
||
userRole: typeof token?.type === "string" ? token.type : null,
|
||
};
|
||
};
|
||
const withAccessLog = (
|
||
response: NextResponse,
|
||
details: AccessLogPayload = {}
|
||
) => {
|
||
const reasons = [...(details.reasons || [])];
|
||
let severity = details.severity || "info";
|
||
|
||
if (staticBlocklistHit) {
|
||
reasons.push("静态黑名单命中但未封禁");
|
||
severity = severity === "critical" ? severity : "high";
|
||
}
|
||
|
||
if (suspiciousCheck.suspicious && suspiciousCheck.reason) {
|
||
reasons.push(suspiciousCheck.reason);
|
||
if (severity === "info") severity = "watch";
|
||
}
|
||
|
||
queueAccessLog(request, event, {
|
||
...accessLogIdentity(),
|
||
statusCode: response.status || details.statusCode || null,
|
||
outcome: details.outcome || "allowed",
|
||
...details,
|
||
severity,
|
||
reasons,
|
||
});
|
||
return response;
|
||
};
|
||
|
||
if (staticBlocklistHit) {
|
||
console.warn(`[Security] Static blocklist hit but request was not blocked: ${clientIP}`, {
|
||
pathname,
|
||
method: request.method,
|
||
userAgent: request.headers.get("user-agent"),
|
||
});
|
||
}
|
||
|
||
const manualBlock = bypassLocalCanvasE2E
|
||
? { blocked: false }
|
||
: await checkManualIpBlock(request, clientIP);
|
||
if (manualBlock.blocked) {
|
||
return withAccessLog(
|
||
new NextResponse("Forbidden", { status: 403 }),
|
||
{
|
||
outcome: "manual_blocked",
|
||
severity: "critical",
|
||
reasons: [
|
||
"管理员手动拉黑",
|
||
manualBlock.entry?.reason
|
||
? `拉黑原因:${manualBlock.entry.reason}`
|
||
: "",
|
||
].filter(Boolean),
|
||
metadata: {
|
||
blocklistId: manualBlock.entry?.id || null,
|
||
matchedRule: manualBlock.entry?.ip || null,
|
||
},
|
||
}
|
||
);
|
||
}
|
||
|
||
// 🛡️ 安全检查2:检测可疑请求模式
|
||
suspiciousCheck = bypassLocalCanvasE2E
|
||
? { suspicious: false }
|
||
: isSuspiciousRequest(request);
|
||
if (suspiciousCheck.suspicious) {
|
||
logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern");
|
||
// 对于可疑请求,记录但不立即阻止(避免误报)
|
||
// 可以在这里添加更严格的检查
|
||
}
|
||
|
||
if (bypassLocalCanvasE2E) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 浏览器会自动拉取 favicon,不应参与通用 API 限流。
|
||
if (pathname === "/api/favicon") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// three.js KTX2/Basis transcoder assets are public runtime dependencies used
|
||
// by the in-browser 3D viewer. Redirecting them to login breaks texture decode
|
||
// and leaves generated 3D models as white clay renders.
|
||
if (pathname.startsWith("/three/basis/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// Site fonts are public static assets referenced by global CSS. Redirecting
|
||
// them to login prevents the browser from loading the configured typeface.
|
||
if (pathname.startsWith("/fonts/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 🛡️ 安全检查3:速率限制
|
||
// 对API路径应用速率限制
|
||
if (pathname === "/api/chat") {
|
||
const chatRateLimit = checkRateLimit(request, rateLimiters.chat);
|
||
if (!chatRateLimit.allowed) {
|
||
console.warn(`[Security] Chat rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(chatRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["聊天接口限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (pathname === "/api/image-proxy" && request.method === "GET") {
|
||
const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy);
|
||
if (!imageProxyRateLimit.allowed) {
|
||
const retryAfter = Math.max(1, Math.ceil((imageProxyRateLimit.resetTime - Date.now()) / 1000));
|
||
console.warn(`[Security] Image proxy rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
method: request.method,
|
||
resetTime: new Date(imageProxyRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many image proxy requests", retryAfter },
|
||
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["图片代理限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (
|
||
(pathname === "/api/files/upload" ||
|
||
pathname === "/api/files/direct-upload") &&
|
||
request.method === "POST"
|
||
) {
|
||
const token = await resolveToken();
|
||
const userId =
|
||
typeof token?.sub === "string" && token.sub.length > 0
|
||
? token.sub
|
||
: "anonymous";
|
||
const uploadRateLimit = checkRateLimitByIdentifier(
|
||
`${userId}:${request.method}:${pathname}`,
|
||
rateLimiters.upload
|
||
);
|
||
if (!uploadRateLimit.allowed) {
|
||
const retryAfter = Math.max(1, Math.ceil((uploadRateLimit.resetTime - Date.now()) / 1000));
|
||
console.warn(`[Security] Upload rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
method: request.method,
|
||
userId,
|
||
resetTime: new Date(uploadRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many upload requests", retryAfter },
|
||
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["上传接口限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") {
|
||
const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas);
|
||
if (!canvasRateLimit.allowed) {
|
||
console.warn(`[Security] Canvas rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(canvasRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["画布接口限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (pathname === "/api/canvas/auto-title" && request.method === "POST") {
|
||
const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas);
|
||
if (!canvasRateLimit.allowed) {
|
||
console.warn(`[Security] Canvas auto-title rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(canvasRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{
|
||
error: "Too many canvas requests",
|
||
retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000),
|
||
},
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["画布自动标题限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (
|
||
pathname.startsWith("/api/realtime/") ||
|
||
pathname.startsWith("/api/fal/proxy")
|
||
) {
|
||
const realtimeRateLimit = checkRateLimit(request, rateLimiters.realtime);
|
||
if (!realtimeRateLimit.allowed) {
|
||
console.warn(`[Security] Realtime rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(realtimeRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["实时接口限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (isHighFrequencyReadApi(pathname, request.method)) {
|
||
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
||
if (!pollingRateLimit.allowed) {
|
||
console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(pollingRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{
|
||
error: "Too many polling requests",
|
||
retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000),
|
||
},
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["高频轮询限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (isRoutineAuthRead(pathname, request.method)) {
|
||
// Session reads are used for UI hydration and should not count as login attempts.
|
||
} else if (pathname.startsWith("/api/admin/")) {
|
||
const token = await resolveToken();
|
||
const adminIdentifier =
|
||
typeof token?.sub === "string" && token.sub.length > 0
|
||
? `admin:${token.sub}`
|
||
: `admin-ip:${clientIP}`;
|
||
const adminRateLimit = checkRateLimitByIdentifier(
|
||
adminIdentifier,
|
||
rateLimiters.admin
|
||
);
|
||
if (!adminRateLimit.allowed) {
|
||
const retryAfter = Math.max(
|
||
1,
|
||
Math.ceil((adminRateLimit.resetTime - Date.now()) / 1000)
|
||
);
|
||
console.warn(`[Security] Admin rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(adminRateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many admin requests", retryAfter },
|
||
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["管理后台限流"],
|
||
}
|
||
);
|
||
}
|
||
} else if (pathname.startsWith("/api/")) {
|
||
const rateLimit = checkRateLimit(request, rateLimiters.api);
|
||
if (!rateLimit.allowed) {
|
||
console.warn(`[Security] Rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(rateLimit.resetTime).toISOString(),
|
||
});
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["通用 API 限流"],
|
||
}
|
||
);
|
||
}
|
||
}
|
||
|
||
// 🛡️ 安全检查4:认证相关API的严格速率限制
|
||
if (
|
||
pathname.startsWith("/api/auth/") &&
|
||
!isRoutineAuthRead(pathname, request.method) &&
|
||
!shouldBypassLocalAuthSessionRateLimit(pathname, clientIP)
|
||
) {
|
||
const rateLimit = checkRateLimit(request, rateLimiters.auth);
|
||
if (!rateLimit.allowed) {
|
||
console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`);
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
),
|
||
{
|
||
outcome: "rate_limited",
|
||
severity: "high",
|
||
reasons: ["认证接口限流"],
|
||
}
|
||
);
|
||
}
|
||
}
|
||
|
||
// 允许访问 ping 端点
|
||
if (pathname.startsWith("/ping")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
if (pathname === "/classic" || pathname.startsWith("/chat/")) {
|
||
return withAccessLog(
|
||
NextResponse.redirect(new URL("/", request.url)),
|
||
{
|
||
outcome: "redirect",
|
||
reasons: ["旧入口重定向"],
|
||
}
|
||
);
|
||
}
|
||
|
||
// 允许访问认证相关 API
|
||
if (pathname.startsWith("/api/auth")) {
|
||
return withAccessLog(NextResponse.next(), {
|
||
outcome: "allowed",
|
||
reasons: ["认证接口访问"],
|
||
});
|
||
}
|
||
|
||
// 允许访问公开的网站设置 API(登录页面需要)
|
||
if (pathname === "/api/site-settings") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许定时任务接口由服务端调用(具体鉴权由接口内部 CRON_SECRET 负责)
|
||
if (pathname.startsWith("/api/jobs/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许公开访问动态 favicon(浏览器标签图标)
|
||
if (pathname === "/api/favicon") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许访问公开的认证配置(登录弹窗需要)
|
||
if (pathname === "/api/auth-settings") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许匿名访问新版首页灵感数据
|
||
if (pathname === "/api/featured" || pathname.startsWith("/api/featured/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许匿名访问新版首页的轻量渐进配置接口
|
||
if (
|
||
request.method === "GET" &&
|
||
["/api/nav-links", "/api/tutorial-videos", "/api/home-animation"].includes(pathname)
|
||
) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许访问钉钉回调页面(public 文件夹中的文件)
|
||
if (pathname === "/dingtalk-callback.html") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许 DuluPay 支付回调(服务端回调无 session cookie)
|
||
if (pathname.startsWith("/api/payment/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许 World Labs key 池导入接口由外部脚本调用(具体鉴权由接口内 token 负责)
|
||
if (pathname === "/api/worldlabs/key-pool/import") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许第三方生图 API 由外部系统直接调用(具体鉴权由接口内 API Key 负责)
|
||
if (pathname.startsWith("/api/external/image/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
const token = await resolveToken();
|
||
|
||
const isPublicPage =
|
||
pathname === "/" ||
|
||
pathname === "/login" ||
|
||
pathname === "/register" ||
|
||
pathname === "/particle-preview" ||
|
||
pathname === "/riji" ||
|
||
pathname.startsWith("/riji/") ||
|
||
pathname.startsWith("/worlds/share/") ||
|
||
shouldBypassLocalCanvasPageAuth(pathname, clientIP);
|
||
|
||
// 未登录用户允许访问公开页面,其余需要登录
|
||
if (!token && !isPublicPage) {
|
||
// 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页
|
||
if (pathname.startsWith("/api/")) {
|
||
return withAccessLog(
|
||
NextResponse.json(
|
||
{ error: "未授权,请先登录" },
|
||
{ status: 401 }
|
||
),
|
||
{
|
||
outcome: "unauthorized",
|
||
severity: "watch",
|
||
reasons: ["未登录访问 API"],
|
||
}
|
||
);
|
||
}
|
||
|
||
// 对于页面路由(GET 请求),重定向到登录页面
|
||
const externalBase =
|
||
process.env.AUTH_URL ??
|
||
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
|
||
const loginUrl = new URL("/login", externalBase);
|
||
// 保存当前路径作为回调 URL
|
||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||
return withAccessLog(
|
||
NextResponse.redirect(loginUrl),
|
||
{
|
||
outcome: "redirect",
|
||
severity: "watch",
|
||
reasons: ["未登录访问受保护页面"],
|
||
}
|
||
);
|
||
}
|
||
|
||
return withAccessLog(NextResponse.next());
|
||
}
|
||
|
||
export const config = {
|
||
matcher: [
|
||
/*
|
||
* 匹配所有请求路径,除了:
|
||
* - _next/static (静态文件)
|
||
* - _next/image (图片优化文件)
|
||
* - favicon.ico (favicon文件)
|
||
* - public 文件夹中的文件
|
||
*/
|
||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|js|wasm|woff2?|ttf|otf|eot)$).*)",
|
||
],
|
||
};
|