feat: 完成 SKU 图片选择及素材导入后端
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user