import { createReadStream, createWriteStream, existsSync, mkdirSync } from "fs"; import { unlink, copyFile, readdir, rm, stat, readFile, writeFile } from "fs/promises"; import path from "path"; import { v4 as uuidv4 } from "uuid"; import { ReadStream } from "fs"; import sharp from "sharp"; import { encryptBuffer, decryptBuffer } from "./filecrypto"; const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads"; const FILES_DIR = path.join(UPLOAD_DIR, "files"); const CHUNKS_DIR = path.join(UPLOAD_DIR, "chunks"); const VERSIONS_DIR = path.join(UPLOAD_DIR, "versions"); const THUMBS_DIR = path.join(UPLOAD_DIR, "thumbnails"); // Ensure directories exist [FILES_DIR, CHUNKS_DIR, VERSIONS_DIR, THUMBS_DIR].forEach((dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); }); function getExtension(filename: string): string { const ext = path.extname(filename); return ext || ""; } export async function saveFile( buffer: Buffer, originalName: string ): Promise<{ storagePath: string; size: number }> { const ext = getExtension(originalName); const storagePath = `${uuidv4()}${ext}`; const fullPath = path.join(FILES_DIR, storagePath); // Chiffrement au repos (le fichier sur disque est chiffré ; size = taille en clair) await writeFile(fullPath, encryptBuffer(buffer)); return { storagePath, size: buffer.length }; } export async function saveChunk( buffer: Buffer, uploadId: string, chunkIndex: number ): Promise { const chunkDir = path.join(CHUNKS_DIR, uploadId); if (!existsSync(chunkDir)) mkdirSync(chunkDir, { recursive: true }); const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`); await new Promise((resolve, reject) => { const stream = createWriteStream(chunkPath); stream.write(buffer); stream.end(); stream.on("finish", resolve); stream.on("error", reject); }); } export async function assembleChunks( uploadId: string, totalChunks: number, originalName: string ): Promise<{ storagePath: string; size: number }> { const ext = getExtension(originalName); const storagePath = `${uuidv4()}${ext}`; const fullPath = path.join(FILES_DIR, storagePath); const chunkDir = path.join(CHUNKS_DIR, uploadId); // Réassemble en mémoire puis chiffre (size = taille en clair) const parts: Buffer[] = []; for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunkDir, `chunk-${i}`); parts.push(await readFile(chunkPath)); } const full = Buffer.concat(parts); await writeFile(fullPath, encryptBuffer(full)); // Clean up chunks await rm(chunkDir, { recursive: true, force: true }); return { storagePath, size: full.length }; } export async function deleteFile(storagePath: string): Promise { const fullPath = path.join(FILES_DIR, storagePath); if (existsSync(fullPath)) { await unlink(fullPath); } } export async function duplicateFile( storagePath: string, originalName: string ): Promise<{ storagePath: string; size: number }> { const ext = getExtension(originalName); const newStoragePath = `${uuidv4()}${ext}`; const srcPath = path.join(FILES_DIR, storagePath); const destPath = path.join(FILES_DIR, newStoragePath); await copyFile(srcPath, destPath); const stats = await stat(destPath); return { storagePath: newStoragePath, size: Number(stats.size) }; } export function getFilePath(storagePath: string): string { return path.resolve(FILES_DIR, storagePath); } export function getFileStream(storagePath: string): ReadStream { const fullPath = path.join(FILES_DIR, storagePath); return createReadStream(fullPath); } export async function getStorageUsage(): Promise<{ totalSize: number; fileCount: number; }> { let totalSize = 0; let fileCount = 0; if (existsSync(FILES_DIR)) { const files = await readdir(FILES_DIR); for (const file of files) { const filePath = path.join(FILES_DIR, file); const stats = await stat(filePath); totalSize += stats.size; fileCount++; } } return { totalSize, fileCount }; } // --- Version management --- export async function saveVersion( currentStoragePath: string, originalName: string ): Promise<{ storagePath: string; size: number }> { const ext = getExtension(originalName); const versionStoragePath = `${uuidv4()}${ext}`; const srcPath = path.join(FILES_DIR, currentStoragePath); const destPath = path.join(VERSIONS_DIR, versionStoragePath); await copyFile(srcPath, destPath); const stats = await stat(destPath); return { storagePath: versionStoragePath, size: Number(stats.size) }; } export function getVersionPath(storagePath: string): string { return path.resolve(VERSIONS_DIR, storagePath); } export async function deleteVersion(storagePath: string): Promise { const fullPath = path.join(VERSIONS_DIR, storagePath); if (existsSync(fullPath)) { await unlink(fullPath); } } export async function restoreVersion( versionStoragePath: string, currentStoragePath: string ): Promise { const srcPath = path.join(VERSIONS_DIR, versionStoragePath); const destPath = path.join(FILES_DIR, currentStoragePath); await copyFile(srcPath, destPath); } export async function saveFileFromBuffer( buffer: Buffer, storagePath: string ): Promise { // Chiffre au repos (écrase le fichier existant avec le contenu chiffré) await writeFile(path.join(FILES_DIR, storagePath), encryptBuffer(buffer)); } // --- Thumbnails (rendus image redimensionnés, mis en cache sur disque) --- // Évite de régénérer la même miniature plusieurs fois en parallèle const inflightThumbs = new Map>(); /** * Retourne le chemin d'une miniature webp de largeur `width` pour le fichier image. * Génère et met en cache à la première demande ; régénère si l'original a changé. */ /** * Retourne les octets webp d'une miniature de largeur `width` (déchiffrés, prêts à servir). * Cache disque CHIFFRÉ (`-.webp.enc`). Régénère si l'original a changé. */ export async function getThumbnail( storagePath: string, fileId: string, width: number, isEncrypted: boolean ): Promise { const srcPath = path.join(FILES_DIR, storagePath); const cachePath = path.join(THUMBS_DIR, `${fileId}-${width}.webp.enc`); let fresh = false; if (existsSync(cachePath)) { try { const [src, thumb] = await Promise.all([stat(srcPath), stat(cachePath)]); fresh = thumb.mtimeMs >= src.mtimeMs; } catch { fresh = false; } } if (fresh) { return decryptBuffer(await readFile(cachePath)); } let job = inflightThumbs.get(cachePath); if (!job) { job = (async () => { const raw = await readFile(srcPath); const src = isEncrypted ? decryptBuffer(raw) : raw; const webp = await sharp(src, { failOn: "none" }) .rotate() // respecte l'orientation EXIF .resize({ width, withoutEnlargement: true }) .webp({ quality: 80 }) .toBuffer(); await writeFile(cachePath, encryptBuffer(webp)); // cache chiffré au repos return webp; })().finally(() => inflightThumbs.delete(cachePath)); inflightThumbs.set(cachePath, job); } return job; } /** Lit un fichier et le déchiffre si nécessaire → buffer en clair prêt à servir. */ export async function loadFileData( storagePath: string, isEncrypted: boolean ): Promise { const raw = await readFile(path.join(FILES_DIR, storagePath)); return isEncrypted ? decryptBuffer(raw) : raw; } /** Lit une version (dans VERSIONS_DIR) et la déchiffre si nécessaire. */ export async function loadVersionData( storagePath: string, isEncrypted: boolean ): Promise { const raw = await readFile(path.join(VERSIONS_DIR, storagePath)); return isEncrypted ? decryptBuffer(raw) : raw; } /** Supprime toutes les miniatures en cache d'un fichier (à appeler à la suppression). */ export async function deleteThumbnails(fileId: string): Promise { if (!existsSync(THUMBS_DIR)) return; try { const entries = await readdir(THUMBS_DIR); await Promise.all( entries .filter((name) => name.startsWith(`${fileId}-`)) .map((name) => unlink(path.join(THUMBS_DIR, name)).catch(() => {})) ); } catch { // ignore } }