SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
1 h agolast push
JavaScript 56.7% TypeScript 42.6%
8.1 KB · 264 lines typescript
Raw Blame History
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