File size: 4,929 Bytes
5ef6e9d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | 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<boolean>;
delete(): Promise<void>;
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<void> {
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<LocalFile> {
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<Response> {
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<string, string> = {
"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<void> {
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();
|