import { AgentApiError } from './api-error-response'; import { resolveImageOutputDir } from './server-runtime'; import crypto from 'crypto'; import fs from 'fs/promises'; import path from 'path'; export type ImageDimensions = { width: number | null; height: number | null; }; export type DetectedImageFormat = { outputFormat: 'png' | 'jpeg' | 'webp'; mimeType: string; }; type ImageFormatFallback = DetectedImageFormat['outputFormat'] | 'jpg'; export function mimeTypeForOutputFormat(outputFormat: string): string { if (outputFormat === 'jpeg' || outputFormat === 'jpg') return 'image/jpeg'; if (outputFormat === 'webp') return 'image/webp'; return 'image/png'; } export function detectImageFormat(buffer: Buffer, fallbackOutputFormat: ImageFormatFallback): DetectedImageFormat { if (isPng(buffer)) return { outputFormat: 'png', mimeType: 'image/png' }; if (isJpeg(buffer)) return { outputFormat: 'jpeg', mimeType: 'image/jpeg' }; if (isWebp(buffer)) return { outputFormat: 'webp', mimeType: 'image/webp' }; const outputFormat = fallbackOutputFormat === 'jpg' ? 'jpeg' : fallbackOutputFormat; return { outputFormat, mimeType: mimeTypeForOutputFormat(fallbackOutputFormat) }; } export async function writeFileAtomic(filepath: string, buffer: Buffer): Promise { await fs.mkdir(path.dirname(filepath), { recursive: true }); const tmpPath = `${filepath}.tmp-${crypto.randomUUID()}`; try { await fs.writeFile(tmpPath, buffer); await fs.rename(tmpPath, filepath); } catch (error) { try { await deleteFileIfExists(tmpPath); } catch (cleanupError) { console.error('清理临时产物文件失败。', cleanupError); } throw error; } } export async function deleteFileIfExists(filepath: string): Promise { try { await fs.unlink(filepath); return true; } catch (error) { if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { return false; } throw error; } } export type MovedFileForDeletion = { originalPath: string; tempPath: string; }; export async function moveFileIfExists(filepath: string): Promise { const tempPath = `${filepath}.purge-${crypto.randomUUID()}`; try { await fs.rename(filepath, tempPath); return { originalPath: filepath, tempPath }; } catch (error) { if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { return undefined; } throw error; } } export async function restoreMovedFile(file: MovedFileForDeletion): Promise { await fs.rename(file.tempPath, file.originalPath); } export async function discardMovedFile(file: MovedFileForDeletion): Promise { await fs.rm(file.tempPath, { force: true, recursive: true }); } export async function moveArtifactFilesForDeletion(filepaths: string[]): Promise { const movedFiles: MovedFileForDeletion[] = []; try { for (const filepath of filepaths) { const moved = await moveFileIfExists(filepath); if (moved) { movedFiles.push(moved); } } return movedFiles; } catch (error) { await restoreArtifactFiles(movedFiles); throw error; } } export async function restoreArtifactFiles(files: MovedFileForDeletion[]): Promise { await Promise.allSettled(files.map((file) => restoreMovedFile(file))); } export async function discardArtifactFiles(files: MovedFileForDeletion[]): Promise { await Promise.allSettled(files.map((file) => discardMovedFile(file))); } export function assertArtifactFilepathAllowed(filepath: string): void { if (isArtifactFilepathAllowed(filepath)) return; throw new AgentApiError({ code: 'artifact_not_found', message: '产物文件路径位于已配置图片目录之外。', status: 404, retryable: false }); } export function isArtifactFilepathAllowed(filepath: string): boolean { const resolvedFilepath = path.resolve(filepath); const resolvedOutputDir = resolveImageOutputDir(); return resolvedFilepath === resolvedOutputDir || resolvedFilepath.startsWith(`${resolvedOutputDir}${path.sep}`); } export async function deleteArtifactFileIfAllowed(filepath: string): Promise { assertArtifactFilepathAllowed(filepath); return deleteFileIfExists(filepath); } export function readImageDimensions(buffer: Buffer): ImageDimensions { const png = readPngDimensions(buffer); if (png.width !== null) return png; const jpeg = readJpegDimensions(buffer); if (jpeg.width !== null) return jpeg; const webp = readWebpDimensions(buffer); return webp; } function readPngDimensions(buffer: Buffer): ImageDimensions { if (buffer.length >= 24 && isPng(buffer) && buffer.toString('ascii', 12, 16) === 'IHDR') { return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; } return { width: null, height: null }; } function readJpegDimensions(buffer: Buffer): ImageDimensions { if (!isJpeg(buffer)) { return { width: null, height: null }; } let offset = 2; while (offset + 9 < buffer.length) { if (buffer[offset] !== 0xff) { offset += 1; continue; } const marker = buffer[offset + 1]; const length = buffer.readUInt16BE(offset + 2); if (length < 2) break; if ( (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) ) { return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; } offset += 2 + length; } return { width: null, height: null }; } function readWebpDimensions(buffer: Buffer): ImageDimensions { if (!isWebp(buffer) || buffer.length < 30) { return { width: null, height: null }; } const chunk = buffer.toString('ascii', 12, 16); if (chunk === 'VP8X' && buffer.length >= 30) { return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) }; } if (chunk === 'VP8 ' && buffer.length >= 30) { return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }; } if (chunk === 'VP8L' && buffer.length >= 25) { const b0 = buffer[21]; const b1 = buffer[22]; const b2 = buffer[23]; const b3 = buffer[24]; return { width: 1 + (((b1 & 0x3f) << 8) | b0), height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) }; } return { width: null, height: null }; } function isPng(buffer: Buffer): boolean { return ( buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a ); } function isJpeg(buffer: Buffer): boolean { return buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8; } function isWebp(buffer: Buffer): boolean { return ( buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP' ); }