@@ -109,6 +109,11 @@ const DEFAULT_AUTO_REFRESH_INTERVAL_MINUTES = 30;
109109const DEFAULT_PARALLEL_FETCH_COUNT = 2 ;
110110const FEED_FETCH_TIMEOUT_MS = 15_000 ;
111111const 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" ] ) ;
112117const 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+
569612function 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+
647739async 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+
16251771app . get ( "/api/rsshub/fetch" , ensureAuthenticated , async ( request , response ) => {
16261772 const routePath = normalizeText ( request . query . routePath ) ;
16271773
0 commit comments