( url: URL, )
| 68 | |
| 69 | // Fetch image with timeout and size limits |
| 70 | async function fetchImage( |
| 71 | url: URL, |
| 72 | ): Promise<{ buffer: Buffer; contentType: string; status: number }> { |
| 73 | const controller = new AbortController(); |
| 74 | const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout |
| 75 | |
| 76 | try { |
| 77 | const response = await fetch(url.toString(), { |
| 78 | redirect: 'follow', |
| 79 | signal: controller.signal, |
| 80 | headers: { |
| 81 | 'user-agent': USER_AGENT, |
| 82 | accept: 'image/*,*/*;q=0.8', |
| 83 | }, |
| 84 | }); |
| 85 | |
| 86 | clearTimeout(timeout); |
| 87 | |
| 88 | if (!response.ok) { |
| 89 | return { |
| 90 | buffer: Buffer.alloc(0), |
| 91 | contentType: 'text/plain', |
| 92 | status: response.status, |
| 93 | }; |
| 94 | } |
| 95 | |
| 96 | // Size guard |
| 97 | const contentLength = Number(response.headers.get('content-length') ?? '0'); |
| 98 | if (contentLength > MAX_BYTES) { |
| 99 | throw new Error(`Remote file too large: ${contentLength} bytes`); |
| 100 | } |
| 101 | |
| 102 | const arrayBuffer = await response.arrayBuffer(); |
| 103 | const buffer = Buffer.from(arrayBuffer); |
| 104 | |
| 105 | // Additional size check for actual content |
| 106 | if (buffer.length > MAX_BYTES) { |
| 107 | throw new Error('Remote file exceeded size limit'); |
| 108 | } |
| 109 | |
| 110 | const contentType = |
| 111 | response.headers.get('content-type') || 'application/octet-stream'; |
| 112 | return { buffer, contentType, status: 200 }; |
| 113 | } catch (error) { |
| 114 | clearTimeout(timeout); |
| 115 | return { buffer: Buffer.alloc(0), contentType: 'text/plain', status: 500 }; |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // Check if URL is an ICO file |
| 120 | function isIcoFile(url: string, contentType?: string): boolean { |
no test coverage detected