138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
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<IncomingMessage> {
|
|
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<IncomingMessage>((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();
|
|
}
|