feat(api): 添加安全上传 Skill 和 COS 图片归一化
This commit is contained in:
142
api/storage.ts
142
api/storage.ts
@@ -1,7 +1,10 @@
|
||||
import COS from 'cos-nodejs-sdk-v5';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import sharp from 'sharp';
|
||||
import { database } from './database.js';
|
||||
import { decryptSecret } from './configCrypto.js';
|
||||
|
||||
@@ -22,6 +25,28 @@ export interface StoredUpload {
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export interface StoredExternalImage extends StoredUpload {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class StorageImportError extends Error {
|
||||
constructor(public statusCode: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_REMOTE_REDIRECTS = 3;
|
||||
const IMAGE_CONTENT_TYPES = new Map([
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/jpg', '.jpg'],
|
||||
['image/png', '.png'],
|
||||
['image/gif', '.gif'],
|
||||
['image/webp', '.webp'],
|
||||
['image/avif', '.avif'],
|
||||
['image/heic', '.heic'],
|
||||
['image/heif', '.heif'],
|
||||
]);
|
||||
|
||||
export async function getActiveStorageConfig(): Promise<StorageConfigRecord | undefined> {
|
||||
return database.one<StorageConfigRecord>(`
|
||||
SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix,
|
||||
@@ -46,6 +71,95 @@ function objectUrl(config: StorageConfigRecord, key: string): string {
|
||||
return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
function configuredOrigins(config: StorageConfigRecord): Set<string> {
|
||||
const values = [config.cdn_domain, config.public_base_url, `https://${config.bucket}.cos.${config.region}.myqcloud.com`];
|
||||
return new Set(values.filter(Boolean).map((value) => new URL(value).origin));
|
||||
}
|
||||
|
||||
function isPrivateIpv4(address: string): boolean {
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a, b, c] = parts;
|
||||
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
||||
|| (a === 192 && b === 0 && c === 0) || (a === 192 && b === 0 && c === 2)
|
||||
|| (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100)
|
||||
|| (a === 203 && b === 0 && c === 113) || a >= 224;
|
||||
}
|
||||
|
||||
function isPrivateAddress(address: string): boolean {
|
||||
const normalized = address.toLowerCase().split('%')[0];
|
||||
if (isIP(normalized) === 4) return isPrivateIpv4(normalized);
|
||||
if (isIP(normalized) !== 6) return true;
|
||||
if (normalized.startsWith('::ffff:')) return isPrivateIpv4(normalized.slice(7));
|
||||
return normalized === '::' || normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd')
|
||||
|| normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea')
|
||||
|| normalized.startsWith('feb') || normalized.startsWith('ff');
|
||||
}
|
||||
|
||||
async function assertPublicRemote(url: URL): Promise<void> {
|
||||
assertRemoteUrlShape(url);
|
||||
const rawHost = url.hostname.toLowerCase();
|
||||
const host = rawHost.startsWith('[') && rawHost.endsWith(']') ? rawHost.slice(1, -1) : rawHost;
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) throw new StorageImportError(400, '外部图片地址不能指向本机或内网');
|
||||
let addresses: Array<{ address: string }>;
|
||||
try { addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true, verbatim: true }); }
|
||||
catch { throw new StorageImportError(422, '无法解析外部图片地址'); }
|
||||
if (!addresses.length || addresses.some((item) => isPrivateAddress(item.address))) {
|
||||
throw new StorageImportError(400, '外部图片地址不能指向本机、内网或保留地址');
|
||||
}
|
||||
}
|
||||
|
||||
function assertRemoteUrlShape(url: URL): void {
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new StorageImportError(400, '图片地址必须是公开的 HTTP/HTTPS URL');
|
||||
}
|
||||
if ((url.protocol === 'http:' && url.port && url.port !== '80') || (url.protocol === 'https:' && url.port && url.port !== '443')) {
|
||||
throw new StorageImportError(400, '外部图片地址只能使用标准 HTTP/HTTPS 端口');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRemoteImage(source: URL, redirectCount = 0): Promise<{ body: Buffer; contentType: string }> {
|
||||
await assertPublicRemote(source);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(source, { redirect: 'manual', signal: controller.signal, headers: { Accept: 'image/*', 'User-Agent': 'Delivery-Desk/1.0' } });
|
||||
} catch (error) {
|
||||
throw new StorageImportError(422, `外部图片下载失败:${error instanceof Error ? error.message : '网络错误'}`);
|
||||
}
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
await response.body?.cancel();
|
||||
if (!location || redirectCount >= MAX_REMOTE_REDIRECTS) throw new StorageImportError(422, '外部图片重定向无效或次数过多');
|
||||
return downloadRemoteImage(new URL(location, source), redirectCount + 1);
|
||||
}
|
||||
if (!response.ok || !response.body) { await response.body?.cancel(); throw new StorageImportError(422, `外部图片下载失败:HTTP ${response.status}`); }
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() || '';
|
||||
if (!IMAGE_CONTENT_TYPES.has(contentType)) { await response.body.cancel(); throw new StorageImportError(422, '外部地址返回的不是支持的图片类型'); }
|
||||
const declaredLength = Number(response.headers.get('content-length') || 0);
|
||||
if (declaredLength > MAX_REMOTE_IMAGE_BYTES) { await response.body.cancel(); throw new StorageImportError(413, '外部图片不能超过 20 MB'); }
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_REMOTE_IMAGE_BYTES) { await reader.cancel(); throw new StorageImportError(413, '外部图片不能超过 20 MB'); }
|
||||
chunks.push(value);
|
||||
}
|
||||
return { body: Buffer.concat(chunks), contentType };
|
||||
} catch (error) {
|
||||
if (error instanceof StorageImportError) throw error;
|
||||
throw new StorageImportError(422, `外部图片下载失败:${error instanceof Error ? error.message : '网络错误'}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function safeError(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return 'COS 连接失败';
|
||||
const item = error as { code?: string; statusCode?: number; message?: string };
|
||||
@@ -88,3 +202,29 @@ export async function storeUploadedFile(file: { filename: string; originalname?:
|
||||
fs.unlinkSync(file.path);
|
||||
return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key };
|
||||
}
|
||||
|
||||
export async function storeExternalImageUrl(value: string): Promise<StoredExternalImage> {
|
||||
const config = await getActiveStorageConfig();
|
||||
if (!config) throw new StorageImportError(409, '未启用腾讯云 COS 配置,无法导入外部图片 URL');
|
||||
const source = new URL(value);
|
||||
assertRemoteUrlShape(source);
|
||||
if (configuredOrigins(config).has(source.origin)) {
|
||||
return { url: source.toString(), width: 0, height: 0, storageProvider: 'tencent_cos', storageKey: source.pathname.replace(/^\/+/, '') };
|
||||
}
|
||||
|
||||
const { body, contentType } = await downloadRemoteImage(source);
|
||||
let metadata: sharp.Metadata;
|
||||
try { metadata = await sharp(body).metadata(); }
|
||||
catch { throw new StorageImportError(422, '外部地址返回的内容不是有效图片'); }
|
||||
const extension = IMAGE_CONTENT_TYPES.get(contentType)!;
|
||||
const prefix = cleanPrefix(config.path_prefix);
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
const key = `${prefix ? `${prefix}/` : ''}imports/${digest}${extension}`;
|
||||
const cos = createClient(config);
|
||||
try {
|
||||
await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: body, ContentLength: body.length, ContentType: contentType });
|
||||
} catch (error) {
|
||||
throw new StorageImportError(502, `外部图片转存 COS 失败:${safeError(error)}`);
|
||||
}
|
||||
return { url: objectUrl(config, key), width: metadata.width ?? 0, height: metadata.height ?? 0, storageProvider: 'tencent_cos', storageKey: key };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user