Skip to content

Commit ef6edba

Browse files
committed
feat: 增加“代理URL”,可配置存储的代理URL进行反代(下载/预览),详情见Cloudpaste-Proxy.js
refactor(storage): 统一代理架构与上游HTTP抽象
1 parent a1fa4e1 commit ef6edba

File tree

29 files changed

+1136
-235
lines changed

29 files changed

+1136
-235
lines changed

Cloudpaste-Proxy.js

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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+
// - 这样可以继续复用本文件的所有逻辑。

backend/src/cache/UrlCache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class UrlCacheManager extends BaseCache {
1414
...options,
1515
});
1616
this.config = {
17-
customHostTtl: options.customHostTtl || 86400 * 7, // 自定义域名TTL(7天)
17+
customHostTtl: options.customHostTtl || 86400 * 7,
1818
};
1919
}
2020

0 commit comments

Comments
 (0)