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