import COS from 'cos-nodejs-sdk-v5'; import fs from 'fs'; import path from 'path'; 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'; export interface StorageConfigRecord { id: number; region: string; bucket: string; public_base_url: string; cdn_domain: string; path_prefix: string; secret_id_encrypted: string; secret_key_encrypted: string; } export interface StoredUpload { url: string; storageProvider: 'local' | 'tencent_cos'; 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 { return database.one(` SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix, secret_id_encrypted, secret_key_encrypted FROM storage_configs WHERE status = 'active' ORDER BY id DESC LIMIT 1 `); } function createClient(config: StorageConfigRecord) { return new COS({ SecretId: decryptSecret(config.secret_id_encrypted), SecretKey: decryptSecret(config.secret_key_encrypted), }); } function cleanPrefix(value: string): string { return value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/'); } function objectUrl(config: StorageConfigRecord, key: string): string { const base = (config.cdn_domain || config.public_base_url || `https://${config.bucket}.cos.${config.region}.myqcloud.com`).replace(/\/+$/, ''); return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`; } function configuredOrigins(config: StorageConfigRecord): Set { 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 { 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, '外部图片地址不能指向本机、内网或保留地址'); } } export async function assertPublicHttpUrl(value: string): Promise { await assertPublicRemote(new URL(value)); } 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 }; return [item.code, item.statusCode, item.message].filter(Boolean).join(' · ').slice(0, 300) || 'COS 连接失败'; } export async function testStorageConfig(config: StorageConfigRecord): Promise { const cos = createClient(config); const prefix = cleanPrefix(config.path_prefix); const key = `${prefix ? `${prefix}/` : ''}.delivery-desk-check/${randomUUID()}.txt`; try { await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: Buffer.from('delivery-desk storage check'), ContentType: 'text/plain' }); await cos.getObject({ Bucket: config.bucket, Region: config.region, Key: key }); await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key }); return '上传、读取和删除测试均已通过'; } catch (error) { try { await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key }); } catch { /* best-effort cleanup */ } throw new Error(safeError(error)); } } export async function storeUploadedFile(file: { filename: string; originalname?: string; mimetype?: string; path: string }): Promise { const config = await getActiveStorageConfig(); if (!config) return { url: file.filename, storageProvider: 'local', storageKey: file.filename }; const prefix = cleanPrefix(config.path_prefix); const now = new Date(); const extension = path.extname(file.originalname || file.filename).toLowerCase() || '.jpg'; const month = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`; const key = `${prefix ? `${prefix}/` : ''}originals/${month}/${randomUUID()}${extension}`; const cos = createClient(config); await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: fs.createReadStream(file.path), ContentLength: fs.statSync(file.path).size, ContentType: file.mimetype || 'application/octet-stream', }); fs.unlinkSync(file.path); return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key }; } export async function storeExternalImageUrl(value: string): Promise { const config = await getActiveStorageConfig(); if (!config) throw new StorageImportError(409, '未启用腾讯云 COS 配置,无法导入外部图片 URL'); const source = new URL(value); await assertPublicRemote(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 }; }