|
| 1 | +// 通用边缘反向代理示例(Cloudflare Workers / Deno Deploy / Vercel Edge 等) |
| 2 | +// 依赖标准 Fetch API 与 Web Crypto,不绑定具体平台。 |
| 3 | +// |
| 4 | +// 配置方式有两种(二选一): |
| 5 | +// 1)直接修改下方常量(最适合在 Cloudflare Worker / Deno 在线编辑器里复制即用) |
| 6 | +// 2)在运行时通过 env.{ORIGIN,TOKEN,SIGN_SECRET,WORKER_BASE} 覆盖这些常量 |
| 7 | +// |
| 8 | +// 字段说明: |
| 9 | +// - ORIGIN: |
| 10 | +// CloudPaste 后端地址,例如 https://cloudpaste.example.com 项目的后端地址 |
| 11 | + |
| 12 | +// - TOKEN: |
| 13 | +// 反代调用 CloudPaste /api/proxy/link 时使用的认证凭证,会被放在 Authorization 头里。 |
| 14 | +// CloudPaste 的 AuthService 会解析 Authorization 头: |
| 15 | +// - 形如 "Bearer <adminToken>" 时按管理员令牌验证(validateAdminToken) |
| 16 | +// - 形如 "ApiKey <apiKeyValue>" 时按 API 密钥验证 |
| 17 | +// 推荐做法: |
| 18 | +// 1)在 CloudPaste 后台创建一个专用的 API 密钥,并启用它 |
| 19 | +// 2)将 TOKEN 设置为完整的认证头串,例如:TOKEN = "ApiKey KS6PPQAKz8dRPhATdJ4wx3tA8rxsGKXx" |
| 20 | +// 也可以使用管理员 Token:TOKEN = "Bearer <adminToken>",但生产环境更推荐使用专用 API Key。 |
| 21 | + |
| 22 | +// - SIGN_SECRET: |
| 23 | +// FS 场景下 /proxy/fs 路由使用的 HMAC 签名密钥。 |
| 24 | +// 后端 .env 中 ENCRYPTION_SECRET 相同的值 |
| 25 | + |
| 26 | +// - WORKER_BASE: |
| 27 | +// 本反代对外地址,例如 https://proxy.example.com,自己反代的域名地址,仅用于检测上游重定向是否回环到自身。 |
| 28 | +// 配错最多会导致“回环检测”不生效,不会影响正常代理功能。 |
| 29 | + |
| 30 | +// ==== 全局配置(可直接修改为你自己的值 / 在运行时通过 env 覆盖) ==== |
| 31 | +let ORIGIN = "https://cloudpaste.example.com"; |
| 32 | +let TOKEN = "ApiKey xxx"; |
| 33 | +let SIGN_SECRET = "default-encryption-key"; |
| 34 | +let WORKER_BASE = "https://proxy.example.com"; |
| 35 | + |
| 36 | +// 从 env 覆盖全局配置(如 Cloudflare Worker 的 env、Deno.serve 传入的对象等) |
| 37 | +function initFromEnv(env) { |
| 38 | + if (!env) return; |
| 39 | + if (env.ORIGIN) ORIGIN = String(env.ORIGIN); |
| 40 | + if (env.TOKEN) TOKEN = String(env.TOKEN); |
| 41 | + if (env.SIGN_SECRET) SIGN_SECRET = String(env.SIGN_SECRET); |
| 42 | + if (env.WORKER_BASE) WORKER_BASE = String(env.WORKER_BASE); |
| 43 | +} |
| 44 | + |
| 45 | +const textEncoder = new TextEncoder(); |
| 46 | + |
| 47 | +/** 将 HMAC-SHA256 结果编码为标准 Base64 字符串(与 Node.js crypto.digest("base64") 对齐) */ |
| 48 | +async function hmacSha256Base64(secret, data) { |
| 49 | + const key = await crypto.subtle.importKey("raw", textEncoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); |
| 50 | + const sig = await crypto.subtle.sign("HMAC", key, textEncoder.encode(data)); |
| 51 | + const bytes = new Uint8Array(sig); |
| 52 | + let bin = ""; |
| 53 | + for (const b of bytes) { |
| 54 | + bin += String.fromCharCode(b); |
| 55 | + } |
| 56 | + // 标准 Base64,与 CloudPaste ProxySignatureService 的 digest("base64") 完全一致 |
| 57 | + return btoa(bin); |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * 校验 FS 场景下的签名: |
| 62 | + * sign 形如 "<base64UrlHash>:<expire>",其中 expire 为秒级时间戳,0 表示永不过期。 |
| 63 | + */ |
| 64 | +async function verifyFsSign(path, sign, secret) { |
| 65 | + // 未提供 sign 时,反代层不做校验,完全交由 CloudPaste 按挂载/全局策略决定是否需要签名。 |
| 66 | + // - 当挂载/全局未启用签名时,LinkService 生成的 url_proxy 入口本身就不会带 sign。 |
| 67 | + // - 当挂载启用签名时,CloudPaste 仍会在 /api/p 链路上强制验证签名,保证安全性。 |
| 68 | + if (!sign) return ""; |
| 69 | + const parts = String(sign).split(":"); |
| 70 | + if (!parts.length) return "expire missing"; |
| 71 | + const expireStr = parts[parts.length - 1]; |
| 72 | + if (!expireStr) return "expire missing"; |
| 73 | + |
| 74 | + const expire = parseInt(expireStr, 10); |
| 75 | + if (Number.isNaN(expire)) return "expire invalid"; |
| 76 | + if (expire > 0 && Math.floor(Date.now() / 1000) > expire) { |
| 77 | + return "expire expired"; |
| 78 | + } |
| 79 | + |
| 80 | + const base = await hmacSha256Base64(secret, `${path}:${expireStr}`); |
| 81 | + const expected = `${base}:${expireStr}`; |
| 82 | + if (expected !== sign) return "sign mismatch"; |
| 83 | + return ""; |
| 84 | +} |
| 85 | + |
| 86 | +/** 将 CloudPaste /api/proxy/link 响应的 header 映射到请求头 */ |
| 87 | +function applyUpstreamHeaders(request, headerMap) { |
| 88 | + if (!headerMap || typeof headerMap !== "object") { |
| 89 | + return request; |
| 90 | + } |
| 91 | + const next = new Request(request); |
| 92 | + for (const [k, values] of Object.entries(headerMap)) { |
| 93 | + if (!Array.isArray(values)) continue; |
| 94 | + for (const v of values) { |
| 95 | + next.headers.set(k, String(v)); |
| 96 | + } |
| 97 | + } |
| 98 | + return next; |
| 99 | +} |
| 100 | + |
| 101 | +/** 判断重定向是否指向当前反代自身(用于避免无限循环) */ |
| 102 | +function isSelfRedirect(location, currentHost) { |
| 103 | + if (!location) return false; |
| 104 | + try { |
| 105 | + const loc = new URL(location, "http://dummy"); |
| 106 | + const target = loc.host; |
| 107 | + |
| 108 | + // 1. 显式配置的 WORKER_BASE |
| 109 | + if (WORKER_BASE) { |
| 110 | + const base = new URL(WORKER_BASE); |
| 111 | + if (target === base.host) return true; |
| 112 | + } |
| 113 | + |
| 114 | + // 2. 回落到当前请求 Host |
| 115 | + if (currentHost && target === currentHost) { |
| 116 | + return true; |
| 117 | + } |
| 118 | + } catch { |
| 119 | + return true; |
| 120 | + } |
| 121 | + return false; |
| 122 | +} |
| 123 | + |
| 124 | +/** 处理 /proxy/fs 与 /proxy/share 的统一入口(平台无关核心逻辑) */ |
| 125 | +export async function handleProxyRequest(request, env) { |
| 126 | + // 优先使用传入 env 覆盖全局配置,方便在不同平台注入机密 |
| 127 | + initFromEnv(env); |
| 128 | + |
| 129 | + if (!ORIGIN || !TOKEN || !SIGN_SECRET) { |
| 130 | + return new Response(JSON.stringify({ code: 500, message: "proxy env not configured" }), { |
| 131 | + status: 500, |
| 132 | + headers: { "content-type": "application/json;charset=UTF-8" }, |
| 133 | + }); |
| 134 | + } |
| 135 | + |
| 136 | + const url = new URL(request.url); |
| 137 | + const path = url.pathname; |
| 138 | + const requestOrigin = request.headers.get("Origin") || "*"; |
| 139 | + |
| 140 | + // 统一处理 CORS 预检请求,避免将 OPTIONS 透传到上游 |
| 141 | + if (request.method === "OPTIONS" && path.startsWith("/proxy/")) { |
| 142 | + const allowHeaders = |
| 143 | + request.headers.get("Access-Control-Request-Headers") || |
| 144 | + "Range, Content-Type"; |
| 145 | + |
| 146 | + return new Response(null, { |
| 147 | + status: 204, |
| 148 | + headers: { |
| 149 | + "Access-Control-Allow-Origin": requestOrigin, |
| 150 | + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", |
| 151 | + "Access-Control-Allow-Headers": allowHeaders, |
| 152 | + "Access-Control-Max-Age": "86400", |
| 153 | + }, |
| 154 | + }); |
| 155 | + } |
| 156 | + |
| 157 | + // 仅处理 /proxy 路径,其余直接 404 |
| 158 | + if (!path.startsWith("/proxy/")) { |
| 159 | + return new Response("Not Found", { status: 404 }); |
| 160 | + } |
| 161 | + |
| 162 | + // FS 视图:/proxy/fs<fsPath>?sign=... |
| 163 | + if (path.startsWith("/proxy/fs")) { |
| 164 | + const fsPath = decodeURIComponent(path.slice("/proxy/fs".length) || "/"); |
| 165 | + const sign = url.searchParams.get("sign") ?? ""; |
| 166 | + |
| 167 | + // 签名校验 |
| 168 | + const verifyMsg = await verifyFsSign(fsPath, sign, SIGN_SECRET); |
| 169 | + if (verifyMsg) { |
| 170 | + return new Response(JSON.stringify({ code: 401, message: verifyMsg }), { |
| 171 | + status: 401, |
| 172 | + headers: { "content-type": "application/json;charset=UTF-8" }, |
| 173 | + }); |
| 174 | + } |
| 175 | + |
| 176 | + // 调用 CloudPaste /api/proxy/link 获取上游 URL |
| 177 | + const controlUrl = new URL(ORIGIN); |
| 178 | + controlUrl.pathname = "/api/proxy/link"; |
| 179 | + |
| 180 | + const linkResp = await fetch(controlUrl.toString(), { |
| 181 | + method: "POST", |
| 182 | + headers: { |
| 183 | + "content-type": "application/json;charset=UTF-8", |
| 184 | + Authorization: TOKEN, |
| 185 | + }, |
| 186 | + body: JSON.stringify({ type: "fs", path: fsPath }), |
| 187 | + }); |
| 188 | + |
| 189 | + const linkJson = await linkResp.json().catch(() => null); |
| 190 | + const code = linkJson?.code ?? linkResp.status; |
| 191 | + const data = linkJson?.data ?? null; |
| 192 | + if (code !== 200 || !data?.url) { |
| 193 | + return new Response(JSON.stringify({ code, message: "proxy link resolve failed" }), { |
| 194 | + status: 502, |
| 195 | + headers: { "content-type": "application/json;charset=UTF-8" }, |
| 196 | + }); |
| 197 | + } |
| 198 | + |
| 199 | + // 转发到上游 |
| 200 | + let upstreamReq = applyUpstreamHeaders(request, data.header); |
| 201 | + upstreamReq = new Request(data.url, upstreamReq); |
| 202 | + |
| 203 | + let upstreamRes = await fetch(upstreamReq); |
| 204 | + const currentHost = url.host; |
| 205 | + |
| 206 | + // 跟随重定向,避免自递归 |
| 207 | + for (let i = 0; i < 5 && upstreamRes.status >= 300 && upstreamRes.status < 400; i++) { |
| 208 | + const location = upstreamRes.headers.get("Location"); |
| 209 | + if (!location) break; |
| 210 | + |
| 211 | + if (isSelfRedirect(location, currentHost)) { |
| 212 | + // 自身重定向:递归交给 handleProxyRequest 处理 |
| 213 | + const nextReq = new Request(new URL(location, request.url).toString(), request); |
| 214 | + return handleProxyRequest(nextReq, env); |
| 215 | + } |
| 216 | + |
| 217 | + upstreamReq = new Request(location, upstreamReq); |
| 218 | + upstreamRes = await fetch(upstreamReq); |
| 219 | + } |
| 220 | + |
| 221 | + const resp = new Response(upstreamRes.body, upstreamRes); |
| 222 | + resp.headers.delete("set-cookie"); |
| 223 | + resp.headers.set("Access-Control-Allow-Origin", requestOrigin); |
| 224 | + resp.headers.set( |
| 225 | + "Access-Control-Expose-Headers", |
| 226 | + "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified, Content-Type", |
| 227 | + ); |
| 228 | + resp.headers.append("Vary", "Origin"); |
| 229 | + return resp; |
| 230 | + } |
| 231 | + |
| 232 | + // 分享视图:/proxy/share/:slug |
| 233 | + if (path.startsWith("/proxy/share/")) { |
| 234 | + const slug = decodeURIComponent(path.slice("/proxy/share/".length)); |
| 235 | + if (!slug) { |
| 236 | + return new Response(JSON.stringify({ code: 400, message: "missing share slug" }), { |
| 237 | + status: 400, |
| 238 | + headers: { "content-type": "application/json;charset=UTF-8" }, |
| 239 | + }); |
| 240 | + } |
| 241 | + |
| 242 | + const controlUrl = new URL(ORIGIN); |
| 243 | + controlUrl.pathname = "/api/proxy/link"; |
| 244 | + |
| 245 | + const linkResp = await fetch(controlUrl.toString(), { |
| 246 | + method: "POST", |
| 247 | + headers: { |
| 248 | + "content-type": "application/json;charset=UTF-8", |
| 249 | + Authorization: TOKEN, |
| 250 | + }, |
| 251 | + body: JSON.stringify({ type: "share", slug }), |
| 252 | + }); |
| 253 | + |
| 254 | + const linkJson = await linkResp.json().catch(() => null); |
| 255 | + const code = linkJson?.code ?? linkResp.status; |
| 256 | + const data = linkJson?.data ?? null; |
| 257 | + if (code !== 200 || !data?.url) { |
| 258 | + return new Response(JSON.stringify({ code, message: "proxy link resolve failed" }), { |
| 259 | + status: 502, |
| 260 | + headers: { "content-type": "application/json;charset=UTF-8" }, |
| 261 | + }); |
| 262 | + } |
| 263 | + |
| 264 | + let upstreamReq = applyUpstreamHeaders(request, data.header); |
| 265 | + upstreamReq = new Request(data.url, upstreamReq); |
| 266 | + |
| 267 | + let upstreamRes = await fetch(upstreamReq); |
| 268 | + const currentHost = url.host; |
| 269 | + |
| 270 | + for (let i = 0; i < 5 && upstreamRes.status >= 300 && upstreamRes.status < 400; i++) { |
| 271 | + const location = upstreamRes.headers.get("Location"); |
| 272 | + if (!location) break; |
| 273 | + |
| 274 | + if (isSelfRedirect(location, currentHost)) { |
| 275 | + const nextReq = new Request(new URL(location, request.url).toString(), request); |
| 276 | + return handleProxyRequest(nextReq, env); |
| 277 | + } |
| 278 | + |
| 279 | + upstreamReq = new Request(location, upstreamReq); |
| 280 | + upstreamRes = await fetch(upstreamReq); |
| 281 | + } |
| 282 | + |
| 283 | + const resp = new Response(upstreamRes.body, upstreamRes); |
| 284 | + resp.headers.delete("set-cookie"); |
| 285 | + resp.headers.set("Access-Control-Allow-Origin", requestOrigin); |
| 286 | + resp.headers.set( |
| 287 | + "Access-Control-Expose-Headers", |
| 288 | + "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified, Content-Type", |
| 289 | + ); |
| 290 | + resp.headers.append("Vary", "Origin"); |
| 291 | + return resp; |
| 292 | + } |
| 293 | + |
| 294 | + return new Response("Not Found", { status: 404 }); |
| 295 | +} |
| 296 | + |
| 297 | +// ==== 平台自适配入口 ==== |
| 298 | +// 说明: |
| 299 | +// - Cloudflare Workers:直接使用默认导出(workerd 风格) |
| 300 | +// - Deno Deploy :如果检测到 Deno.serve,则自动注册 HTTP 服务 |
| 301 | +// - 其他 Fetch 兼容运行时:导入 handleProxyRequest 自行调用 |
| 302 | + |
| 303 | +// Cloudflare Workers |
| 304 | +export default { |
| 305 | + async fetch(request, env, ctx) { |
| 306 | + return handleProxyRequest(request, env); |
| 307 | + }, |
| 308 | +}; |
| 309 | + |
| 310 | +// Deno Deploy:若存在 Deno.serve,则自动启动服务 |
| 311 | +if (typeof globalThis.Deno !== "undefined" && typeof globalThis.Deno.serve === "function") { |
| 312 | + globalThis.Deno.serve((req) => |
| 313 | + handleProxyRequest(req, { |
| 314 | + ORIGIN: globalThis.Deno.env?.get?.("ORIGIN") ?? ORIGIN, |
| 315 | + TOKEN: globalThis.Deno.env?.get?.("TOKEN") ?? TOKEN, |
| 316 | + SIGN_SECRET: globalThis.Deno.env?.get?.("SIGN_SECRET") ?? SIGN_SECRET, |
| 317 | + WORKER_BASE: globalThis.Deno.env?.get?.("WORKER_BASE") ?? WORKER_BASE, |
| 318 | + }) |
| 319 | + ); |
| 320 | +} |
| 321 | + |
| 322 | +// 对于 Vercel Edge / 其他 Edge Runtime: |
| 323 | +// import { handleProxyRequest } from "./cloudpaste-proxy.js"; |
| 324 | +// export const config = { runtime: "edge" }; |
| 325 | +// export default (req) => handleProxyRequest(req, { |
| 326 | +// ORIGIN: process.env.ORIGIN, |
| 327 | +// TOKEN: process.env.TOKEN, |
| 328 | +// SIGN_SECRET: process.env.SIGN_SECRET, |
| 329 | +// WORKER_BASE: process.env.WORKER_BASE, |
| 330 | +// }); |
| 331 | +// - 这样可以继续复用本文件的所有逻辑。 |
0 commit comments