import { lookup } from 'node:dns/promises'; import { request as httpsRequest } from 'node:https'; import type { IncomingMessage } from 'node:http'; import { isIP } from 'node:net'; import { ApiError } from '../common/api-error.js'; import { detectImage } from './media-image-validation.js'; const MAX_BYTES = 5 * 1024 * 1024; function invalidImage(): never { throw new ApiError( 422, 'PRODUCT_IMAGE_IMPORT_INVALID', '网络图片链接无效或图片不符合要求', ); } export function publicIpv4(address: string) { if (isIP(address) !== 4) return false; const [a, b, c] = address.split('.').map(Number); return !( a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || (b === 0 && c === 0) || (b === 0 && c === 2))) || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || (a === 203 && b === 0 && c === 113) ); } /** 每次重定向重新解析域名,并将 HTTPS 连接固定到已检查的公网 IPv4。 */ async function requestPublicImage(url: URL): Promise { if ( url.protocol !== 'https:' || url.username || url.password || url.port || isIP(url.hostname) ) invalidImage(); const addresses = await lookup(url.hostname, { all: true, family: 4, }).catch(() => []); if ( !addresses.length || addresses.some((item) => !publicIpv4(item.address)) ) invalidImage(); const address = addresses[0].address; return new Promise((resolve, reject) => { const request = httpsRequest( url, { lookup: (_hostname, _options, callback) => callback(null, address, 4), headers: { Accept: 'image/jpeg,image/png', 'User-Agent': 'Atlas-Image-Importer/0.1', }, timeout: 15_000, }, resolve, ); request.on('error', reject); request.on('timeout', () => request.destroy(new Error('网络图片下载超时')), ); request.end(); }); } /** 仅导入公开 HTTPS 的 JPG/PNG,硬限制 5 MiB 并验证文件内容。 */ export async function downloadProductImage(source: string) { let url = new URL(source); for (let redirect = 0; redirect < 4; redirect += 1) { let response: IncomingMessage; try { response = await requestPublicImage(url); } catch (error) { if (error instanceof ApiError) throw error; invalidImage(); } if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0)) { const location = response.headers.location; response.resume(); if (!location) invalidImage(); url = new URL(location, url); continue; } if ( response.statusCode !== 200 || Number(response.headers['content-length'] ?? 0) > MAX_BYTES ) { response.resume(); invalidImage(); } const chunks: Buffer[] = []; let total = 0; try { for await (const chunk of response) { total += chunk.length; if (total > MAX_BYTES) { response.destroy(); invalidImage(); } chunks.push(Buffer.from(chunk)); } } catch (error) { if (error instanceof ApiError) throw error; invalidImage(); } const bytes = Buffer.concat(chunks, total); const detected = detectImage(bytes, 10_000); if ( !detected || !['image/jpeg', 'image/png'].includes(detected.contentType) ) invalidImage(); const declared = response.headers['content-type'] ?.split(';', 1)[0] ?.toLowerCase(); if (declared && declared !== detected.contentType) invalidImage(); return { bytes, contentType: detected.contentType, width: detected.width, height: detected.height, }; } invalidImage(); }