ai-chatbot/proxy.ts
Your Name 75f93976ef
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(proxy): allow /api/jobs routes before auth gate
2026-03-05 12:21:03 +08:00

226 lines
7.8 KiB
TypeScript
Raw 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 NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist";
import { checkRateLimit, rateLimiters } from "@/lib/security/rate-limiter";
import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor";
function isHighFrequencyReadApi(pathname: string, method: string) {
if (method !== "GET") return false;
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
return [
"/api/models",
"/api/canvas",
"/api/nav-links",
"/api/site-settings",
"/api/messages",
"/api/history",
"/api/video-tasks",
"/api/tutorial-videos",
"/api/user/membership-summary",
"/api/user/account-center",
].includes(pathname);
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 🛡️ 安全检查1:阻止黑名单IP
const clientIP = getClientIP(request);
if (isBlockedIP(clientIP)) {
console.warn(`[Security] Blocked request from blacklisted IP: ${clientIP}`, {
pathname,
method: request.method,
userAgent: request.headers.get("user-agent"),
});
return new NextResponse("Forbidden", { status: 403 });
}
// 🛡️ 安全检查2:检测可疑请求模式
const suspiciousCheck = isSuspiciousRequest(request);
if (suspiciousCheck.suspicious) {
logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern");
// 对于可疑请求,记录但不立即阻止(避免误报)
// 可以在这里添加更严格的检查
}
// 🛡️ 安全检查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 NextResponse.json(
{ error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
} 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 NextResponse.json(
{ error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
} 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 NextResponse.json(
{ error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
} 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 NextResponse.json(
{
error: "Too many polling requests",
retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000),
},
{ status: 429 }
);
}
} 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 NextResponse.json(
{ error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
}
// 🛡️ 安全检查4:认证相关API的严格速率限制
if (pathname.startsWith("/api/auth/")) {
const rateLimit = checkRateLimit(request, rateLimiters.auth);
if (!rateLimit.allowed) {
console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`);
return NextResponse.json(
{ error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
}
// 允许访问 ping 端点
if (pathname.startsWith("/ping")) {
return NextResponse.next();
}
// 允许访问认证相关 API
if (pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
// 允许访问公开的网站设置 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();
}
// 允许访问钉钉回调页面(public 文件夹中的文件)
if (pathname === "/dingtalk-callback.html") {
return NextResponse.next();
}
// 允许 DuluPay 支付回调(服务端回调无 session cookie)
if (pathname.startsWith("/api/payment/")) {
return NextResponse.next();
}
// 检测是否为 HTTPS(通过 AUTH_URL 或请求协议判断)
const isSecure = process.env.AUTH_URL?.startsWith("https://") ||
request.nextUrl.protocol === "https:";
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: isSecure,
});
const externalBase =
process.env.AUTH_URL ??
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
// 未登录用户允许访问 /(新版首页)、/login、/register,其余需要登录
if (!token && !["/", "/login", "/register"].includes(pathname)) {
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
if (pathname.startsWith("/api/") && request.method !== "GET") {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
}
// 对于页面路由(GET 请求),重定向到登录页面
const loginUrl = new URL("/login", externalBase);
// 保存当前路径作为回调 URL
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录用户访问 /login 或 /register 时重定向到首页
if (token && ["/login", "/register"].includes(pathname)) {
return NextResponse.redirect(new URL("/", externalBase));
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* 匹配所有请求路径,除了:
* - api/auth (NextAuth.js)
* - _next/static (静态文件)
* - _next/image (图片优化文件)
* - favicon.ico (favicon文件)
* - public 文件夹中的文件
*/
"/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};