feat: 完成 SKU 图片选择及素材导入后端

This commit is contained in:
R524809
2026-09-24 17:46:34 +08:00
parent d24ce8f4e0
commit e670d06ca6
26 changed files with 2287 additions and 119 deletions
@@ -0,0 +1,137 @@
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();
}