将合图处理从服务端迁移到浏览器端,使用 Web Worker 实现高性能打包算法,新增三栏布局界面和精灵动画预览功能 - 新增 atlasStore 状态管理,实现文件、配置、结果的统一管理 - 新增 atlas-packer 打包算法库(MaxRects/Shelf),支持浏览器端快速合图 - 新增 atlas-worker Web Worker,实现异步打包处理避免阻塞 UI - 新增三栏布局组件:FileListPanel、CanvasPreview、AtlasConfigPanel - 新增 AnimationPreviewDialog 支持精灵动画帧预览和帧率控制 - 优化所有工具页面的响应式布局和交互体验 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
306 lines
7.4 KiB
TypeScript
306 lines
7.4 KiB
TypeScript
import { promises as fs } from "fs";
|
|
import path from "path";
|
|
import { randomUUID } from "crypto";
|
|
|
|
/**
|
|
* File storage service for handling temporary files
|
|
* Files are automatically cleaned up after a specified TTL
|
|
*/
|
|
|
|
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
|
|
const DOWNLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "downloads");
|
|
const FILE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
|
|
|
|
// Allowed MIME types for image upload
|
|
const ALLOWED_IMAGE_TYPES = [
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"image/webp",
|
|
"image/gif",
|
|
"image/bmp",
|
|
"image/tiff",
|
|
] as const;
|
|
|
|
// Maximum file size (50MB)
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
|
|
// Track cleanup timeout
|
|
const cleanupTimeouts = new Map<string, NodeJS.Timeout>();
|
|
|
|
/**
|
|
* Initialize temporary directories
|
|
*/
|
|
export async function initFileStorage(): Promise<void> {
|
|
try {
|
|
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
|
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
|
|
} catch (error) {
|
|
console.error("Failed to initialize file storage:", error);
|
|
throw new Error("File storage initialization failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate image file type and size
|
|
*/
|
|
export function validateImageFile(
|
|
file: File | { name: string; size: number; type: string }
|
|
): { valid: boolean; error?: string } {
|
|
// Check file size
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return {
|
|
valid: false,
|
|
error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`,
|
|
};
|
|
}
|
|
|
|
// Check file size (minimum 100 bytes to avoid corrupt files)
|
|
if (file.size < 100) {
|
|
return {
|
|
valid: false,
|
|
error: "File is too small or corrupt",
|
|
};
|
|
}
|
|
|
|
// Check MIME type
|
|
if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as typeof ALLOWED_IMAGE_TYPES[number])) {
|
|
return {
|
|
valid: false,
|
|
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
// Check file extension matches MIME type
|
|
const ext = getFileExtension(file.name).toLowerCase();
|
|
const validExtensions = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"];
|
|
|
|
if (!validExtensions.includes(ext)) {
|
|
return {
|
|
valid: false,
|
|
error: `Invalid file extension. Allowed: ${validExtensions.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Get file extension from filename
|
|
*/
|
|
function getFileExtension(filename: string): string {
|
|
const parts = filename.split(".");
|
|
return parts.length > 1 ? parts[parts.length - 1] : "";
|
|
}
|
|
|
|
/**
|
|
* Sanitize filename to prevent path traversal
|
|
*/
|
|
export function sanitizeFilename(filename: string): string {
|
|
// Remove any directory separators and special characters
|
|
const sanitized = filename
|
|
.replace(/[\\/]/g, "") // Remove path separators
|
|
.replace(/\.{2,}/g, ".") // Remove double dots
|
|
.replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars
|
|
.substring(0, 255); // Limit length
|
|
|
|
return sanitized || "file";
|
|
}
|
|
|
|
/**
|
|
* Save uploaded file to temp storage
|
|
*/
|
|
export async function saveUploadedFile(
|
|
file: File | { name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }
|
|
): Promise<{ fileId: string; filePath: string; originalName: string; size: number; type: string }> {
|
|
await initFileStorage();
|
|
|
|
const fileId = randomUUID();
|
|
const sanitized = sanitizeFilename(file.name);
|
|
const ext = getFileExtension(sanitized);
|
|
const filename = `${fileId}.${ext}`;
|
|
const filePath = path.join(UPLOAD_DIR, filename);
|
|
|
|
// Save file
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
await fs.writeFile(filePath, buffer);
|
|
|
|
// Schedule cleanup
|
|
scheduleCleanup(fileId, filePath);
|
|
|
|
return {
|
|
fileId,
|
|
filePath,
|
|
originalName: file.name,
|
|
size: buffer.length,
|
|
type: file.type,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Save processed file to download directory
|
|
*/
|
|
export async function saveProcessedFile(
|
|
_fileId: string,
|
|
buffer: Buffer,
|
|
format: string,
|
|
originalName: string
|
|
): Promise<{ fileUrl: string; filename: string; size: number }> {
|
|
await initFileStorage();
|
|
|
|
const downloadId = randomUUID();
|
|
const sanitized = sanitizeFilename(originalName);
|
|
const nameWithoutExt = sanitized.replace(/\.[^.]+$/, "");
|
|
const filename = `${nameWithoutExt}_compressed.${format}`;
|
|
const filePath = path.join(DOWNLOAD_DIR, `${downloadId}_${filename}`);
|
|
|
|
await fs.writeFile(filePath, buffer);
|
|
|
|
// Schedule cleanup
|
|
scheduleCleanup(downloadId, filePath);
|
|
|
|
return {
|
|
fileUrl: `/api/download/${downloadId}`,
|
|
filename,
|
|
size: buffer.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get file from upload directory
|
|
*/
|
|
export async function getUploadedFile(fileId: string): Promise<Buffer | null> {
|
|
await initFileStorage();
|
|
|
|
try {
|
|
const files = await fs.readdir(UPLOAD_DIR);
|
|
const file = files.find((f) => f.startsWith(`${fileId}.`));
|
|
|
|
if (!file) {
|
|
return null;
|
|
}
|
|
|
|
const filePath = path.join(UPLOAD_DIR, file);
|
|
return await fs.readFile(filePath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get processed file for download
|
|
*/
|
|
export async function getProcessedFile(downloadId: string): Promise<
|
|
| { buffer: Buffer; filename: string; contentType: string }[]
|
|
| null
|
|
> {
|
|
await initFileStorage();
|
|
|
|
try {
|
|
const files = await fs.readdir(DOWNLOAD_DIR);
|
|
const file = files.find((f) => f.startsWith(`${downloadId}_`));
|
|
|
|
if (!file) {
|
|
return null;
|
|
}
|
|
|
|
const filePath = path.join(DOWNLOAD_DIR, file);
|
|
const buffer = await fs.readFile(filePath);
|
|
|
|
// Extract filename
|
|
const filename = file.substring(downloadId.length + 1);
|
|
|
|
// Determine content type
|
|
const ext = getFileExtension(filename);
|
|
const contentTypes: Record<string, string> = {
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
png: "image/png",
|
|
webp: "image/webp",
|
|
gif: "image/gif",
|
|
bmp: "image/bmp",
|
|
tif: "image/tiff",
|
|
tiff: "image/tiff",
|
|
};
|
|
|
|
return [
|
|
{
|
|
buffer,
|
|
filename,
|
|
contentType: contentTypes[ext] || "application/octet-stream",
|
|
},
|
|
];
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule file cleanup
|
|
*/
|
|
function scheduleCleanup(fileId: string, filePath: string): void {
|
|
// Clear existing timeout if any
|
|
const existing = cleanupTimeouts.get(fileId);
|
|
if (existing) {
|
|
clearTimeout(existing);
|
|
}
|
|
|
|
// Schedule new cleanup
|
|
const timeout = setTimeout(async () => {
|
|
try {
|
|
await fs.unlink(filePath);
|
|
cleanupTimeouts.delete(fileId);
|
|
} catch (error) {
|
|
console.error(`Failed to cleanup file ${fileId}:`, error);
|
|
}
|
|
}, FILE_TTL);
|
|
|
|
cleanupTimeouts.set(fileId, timeout);
|
|
}
|
|
|
|
/**
|
|
* Manually cleanup a file
|
|
*/
|
|
export async function cleanupFile(fileId: string): Promise<void> {
|
|
const timeout = cleanupTimeouts.get(fileId);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
cleanupTimeouts.delete(fileId);
|
|
}
|
|
|
|
// Try to delete from both directories
|
|
try {
|
|
const files = await fs.readdir(UPLOAD_DIR);
|
|
const file = files.find((f) => f.startsWith(`${fileId}.`));
|
|
if (file) {
|
|
await fs.unlink(path.join(UPLOAD_DIR, file));
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
|
|
try {
|
|
const files = await fs.readdir(DOWNLOAD_DIR);
|
|
const file = files.find((f) => f.startsWith(`${fileId}_`));
|
|
if (file) {
|
|
await fs.unlink(path.join(DOWNLOAD_DIR, file));
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get allowed image types
|
|
*/
|
|
export function getAllowedImageTypes(): readonly string[] {
|
|
return ALLOWED_IMAGE_TYPES;
|
|
}
|
|
|
|
/**
|
|
* Get maximum file size
|
|
*/
|
|
export function getMaxFileSize(): number {
|
|
return MAX_FILE_SIZE;
|
|
}
|