ai-chatbot/proxy.ts

914 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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\/[^/]+\/revision$/.test(pathname)) return true;
if (/^\/api\/canvas\/layout-replace\/batches\/[^/]+$/.test(pathname)) {
return true;
}
if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true;
return [
"/api/ai-wash/config",
"/api/models",
"/api/canvas",
"/api/canvas/model-aliases",
"/api/canvas-skill-tags",
"/api/canvas/clawd-chat/status",
"/api/enhance/pro/config",
"/api/featured",
"/api/featured/ranking",
"/api/home-animation",
"/api/nav-links",
"/api/scene-fusion/config",
"/api/site-settings",
"/api/style-pro/config",
"/api/messages",
"/api/history",
"/api/vote",
"/api/video-tasks",
"/api/user/image-stats",
"/api/tutorial-videos",
"/api/user/membership-summary",
"/api/user/account-center",
"/api/video-canvas/config",
].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 getInternalAccessUrl(request: NextRequest, pathname: string) {
const configuredBaseUrl = process.env.ACCESS_LOG_INTERNAL_URL?.trim();
if (configuredBaseUrl) {
try {
return new URL(pathname, configuredBaseUrl);
} catch {
// Fall through to the self-hosted runtime address.
}
}
const port = process.env.PORT?.trim();
if (port && /^\d{1,5}$/.test(port)) {
return new URL(pathname, `http://127.0.0.1:${port}`);
}
const url = new URL(request.url);
if (url.hostname === "127.0.0.1" || url.hostname === "localhost") {
url.protocol = "http:";
}
url.pathname = pathname;
url.search = "";
url.hash = "";
return url;
}
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 isPascalInternalProxyRequest(request: NextRequest) {
const pathname = request.nextUrl.pathname;
if (
pathname !== "/api/internal/pascal/access" &&
pathname !== "/api/internal/pascal-agent/v1/chat/completions"
) {
return false;
}
const expected = String(process.env.PASCAL_PROXY_SECRET || "").trim();
if (!expected) return false;
const authorization = request.headers.get("authorization") || "";
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || "";
const supplied =
request.headers.get("x-pascal-proxy-secret")?.trim() || bearer;
return supplied === expected;
}
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(
getInternalAccessUrl(request, "/api/internal/ip-blocklist/check"),
{
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(getInternalAccessUrl(request, "/api/internal/access-log"), {
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/canvas/clawd-chat/artifact" &&
request.method === "GET"
) {
const token = await resolveToken();
const userId = typeof token?.sub === "string" && token.sub ? token.sub : null;
const artifactRateLimit = checkRateLimitByIdentifier(
userId ? `user:${userId}:clawd-artifact` : `ip:${clientIP}:clawd-artifact`,
rateLimiters.clawdArtifact
);
if (!artifactRateLimit.allowed) {
const retryAfter = Math.max(
1,
Math.ceil((artifactRateLimit.resetTime - Date.now()) / 1000)
);
return withAccessLog(
NextResponse.json(
{ error: `附件读取过于频繁,请 ${retryAfter} 秒后重试`, retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
),
{
outcome: "rate_limited",
severity: "watch",
reasons: ["Claw附件读取限流"],
userId,
}
);
}
} 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 === "/api/canvas/download-image" &&
request.method === "POST"
) {
const token = await resolveToken();
const userId =
typeof token?.sub === "string" && token.sub.length > 0
? token.sub
: null;
const downloadIdentifier = userId
? `user:${userId}:canvas-download`
: `ip:${clientIP}:canvas-download`;
const downloadRateLimit = checkRateLimitByIdentifier(
downloadIdentifier,
rateLimiters.download
);
if (!downloadRateLimit.allowed) {
const retryAfter = Math.max(
1,
Math.ceil((downloadRateLimit.resetTime - Date.now()) / 1000)
);
console.warn(`[Security] Download rate limit exceeded for IP: ${clientIP}`, {
pathname,
userId,
resetTime: new Date(downloadRateLimit.resetTime).toISOString(),
});
return withAccessLog(
NextResponse.json(
{
error: `图片下载请求过于频繁,请 ${retryAfter} 秒后重试`,
retryAfter,
},
{
status: 429,
headers: { "Retry-After": String(retryAfter) },
}
),
{
outcome: "rate_limited",
severity: "high",
reasons: ["图片下载限流"],
userId,
}
);
}
} 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) {
const retryAfter = Math.max(
1,
Math.ceil((rateLimit.resetTime - Date.now()) / 1000)
);
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 },
{
status: 429,
headers: { "Retry-After": String(retryAfter) },
}
),
{
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();
}
// Pascal 服务到服务调用由对应 Route 再次验证共享密钥。
if (isPascalInternalProxyRequest(request)) {
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/image-proxy" && request.method === "GET") {
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();
}
// OpenAPI routes authenticate scoped user Access Keys in the route itself.
if (pathname.startsWith("/api/openapi/")) {
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/") ||
pathname.startsWith("/tvstar/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)$).*)",
],
};