Skip to content

Commit 6ffbe5f

Browse files
committed
Complete reader image proxy data commit
1 parent 880debf commit 6ffbe5f

3 files changed

Lines changed: 15707 additions & 12046 deletions

File tree

garss-studio/server/index.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ const DEFAULT_AUTO_REFRESH_INTERVAL_MINUTES = 30;
109109
const DEFAULT_PARALLEL_FETCH_COUNT = 2;
110110
const FEED_FETCH_TIMEOUT_MS = 15_000;
111111
const MAX_FEED_BYTES = 5 * 1024 * 1024;
112+
const IMAGE_PROXY_TIMEOUT_MS = 12_000;
113+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
114+
const IMAGE_PROXY_USER_AGENT =
115+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
116+
const blockedImageProxyHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "backend", "frontend", "rsshub"]);
112117
const openApiScanPaths = [
113118
path.join(rootDir, "server/**/*.ts"),
114119
path.join(rootDir, "dist-server/server/**/*.js"),
@@ -566,6 +571,44 @@ function buildRsshubUrl(routePath: string): string {
566571
return new URL(routePath, base).toString();
567572
}
568573

574+
function normalizeImageProxyTargetUrl(value: unknown): string {
575+
const rawValue = normalizeText(value);
576+
577+
if (!rawValue) {
578+
throw new Error("url 参数不能为空。");
579+
}
580+
581+
let targetUrl: URL;
582+
583+
try {
584+
targetUrl = new URL(rawValue);
585+
} catch {
586+
throw new Error("url 参数必须是合法的绝对地址。");
587+
}
588+
589+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
590+
throw new Error("url 参数只支持 http 或 https。");
591+
}
592+
593+
const hostname = targetUrl.hostname.toLowerCase();
594+
595+
if (blockedImageProxyHosts.has(hostname) || hostname.endsWith(".localhost")) {
596+
throw new Error("不支持代理内部主机图片。");
597+
}
598+
599+
return targetUrl.toString();
600+
}
601+
602+
function buildImageProxyReferer(targetUrl: string): string {
603+
const parsedUrl = new URL(targetUrl);
604+
605+
if (parsedUrl.hostname.toLowerCase().endsWith("doubanio.com")) {
606+
return "https://movie.douban.com/";
607+
}
608+
609+
return `${parsedUrl.protocol}//${parsedUrl.host}/`;
610+
}
611+
569612
function formatByteLimit(bytes: number): string {
570613
return `${Math.round(bytes / 1024 / 1024)} MB`;
571614
}
@@ -644,6 +687,55 @@ async function readResponseTextWithinLimit(
644687
}
645688
}
646689

690+
async function readResponseBufferWithinLimit(
691+
response: globalThis.Response,
692+
controller: AbortController,
693+
maxBytes: number,
694+
): Promise<Buffer> {
695+
const declaredContentLength = parseContentLength(response.headers.get("content-length"));
696+
697+
if (declaredContentLength !== null && declaredContentLength > maxBytes) {
698+
controller.abort();
699+
throw new Error(`response too large (limit ${formatByteLimit(maxBytes)})`);
700+
}
701+
702+
if (!response.body) {
703+
return Buffer.alloc(0);
704+
}
705+
706+
const reader = response.body.getReader();
707+
let totalBytes = 0;
708+
const chunks: Buffer[] = [];
709+
710+
try {
711+
while (true) {
712+
const { done, value } = await reader.read();
713+
714+
if (done) {
715+
break;
716+
}
717+
718+
const chunk = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
719+
totalBytes += chunk.byteLength;
720+
721+
if (totalBytes > maxBytes) {
722+
controller.abort();
723+
throw new Error(`response too large (limit ${formatByteLimit(maxBytes)})`);
724+
}
725+
726+
chunks.push(chunk);
727+
}
728+
729+
return Buffer.concat(chunks, totalBytes);
730+
} finally {
731+
try {
732+
await reader.cancel();
733+
} catch {
734+
// Ignore cancellation errors after the stream has completed or been aborted.
735+
}
736+
}
737+
}
738+
647739
async function fetchFeedXml(targetUrl: string): Promise<string> {
648740
const controller = new AbortController();
649741
const timeout = setTimeout(() => controller.abort(), FEED_FETCH_TIMEOUT_MS);
@@ -1622,6 +1714,60 @@ app.get("/api/health", async (_request, response) => {
16221714
});
16231715
});
16241716

1717+
app.get("/api/image-proxy", async (request, response) => {
1718+
let targetUrl: string;
1719+
1720+
try {
1721+
targetUrl = normalizeImageProxyTargetUrl(request.query.url);
1722+
} catch (error) {
1723+
response.status(400).json({ error: getErrorMessage(error) });
1724+
return;
1725+
}
1726+
1727+
const controller = new AbortController();
1728+
const timeout = setTimeout(() => controller.abort(), IMAGE_PROXY_TIMEOUT_MS);
1729+
1730+
try {
1731+
const upstreamResponse = await fetch(targetUrl, {
1732+
headers: {
1733+
accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
1734+
referer: buildImageProxyReferer(targetUrl),
1735+
"user-agent": IMAGE_PROXY_USER_AGENT,
1736+
},
1737+
redirect: "follow",
1738+
signal: controller.signal,
1739+
});
1740+
1741+
if (!upstreamResponse.ok) {
1742+
response.status(upstreamResponse.status).json({ error: `upstream returned ${upstreamResponse.status}` });
1743+
return;
1744+
}
1745+
1746+
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
1747+
1748+
if (!contentType.toLowerCase().startsWith("image/")) {
1749+
response.status(415).json({ error: "upstream content is not an image" });
1750+
return;
1751+
}
1752+
1753+
const imageBuffer = await readResponseBufferWithinLimit(upstreamResponse, controller, MAX_IMAGE_BYTES);
1754+
1755+
response.setHeader("Content-Type", contentType);
1756+
response.setHeader("Cache-Control", "public, max-age=86400, stale-while-revalidate=604800");
1757+
response.setHeader("X-Content-Type-Options", "nosniff");
1758+
response.send(imageBuffer);
1759+
} catch (error) {
1760+
if (error instanceof Error && error.name === "AbortError") {
1761+
response.status(504).json({ error: `request timed out after ${IMAGE_PROXY_TIMEOUT_MS}ms` });
1762+
return;
1763+
}
1764+
1765+
response.status(502).json({ error: getErrorMessage(error) });
1766+
} finally {
1767+
clearTimeout(timeout);
1768+
}
1769+
});
1770+
16251771
app.get("/api/rsshub/fetch", ensureAuthenticated, async (request, response) => {
16261772
const routePath = normalizeText(request.query.routePath);
16271773

0 commit comments

Comments
 (0)