File size: 6,967 Bytes
9671dbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
import { parseURL } from "./urlParser.js";
import { isValidHostName } from "./utils.js";
import { withCORS } from "./cors.js";
import { onRequestStart, onRequestEnd, onError, renderPrometheus } from "./metrics.js";

/**
 * 创建代理处理器
 * @param {object} options
 * @param {import('http-proxy').default} proxy
 * @returns {(req, res) => void}
 */
export function createProxyHandler(options, proxy) {
  const {
    originBlacklist = [],
    originWhitelist = [],
    requireHeader = [],
    removeHeaders = [],
    setHeaders = {},
    checkRateLimit = null,
    redirectSameOrigin = false,
  } = options || {};
  // 根路径文档页渲染(KISS/YAGNI:仅提供必要说明)
  const renderLandingHTML = (req) => {
    const host = req.headers.host || `localhost:${process.env.PORT || 4399}`;
    const base = `http://${host}`;
    return `<!doctype html>\n<html lang="zh-CN">\n<head>\n  <meta charset="utf-8" />\n  <meta name="viewport" content="width=device-width, initial-scale=1" />\n  <title>CORS Proxy 服务</title>\n  <style>\n    body { font-family: system-ui, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; margin: 2rem; line-height: 1.6; }\n    code, pre { background: #f5f7fa; padding: 0.2rem 0.4rem; border-radius: 4px; }\n    pre { padding: 0.8rem; overflow: auto; }\n    h1, h2 { margin: 0.2rem 0 0.6rem; }\n    .tip { color: #666; }\n  </style>\n</head>\n<body>\n  <h1>CORS Proxy Server</h1>\n  <p class="tip">通过在目标 URL 前加上代理前缀来解决跨域。</p>\n  <h2>用法</h2>\n  <pre><code>${base}/&lt;目标URL&gt;</code></pre>\n  <h2>示例</h2>\n  <pre><code>GET ${base}/https://api.github.com/users/octocat\nGET ${base}/https://jsonplaceholder.typicode.com/posts/1</code></pre>\n  <h2>说明</h2>\n  <ul>\n    <li>支持 <code>GET/POST/PUT/DELETE</code> 等常见方法。</li>\n    <li>自动添加 CORS 响应头。</li>\n    <li>可设置 <code>PORT</code> 环境变量修改端口(默认 4399)。</li>\n  </ul>\n  <p class="tip">将真实 API 地址拼接在 <code>${base}/</code> 后即可。</p>\n</body>\n</html>`;
  };
  return (req, res) => {
    const start = Date.now();
    const reqId = Math.random().toString(36).slice(2, 10);
    onRequestStart();
    const log = (level, data) => {
      try {
        console.log(JSON.stringify({ level, reqId, method: req.method, path: req.url, origin: req.headers.origin || null, ...data }));
      } catch {}
    };

    // 健康检查与指标
    if (req.url === "/healthz") {
      const headers = withCORS({ "Content-Type": "application/json" }, req);
      res.writeHead(200, headers);
      res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
      onRequestEnd();
      return;
    }
    if (req.url === "/metrics") {
      const text = renderPrometheus();
      res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
      res.end(text);
      onRequestEnd();
      return;
    }
    // 0. 处理 OPTIONS 预检请求
    if (req.method === "OPTIONS") {
      const headers = withCORS({}, req);
      res.writeHead(200, headers);
      res.end();
      return;
    }

    // 0.1 检查来源黑/白名单
    const origin = req.headers.origin || "";
    if (originBlacklist.length && originBlacklist.includes(origin)) {
      onError();
      res.writeHead(403, withCORS({ "Content-Type": "application/json" }, req));
      res.end(JSON.stringify({ error: "origin_blacklisted" }));
      onRequestEnd();
      return;
    }
    if (originWhitelist.length && !originWhitelist.includes(origin)) {
      onError();
      res.writeHead(403, withCORS({ "Content-Type": "application/json" }, req));
      res.end(JSON.stringify({ error: "origin_not_whitelisted" }));
      onRequestEnd();
      return;
    }

    // 0.2 检查必需头部
    if (
      requireHeader.length &&
      !requireHeader.every((h) => req.headers[h.toLowerCase()])
    ) {
      onError();
      res.writeHead(400, withCORS({ "Content-Type": "application/json" }, req));
      res.end(JSON.stringify({ error: "missing_required_header" }));
      onRequestEnd();
      return;
    }

    // 0.3 检查限流
    if (typeof checkRateLimit === "function" && !checkRateLimit(req)) {
      onError();
      res.writeHead(429, withCORS({ "Content-Type": "application/json" }, req));
      res.end(JSON.stringify({ error: "rate_limit_exceeded" }));
      onRequestEnd();
      return;
    }
    // 1. 提取目标 URL
    const url = decodeURIComponent(req.url.substring(1));
    if (!url || req.url === "/") {
      const headers = withCORS({ "Content-Type": "text/html; charset=utf-8" }, req);
      res.writeHead(200, headers);
      res.end(renderLandingHTML(req));
      onRequestEnd();
      return;
    }
    const targetLocation = parseURL(url);
    if (!targetLocation) {
      onError();
      res.writeHead(400, withCORS({ "Content-Type": "application/json" }, req));
      res.end(JSON.stringify({ error: "invalid_target_url" }));
      onRequestEnd();
      return;
    }
    // 2. 主机名校验
    if (!isValidHostName(targetLocation.hostname)) {
      onError();
      res.writeHead(404, withCORS({ "Content-Type": "application/json" }, req));
      res.end(JSON.stringify({ error: "invalid_host" }));
      onRequestEnd();
      return;
    }

    // 2.1 移除指定头部
    removeHeaders.forEach((h) => {
      delete req.headers[h.toLowerCase()];
    });
    // 2.2 设置指定头部
    Object.entries(setHeaders).forEach(([k, v]) => {
      req.headers[k.toLowerCase()] = v;
    });

    // 3. 代理请求
    proxy.once("proxyRes", (proxyRes, req2, res2) => {
      // 处理重定向
      if ([301, 302, 303, 307, 308].includes(proxyRes.statusCode)) {
        const loc = proxyRes.headers["location"];
        if (loc) {
          const newUrl = new URL(loc, targetLocation);
          // redirectSameOrigin: 只重定向同源
          if (!redirectSameOrigin || newUrl.origin === targetLocation.origin) {
            proxyRes.headers["location"] = "/" + newUrl.href;
          }
        }
      }
      // 添加 CORS 头
      Object.entries(withCORS({}, req2)).forEach(([k, v]) => {
        res2.setHeader(k, v);
      });
      // 记录成功日志
      try { console.log(JSON.stringify({ level: "info", reqId, status: proxyRes.statusCode, target: targetLocation.href, duration_ms: Date.now() - start })); } catch {}
    });
    proxy.web(req, res, {
      target: targetLocation.href,
      ignorePath: true,
      changeOrigin: true,
    });
    res.on("close", () => {
      onRequestEnd();
    });
  };
}
    // 基础可靠性:socket 超时(15s 默认)
    try {
      req.socket.setTimeout(15000, () => {
        onError();
        try {
          res.writeHead(504, withCORS({ "Content-Type": "application/json" }, req));
          res.end(JSON.stringify({ error: "client_timeout" }));
        } catch {}
        try { req.destroy(); } catch {}
        onRequestEnd();
      });
    } catch {}