Files
delivery-desk/api/storage.ts

235 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<StorageConfigRecord | undefined> {
return database.one<StorageConfigRecord>(`
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<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, '外部图片地址不能指向本机、内网或保留地址');
}
}
export async function assertPublicHttpUrl(value: string): Promise<void> {
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<string> {
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<StoredUpload> {
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<StoredExternalImage> {
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 };
}