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(); /** * Initialize temporary directories */ export async function initFileStorage(): Promise { 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 any)) { 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 } ): 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 { 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 = { 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 { 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; }