JavaScript 56.7%
TypeScript 42.6%
1import { createReadStream, createWriteStream, existsSync, mkdirSync } from "fs";2import { unlink, copyFile, readdir, rm, stat, readFile, writeFile } from "fs/promises";3import path from "path";4import { v4 as uuidv4 } from "uuid";5import { ReadStream } from "fs";6import sharp from "sharp";7import { encryptBuffer, decryptBuffer } from "./filecrypto";89const UPLOAD_DIR = process.env.UPLOAD_DIR || "./uploads";10const FILES_DIR = path.join(UPLOAD_DIR, "files");11const CHUNKS_DIR = path.join(UPLOAD_DIR, "chunks");12const VERSIONS_DIR = path.join(UPLOAD_DIR, "versions");13const THUMBS_DIR = path.join(UPLOAD_DIR, "thumbnails");1415// Ensure directories exist16[FILES_DIR, CHUNKS_DIR, VERSIONS_DIR, THUMBS_DIR].forEach((dir) => {17 if (!existsSync(dir)) mkdirSync(dir, { recursive: true });18});1920function getExtension(filename: string): string {21 const ext = path.extname(filename);22 return ext || "";23}2425export async function saveFile(26 buffer: Buffer,27 originalName: string28): Promise<{ storagePath: string; size: number }> {29 const ext = getExtension(originalName);30 const storagePath = `${uuidv4()}${ext}`;31 const fullPath = path.join(FILES_DIR, storagePath);3233 // Chiffrement au repos (le fichier sur disque est chiffré ; size = taille en clair)34 await writeFile(fullPath, encryptBuffer(buffer));3536 return { storagePath, size: buffer.length };37}3839export async function saveChunk(40 buffer: Buffer,41 uploadId: string,42 chunkIndex: number43): Promise<void> {44 const chunkDir = path.join(CHUNKS_DIR, uploadId);45 if (!existsSync(chunkDir)) mkdirSync(chunkDir, { recursive: true });4647 const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`);48 await new Promise<void>((resolve, reject) => {49 const stream = createWriteStream(chunkPath);50 stream.write(buffer);51 stream.end();52 stream.on("finish", resolve);53 stream.on("error", reject);54 });55}5657export async function assembleChunks(58 uploadId: string,59 totalChunks: number,60 originalName: string61): Promise<{ storagePath: string; size: number }> {62 const ext = getExtension(originalName);63 const storagePath = `${uuidv4()}${ext}`;64 const fullPath = path.join(FILES_DIR, storagePath);65 const chunkDir = path.join(CHUNKS_DIR, uploadId);6667 // Réassemble en mémoire puis chiffre (size = taille en clair)68 const parts: Buffer[] = [];69 for (let i = 0; i < totalChunks; i++) {70 const chunkPath = path.join(chunkDir, `chunk-${i}`);71 parts.push(await readFile(chunkPath));72 }73 const full = Buffer.concat(parts);74 await writeFile(fullPath, encryptBuffer(full));7576 // Clean up chunks77 await rm(chunkDir, { recursive: true, force: true });7879 return { storagePath, size: full.length };80}8182export async function deleteFile(storagePath: string): Promise<void> {83 const fullPath = path.join(FILES_DIR, storagePath);84 if (existsSync(fullPath)) {85 await unlink(fullPath);86 }87}8889export async function duplicateFile(90 storagePath: string,91 originalName: string92): Promise<{ storagePath: string; size: number }> {93 const ext = getExtension(originalName);94 const newStoragePath = `${uuidv4()}${ext}`;95 const srcPath = path.join(FILES_DIR, storagePath);96 const destPath = path.join(FILES_DIR, newStoragePath);9798 await copyFile(srcPath, destPath);99 const stats = await stat(destPath);100101 return { storagePath: newStoragePath, size: Number(stats.size) };102}103104export function getFilePath(storagePath: string): string {105 return path.resolve(FILES_DIR, storagePath);106}107108export function getFileStream(storagePath: string): ReadStream {109 const fullPath = path.join(FILES_DIR, storagePath);110 return createReadStream(fullPath);111}112113export async function getStorageUsage(): Promise<{114 totalSize: number;115 fileCount: number;116}> {117 let totalSize = 0;118 let fileCount = 0;119120 if (existsSync(FILES_DIR)) {121 const files = await readdir(FILES_DIR);122 for (const file of files) {123 const filePath = path.join(FILES_DIR, file);124 const stats = await stat(filePath);125 totalSize += stats.size;126 fileCount++;127 }128 }129130 return { totalSize, fileCount };131}132133// --- Version management ---134135export async function saveVersion(136 currentStoragePath: string,137 originalName: string138): Promise<{ storagePath: string; size: number }> {139 const ext = getExtension(originalName);140 const versionStoragePath = `${uuidv4()}${ext}`;141 const srcPath = path.join(FILES_DIR, currentStoragePath);142 const destPath = path.join(VERSIONS_DIR, versionStoragePath);143144 await copyFile(srcPath, destPath);145 const stats = await stat(destPath);146147 return { storagePath: versionStoragePath, size: Number(stats.size) };148}149150export function getVersionPath(storagePath: string): string {151 return path.resolve(VERSIONS_DIR, storagePath);152}153154export async function deleteVersion(storagePath: string): Promise<void> {155 const fullPath = path.join(VERSIONS_DIR, storagePath);156 if (existsSync(fullPath)) {157 await unlink(fullPath);158 }159}160161export async function restoreVersion(162 versionStoragePath: string,163 currentStoragePath: string164): Promise<void> {165 const srcPath = path.join(VERSIONS_DIR, versionStoragePath);166 const destPath = path.join(FILES_DIR, currentStoragePath);167 await copyFile(srcPath, destPath);168}169170export async function saveFileFromBuffer(171 buffer: Buffer,172 storagePath: string173): Promise<void> {174 // Chiffre au repos (écrase le fichier existant avec le contenu chiffré)175 await writeFile(path.join(FILES_DIR, storagePath), encryptBuffer(buffer));176}177178// --- Thumbnails (rendus image redimensionnés, mis en cache sur disque) ---179180// Évite de régénérer la même miniature plusieurs fois en parallèle181const inflightThumbs = new Map<string, Promise<Buffer>>();182183/**184 * Retourne le chemin d'une miniature webp de largeur `width` pour le fichier image.185 * Génère et met en cache à la première demande ; régénère si l'original a changé.186 */187/**188 * Retourne les octets webp d'une miniature de largeur `width` (déchiffrés, prêts à servir).189 * Cache disque CHIFFRÉ (`<id>-<w>.webp.enc`). Régénère si l'original a changé.190 */191export async function getThumbnail(192 storagePath: string,193 fileId: string,194 width: number,195 isEncrypted: boolean196): Promise<Buffer> {197 const srcPath = path.join(FILES_DIR, storagePath);198 const cachePath = path.join(THUMBS_DIR, `${fileId}-${width}.webp.enc`);199200 let fresh = false;201 if (existsSync(cachePath)) {202 try {203 const [src, thumb] = await Promise.all([stat(srcPath), stat(cachePath)]);204 fresh = thumb.mtimeMs >= src.mtimeMs;205 } catch {206 fresh = false;207 }208 }209210 if (fresh) {211 return decryptBuffer(await readFile(cachePath));212 }213214 let job = inflightThumbs.get(cachePath);215 if (!job) {216 job = (async () => {217 const raw = await readFile(srcPath);218 const src = isEncrypted ? decryptBuffer(raw) : raw;219 const webp = await sharp(src, { failOn: "none" })220 .rotate() // respecte l'orientation EXIF221 .resize({ width, withoutEnlargement: true })222 .webp({ quality: 80 })223 .toBuffer();224 await writeFile(cachePath, encryptBuffer(webp)); // cache chiffré au repos225 return webp;226 })().finally(() => inflightThumbs.delete(cachePath));227 inflightThumbs.set(cachePath, job);228 }229 return job;230}231232/** Lit un fichier et le déchiffre si nécessaire → buffer en clair prêt à servir. */233export async function loadFileData(234 storagePath: string,235 isEncrypted: boolean236): Promise<Buffer> {237 const raw = await readFile(path.join(FILES_DIR, storagePath));238 return isEncrypted ? decryptBuffer(raw) : raw;239}240241/** Lit une version (dans VERSIONS_DIR) et la déchiffre si nécessaire. */242export async function loadVersionData(243 storagePath: string,244 isEncrypted: boolean245): Promise<Buffer> {246 const raw = await readFile(path.join(VERSIONS_DIR, storagePath));247 return isEncrypted ? decryptBuffer(raw) : raw;248}249250/** Supprime toutes les miniatures en cache d'un fichier (à appeler à la suppression). */251export async function deleteThumbnails(fileId: string): Promise<void> {252 if (!existsSync(THUMBS_DIR)) return;253 try {254 const entries = await readdir(THUMBS_DIR);255 await Promise.all(256 entries257 .filter((name) => name.startsWith(`${fileId}-`))258 .map((name) => unlink(path.join(THUMBS_DIR, name)).catch(() => {}))259 );260 } catch {261 // ignore262 }263}264