feat: 实现图片压缩功能
This commit is contained in:
305
src/lib/file-storage.ts
Normal file
305
src/lib/file-storage.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
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 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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user