mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-01 15:46:39 +08:00
route.ts
This commit is contained in:
parent
d1e42f7f1a
commit
faeba81cb4
@ -1,167 +1,196 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
|
import path from "path";
|
||||||
import { getServerSideConfig } from "@/app/config/server";
|
|
||||||
|
|
||||||
const config = getServerSideConfig();
|
// 配置常量
|
||||||
|
const ALLOWED_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PROPFIND", "MKCOL"];
|
||||||
const mergedAllowedWebDavEndpoints = [
|
const ALLOWED_HEADERS = [
|
||||||
...internalAllowedWebDavEndpoints,
|
"authorization",
|
||||||
...config.allowedWebDavEndpoints,
|
"content-type",
|
||||||
].filter((domain) => Boolean(domain.trim()));
|
"accept",
|
||||||
|
"depth",
|
||||||
const normalizeUrl = (url: string) => {
|
"destination",
|
||||||
try {
|
"overwrite",
|
||||||
return new URL(url);
|
"content-length"
|
||||||
} catch (err) {
|
];
|
||||||
return null;
|
const CORS_HEADERS = {
|
||||||
}
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": ALLOWED_METHODS.join(","),
|
||||||
|
"Access-Control-Allow-Headers": ALLOWED_HEADERS.join(", "),
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
};
|
};
|
||||||
|
const TIMEOUT = 30000; // 30 seconds
|
||||||
|
|
||||||
async function handle(
|
// WebDAV 服务器端点配置
|
||||||
req: NextRequest,
|
const ENDPOINT = process.env.WEBDAV_ENDPOINT || "http://localhost:8080";
|
||||||
{ params }: { params: { path: string[] } },
|
|
||||||
) {
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
|
||||||
}
|
|
||||||
const folder = STORAGE_KEY;
|
|
||||||
const fileName = `${folder}/backup.json`;
|
|
||||||
|
|
||||||
const requestUrl = new URL(req.url);
|
|
||||||
let endpoint = requestUrl.searchParams.get("endpoint");
|
|
||||||
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
|
|
||||||
|
|
||||||
// Validate the endpoint to prevent potential SSRF attacks
|
|
||||||
if (
|
|
||||||
!endpoint ||
|
|
||||||
!mergedAllowedWebDavEndpoints.some((allowedEndpoint) => {
|
|
||||||
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
|
|
||||||
const normalizedEndpoint = normalizeUrl(endpoint as string);
|
|
||||||
|
|
||||||
return (
|
|
||||||
normalizedEndpoint &&
|
|
||||||
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
|
|
||||||
normalizedEndpoint.pathname.startsWith(
|
|
||||||
normalizedAllowedEndpoint.pathname,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: true,
|
|
||||||
msg: "Invalid endpoint",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endpoint?.endsWith("/")) {
|
|
||||||
endpoint += "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpointPath = params.path.join("/");
|
|
||||||
const targetPath = `${endpoint}${endpointPath}`;
|
|
||||||
|
|
||||||
// only allow MKCOL, GET, PUT
|
|
||||||
if (
|
|
||||||
proxy_method !== "MKCOL" &&
|
|
||||||
proxy_method !== "GET" &&
|
|
||||||
proxy_method !== "PUT"
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: true,
|
|
||||||
msg: "you are not allowed to request " + targetPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// for MKCOL request, only allow request ${folder}
|
|
||||||
if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: true,
|
|
||||||
msg: "you are not allowed to request " + targetPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// for GET request, only allow request ending with fileName
|
|
||||||
if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: true,
|
|
||||||
msg: "you are not allowed to request " + targetPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// for PUT request, only allow request ending with fileName
|
|
||||||
if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: true,
|
|
||||||
msg: "you are not allowed to request " + targetPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUrl = targetPath;
|
|
||||||
|
|
||||||
const method = proxy_method || req.method;
|
|
||||||
const shouldNotHaveBody = ["get", "head"].includes(
|
|
||||||
method?.toLowerCase() ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
|
||||||
headers: {
|
|
||||||
authorization: req.headers.get("authorization") ?? "",
|
|
||||||
},
|
|
||||||
body: shouldNotHaveBody ? null : req.body,
|
|
||||||
redirect: "manual",
|
|
||||||
method,
|
|
||||||
// @ts-ignore
|
|
||||||
duplex: "half",
|
|
||||||
};
|
|
||||||
|
|
||||||
let fetchResult;
|
|
||||||
|
|
||||||
try {
|
|
||||||
fetchResult = await fetch(targetUrl, fetchOptions);
|
|
||||||
} finally {
|
|
||||||
console.log(
|
|
||||||
"[Any Proxy]",
|
|
||||||
targetUrl,
|
|
||||||
{
|
|
||||||
method: method,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: fetchResult?.status,
|
|
||||||
statusText: fetchResult?.statusText,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PUT = handle;
|
|
||||||
export const GET = handle;
|
|
||||||
export const OPTIONS = handle;
|
|
||||||
|
|
||||||
export const runtime = "edge";
|
export const runtime = "edge";
|
||||||
|
|
||||||
|
// 重试机制
|
||||||
|
async function makeRequest(url: string, options: RequestInit, retries = 3) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
||||||
|
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Attempt ${i + 1} failed:`, error);
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Request Timeout');
|
||||||
|
}
|
||||||
|
if (i === retries - 1) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Max retries reached');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handler(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 获取请求方法
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// 记录请求日志
|
||||||
|
console.log(`[Proxy Request] ${method} ${req.url}`);
|
||||||
|
console.log("Request Headers:", Object.fromEntries(req.headers));
|
||||||
|
|
||||||
|
// 处理 OPTIONS 请求
|
||||||
|
if (method === "OPTIONS") {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: CORS_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求方法
|
||||||
|
if (!ALLOWED_METHODS.includes(method)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Method Not Allowed" },
|
||||||
|
{ status: 405, headers: CORS_HEADERS }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建目标 URL
|
||||||
|
const targetUrlObj = new URL(ENDPOINT);
|
||||||
|
targetUrlObj.pathname = path.join(targetUrlObj.pathname, ...(params.path || []));
|
||||||
|
const targetUrl = targetUrlObj.toString();
|
||||||
|
|
||||||
|
// 处理请求头
|
||||||
|
const headers = new Headers();
|
||||||
|
req.headers.forEach((value, key) => {
|
||||||
|
if (ALLOWED_HEADERS.includes(key.toLowerCase())) {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 特殊处理 WebDAV 相关头
|
||||||
|
const depth = req.headers.get('depth');
|
||||||
|
if (depth) {
|
||||||
|
headers.set('depth', depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理认证头
|
||||||
|
const authHeader = req.headers.get('authorization');
|
||||||
|
if (authHeader) {
|
||||||
|
headers.set('authorization', authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理请求体
|
||||||
|
let requestBody: BodyInit | null = null;
|
||||||
|
if (["POST", "PUT"].includes(method)) {
|
||||||
|
try {
|
||||||
|
const contentLength = req.headers.get('content-length');
|
||||||
|
if (contentLength && parseInt(contentLength) > 10 * 1024 * 1024) {
|
||||||
|
// 大文件使用流式处理
|
||||||
|
requestBody = req.body;
|
||||||
|
headers.set('transfer-encoding', 'chunked');
|
||||||
|
} else {
|
||||||
|
requestBody = await req.blob();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Request Body Error]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid Request Body" },
|
||||||
|
{ status: 400, headers: CORS_HEADERS }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建fetch选项
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: requestBody,
|
||||||
|
redirect: "manual",
|
||||||
|
cache: 'no-store',
|
||||||
|
next: {
|
||||||
|
revalidate: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送代理请求
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
console.log(`[Proxy Forward] ${method} ${targetUrl}`);
|
||||||
|
response = await makeRequest(targetUrl, fetchOptions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Proxy Error]", error);
|
||||||
|
const status = error.message === 'Request Timeout' ? 504 : 500;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Internal Server Error" },
|
||||||
|
{ status, headers: CORS_HEADERS }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理响应头
|
||||||
|
const responseHeaders = new Headers(response.headers);
|
||||||
|
// 移除敏感头信息
|
||||||
|
["set-cookie", "server"].forEach((header) => {
|
||||||
|
responseHeaders.delete(header);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加 CORS 头和安全头
|
||||||
|
Object.entries({
|
||||||
|
...CORS_HEADERS,
|
||||||
|
"Content-Security-Policy": "upgrade-insecure-requests",
|
||||||
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains"
|
||||||
|
}).forEach(([key, value]) => {
|
||||||
|
responseHeaders.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录响应日志
|
||||||
|
console.log(`[Proxy Response] ${response.status} ${response.statusText}`);
|
||||||
|
console.log("Response Headers:", Object.fromEntries(responseHeaders));
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 捕获全局异常
|
||||||
|
console.error("[Global Error]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal Server Error", details: error.message },
|
||||||
|
{ status: 500, headers: CORS_HEADERS }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出支持的 HTTP 方法
|
||||||
|
export const GET = handler;
|
||||||
|
export const POST = handler;
|
||||||
|
export const PUT = handler;
|
||||||
|
export const DELETE = handler;
|
||||||
|
export const OPTIONS = handler;
|
||||||
|
export const PROPFIND = handler;
|
||||||
|
export const MKCOL = handler;
|
||||||
|
Loading…
Reference in New Issue
Block a user