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();