lovart3/proxy.ts
Your Name f874b3464f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add wechat pay channel to unified billing flow
2026-02-12 13:32:13 +08:00

155 lines
5.3 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";
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublicMarketingRoute =
pathname === "/lovart" ||
pathname.startsWith("/lovart/") ||
pathname === "/lovart/pricing" ||
pathname === "/lovart/news";
const isPublicMarketingApi = pathname === "/api/public/lovart" || pathname.startsWith("/api/public/lovart/");
const isStripeWebhookRoute = pathname === "/api/billing/webhook/stripe";
const isWechatWebhookRoute = pathname === "/api/billing/wechat/notify";
// 🛡️ 安全检查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");
// 对于可疑请求,记录但不立即阻止(避免误报)
// 可以在这里添加更严格的检查
}
// 允许 Stripe Webhook 回调(第三方服务端回调)
// 放在速率限制之前,避免第三方回调被误拦截
if (isStripeWebhookRoute || isWechatWebhookRoute) {
return NextResponse.next();
}
// 🛡️ 安全检查3:速率限制
// 对API路径应用速率限制
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();
}
// 允许公开营销数据 API(Lovart 重建页)
if (isPublicMarketingApi) {
return NextResponse.next();
}
// 允许访问钉钉回调页面(public 文件夹中的文件)
if (pathname === "/dingtalk-callback.html") {
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) &&
!isPublicMarketingRoute &&
!isPublicMarketingApi &&
!isStripeWebhookRoute &&
!isWechatWebhookRoute
) {
// 对于 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)$).*)",
],
};