ai-chatbot/proxy.ts
Your Name e1e53713b5
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add direct multipart image uploads
2026-05-27 10:54:45 +08:00

321 lines
11 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,
checkRateLimitByIdentifier,
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;
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);
}
export async function proxy(request: NextRequest) {
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;
};
// 🛡️ 安全检查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");
// 对于可疑请求,记录但不立即阻止(避免误报)
// 可以在这里添加更严格的检查
}
// 浏览器会自动拉取 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();
}
// 🛡️ 安全检查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 === "/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 NextResponse.json(
{ error: "Too many image proxy requests", retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
} 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 NextResponse.json(
{ error: "Too many upload requests", retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
} 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 === "/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 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();
}
// 允许 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 === "/riji" ||
pathname.startsWith("/riji/") ||
pathname.startsWith("/worlds/share/");
// 未登录用户允许访问公开页面,其余需要登录
if (!token && !isPublicPage) {
// 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
}
// 对于页面路由(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 NextResponse.redirect(loginUrl);
}
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|js|wasm)$).*)",
],
};