import { randomUUID } from "crypto"; import fs from "fs"; import path from "path"; import { Readable } from "stream"; const TEMP_STORAGE_PATH = process.env.TEMP_STORAGE_PATH || "/app/temp-storage"; const MAX_FILE_AGE_MS = 24 * 60 * 60 * 1000; // 24 小時 export class ObjectNotFoundError extends Error { constructor() { super("Object not found"); this.name = "ObjectNotFoundError"; Object.setPrototypeOf(this, ObjectNotFoundError.prototype); } } export interface LocalFile { path: string; url: string; exists(): Promise; delete(): Promise; getMetadata(): Promise<{ contentType: string; size: number }>; createReadStream(): fs.ReadStream; } export class LocalTempStorageService { private storagePath: string; constructor() { this.storagePath = TEMP_STORAGE_PATH; this.ensureStorageDir(); this.startCleanupInterval(); } private ensureStorageDir(): void { if (!fs.existsSync(this.storagePath)) { fs.mkdirSync(this.storagePath, { recursive: true }); } } // 定期清理舊檔案 private startCleanupInterval(): void { setInterval(() => { this.cleanupOldFiles().catch((err) => { console.error("[LocalTempStorage] Cleanup error:", err); }); }, 60 * 60 * 1000); // 每小時執行一次 } private async cleanupOldFiles(): Promise { try { const now = Date.now(); const files = fs.readdirSync(this.storagePath); let deletedCount = 0; for (const file of files) { const filePath = path.join(this.storagePath, file); const stats = fs.statSync(filePath); if (now - stats.mtimeMs > MAX_FILE_AGE_MS) { fs.unlinkSync(filePath); deletedCount++; } } if (deletedCount > 0) { console.log(`[LocalTempStorage] Cleaned up ${deletedCount} old files`); } } catch (error) { console.error("[LocalTempStorage] Error during cleanup:", error); } } async saveFile(buffer: Buffer, filename: string, contentType: string = "application/octet-stream"): Promise { const fileId = randomUUID(); const ext = path.extname(filename) || ""; const savedFilename = `${fileId}${ext}`; const filePath = path.join(this.storagePath, savedFilename); fs.writeFileSync(filePath, buffer); // 儲存 metadata const metadataPath = `${filePath}.meta`; fs.writeFileSync(metadataPath, JSON.stringify({ contentType, originalName: filename })); return this.getFile(savedFilename); } getFile(filename: string): LocalFile { const filePath = path.join(this.storagePath, filename); const url = `/api/temp-storage/${filename}`; return { path: filePath, url, async exists() { return fs.existsSync(filePath); }, async delete() { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } const metadataPath = `${filePath}.meta`; if (fs.existsSync(metadataPath)) { fs.unlinkSync(metadataPath); } }, async getMetadata() { const metadataPath = `${filePath}.meta`; if (fs.existsSync(metadataPath)) { const meta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); const stats = fs.statSync(filePath); return { contentType: meta.contentType || "application/octet-stream", size: stats.size, }; } const stats = fs.statSync(filePath); return { contentType: "application/octet-stream", size: stats.size, }; }, createReadStream() { return fs.createReadStream(filePath); }, }; } async downloadFile(file: LocalFile): Promise { const exists = await file.exists(); if (!exists) { throw new ObjectNotFoundError(); } const metadata = await file.getMetadata(); const nodeStream = file.createReadStream(); const webStream = Readable.toWeb(nodeStream) as ReadableStream; const headers: Record = { "Content-Type": metadata.contentType, "Content-Length": String(metadata.size), "Cache-Control": "public, max-age=3600", }; return new Response(webStream, { headers }); } // 從 URL 提取檔案名稱 extractFilenameFromUrl(url: string): string | null { const match = url.match(/\/api\/temp-storage\/([^?]+)/); return match ? match[1] : null; } // 清理所有檔案 (用於測試或維護) async cleanupAll(): Promise { const files = fs.readdirSync(this.storagePath); for (const file of files) { const filePath = path.join(this.storagePath, file); fs.unlinkSync(filePath); } console.log(`[LocalTempStorage] Cleaned up all ${files.length} files`); } } // 單例實例 export const localTempStorage = new LocalTempStorageService();