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.8 KB · 192 lines typescript
Raw Blame History
1/**2 * Corbeille cohérente : mise à la corbeille (fichiers et dossiers récursifs), restauration fidèle,3 * purge réelle (disque + versions + vignettes), purge automatique 30 jours, utilitaires d'arborescence.4 */5import { existsSync } from "fs";6import { readdir, rm, stat } from "fs/promises";7import path from "path";8import { prisma } from "./prisma";9import { deleteFile, deleteThumbnails, deleteVersion } from "./storage";10import { logActivity } from "./activity";1112export const TRASH_RETENTION_DAYS = 30;1314export interface OriginPath { spaceId: string | null; userId: string | null; names: string[] }1516/** Identifiant d'espace partagé effectif d'un dossier (hérité en remontant l'arborescence). */17export async function getSpaceIdForFolder(folderId: string | null | undefined): Promise<string | null> {18  let cur = folderId ?? null;19  const seen = new Set<string>();20  while (cur && !seen.has(cur)) {21    seen.add(cur);22    const f: { parentId: string | null; sharedSpaceId: string | null } | null =23      await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true, sharedSpaceId: true } });24    if (!f) return null;25    if (f.sharedSpaceId) return f.sharedSpaceId;26    cur = f.parentId;27  }28  return null;29}3031/** Chemin (noms) d'un dossier depuis la racine + espace/propriétaire. */32export async function getOriginPath(folderId: string): Promise<OriginPath | null> {33  const names: string[] = [];34  let spaceId: string | null = null;35  let userId: string | null = null;36  let cur: string | null = folderId;37  const seen = new Set<string>();38  while (cur && !seen.has(cur)) {39    seen.add(cur);40    const f: { name: string; parentId: string | null; sharedSpaceId: string | null; userId: string | null } | null =41      await prisma.folder.findUnique({ where: { id: cur }, select: { name: true, parentId: true, sharedSpaceId: true, userId: true } });42    if (!f) return null;43    names.unshift(f.name);44    if (f.sharedSpaceId && !spaceId) spaceId = f.sharedSpaceId;45    if (f.userId && !userId) userId = f.userId;46    cur = f.parentId;47  }48  return { spaceId, userId, names };49}5051/** Tous les identifiants de dossiers du sous-arbre (racine incluse), en largeur. */52export async function collectFolderTree(rootId: string): Promise<string[]> {53  const ids = [rootId];54  const seen = new Set(ids);55  for (let i = 0; i < ids.length; i++) {56    const kids = await prisma.folder.findMany({ where: { parentId: ids[i] }, select: { id: true } });57    for (const k of kids) if (!seen.has(k.id)) { seen.add(k.id); ids.push(k.id); }58  }59  return ids;60}6162/** Vérifie que `folderId` est `ancestorId` ou l'un de ses descendants. */63export async function isDescendantOf(folderId: string, ancestorId: string): Promise<boolean> {64  let cur: string | null = folderId;65  const seen = new Set<string>();66  while (cur && !seen.has(cur)) {67    if (cur === ancestorId) return true;68    seen.add(cur);69    const f: { parentId: string | null } | null = await prisma.folder.findUnique({ where: { id: cur }, select: { parentId: true } });70    cur = f?.parentId ?? null;71  }72  return false;73}7475/** Met des fichiers à la corbeille en mémorisant leur dossier d'origine. */76export async function trashFiles(fileIds: string[], reason = "TRASH"): Promise<number> {77  if (!fileIds.length) return 0;78  const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: null }, select: { id: true, name: true, folderId: true } });79  const pathCache = new Map<string, string | null>();80  const now = new Date();81  for (const f of files) {82    let originPath: string | null = null;83    if (f.folderId) {84      if (!pathCache.has(f.folderId)) {85        const p = await getOriginPath(f.folderId);86        pathCache.set(f.folderId, p ? JSON.stringify(p) : null);87      }88      originPath = pathCache.get(f.folderId) ?? null;89    }90    await prisma.file.update({91      where: { id: f.id },92      data: { deletedAt: now, deletedFromFolderId: f.folderId, deletedFromPath: originPath },93    });94    await logActivity(reason, f.id, `Moved to trash: ${f.name}`);95  }96  return files.length;97}9899/**100 * Supprime un dossier : tous les fichiers du sous-arbre partent à la corbeille (restaurables 30 jours),101 * puis les dossiers sont supprimés. Les fichiers déjà en corbeille dans ce sous-arbre conservent leur origine.102 */103export async function trashFolderRecursive(folderId: string): Promise<{ files: number; folders: number }> {104  const ids = await collectFolderTree(folderId);105  const active = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: null }, select: { id: true } });106  const trashed = await trashFiles(active.map((f) => f.id), "TRASH");107  // Fichiers déjà en corbeille : mémoriser l'origine avant que la cascade ne mette folderId à NULL108  const alreadyTrashed = await prisma.file.findMany({ where: { folderId: { in: ids }, deletedAt: { not: null } }, select: { id: true, folderId: true, deletedFromPath: true } });109  for (const f of alreadyTrashed) {110    if (!f.deletedFromPath && f.folderId) {111      const p = await getOriginPath(f.folderId);112      await prisma.file.update({ where: { id: f.id }, data: { deletedFromFolderId: f.folderId, deletedFromPath: p ? JSON.stringify(p) : null } });113    }114  }115  // Suppression des dossiers (cascade Prisma sur les sous-dossiers ; File.folderId → NULL)116  await prisma.folder.delete({ where: { id: folderId } });117  return { files: trashed, folders: ids.length };118}119120/** Recrée (ou retrouve) une chaîne de dossiers par noms dans un espace / pour un propriétaire. */121async function ensureFolderPath(origin: OriginPath): Promise<string | null> {122  let parentId: string | null = null;123  for (const name of origin.names) {124    const where: Record<string, unknown> = { name, parentId };125    if (parentId === null) {126      // au niveau racine, distinguer par espace127      if (origin.spaceId) where.sharedSpaceId = origin.spaceId; else where.sharedSpaceId = null;128    }129    let folder = await prisma.folder.findFirst({ where });130    if (!folder) {131      folder = await prisma.folder.create({ data: { name, parentId, sharedSpaceId: origin.spaceId, userId: origin.userId } });132    }133    parentId = folder.id;134  }135  return parentId;136}137138/** Restaure des fichiers : dossier d'origine s'il existe, sinon chemin recréé, sinon racine. */139export async function restoreFiles(fileIds: string[]): Promise<number> {140  const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } } });141  for (const f of files) {142    let target: string | null = null;143    const candidate = f.deletedFromFolderId ?? f.folderId;144    if (candidate && (await prisma.folder.findUnique({ where: { id: candidate }, select: { id: true } }))) target = candidate;145    else if (f.deletedFromPath) {146      try { target = await ensureFolderPath(JSON.parse(f.deletedFromPath) as OriginPath); } catch { target = null; }147    }148    await prisma.file.update({ where: { id: f.id }, data: { deletedAt: null, folderId: target, deletedFromFolderId: null, deletedFromPath: null } });149    await logActivity("RESTORE", f.id, `Restored from trash: ${f.name}`);150  }151  return files.length;152}153154/** Suppression définitive : contenu, versions, vignettes, enregistrement. */155export async function purgeFiles(fileIds: string[], log = true): Promise<number> {156  const files = await prisma.file.findMany({ where: { id: { in: fileIds }, deletedAt: { not: null } }, include: { versions: true } });157  for (const f of files) {158    try { await deleteFile(f.storagePath); } catch { /* déjà absent */ }159    for (const v of f.versions) { try { await deleteVersion(v.storagePath); } catch { /* ignore */ } }160    await deleteThumbnails(f.id);161    await prisma.file.delete({ where: { id: f.id } });162    if (log) await logActivity("PURGE", null, `Permanently deleted: ${f.name}`);163  }164  return files.length;165}166167/** Purge automatique des fichiers en corbeille depuis plus de `days` jours. */168export async function purgeExpiredTrash(days = TRASH_RETENTION_DAYS): Promise<number> {169  const limit = new Date(Date.now() - days * 86400000);170  const expired = await prisma.file.findMany({ where: { deletedAt: { not: null, lt: limit } }, select: { id: true } });171  if (!expired.length) return 0;172  const n = await purgeFiles(expired.map((f) => f.id), false);173  await logActivity("PURGE", null, `Auto-purge: ${n} fichier(s) en corbeille depuis plus de ${days} jours`);174  return n;175}176177/** Nettoie les dossiers de morceaux d'upload orphelins (uploads interrompus). */178export async function cleanupOrphanChunks(maxAgeHours = 24): Promise<number> {179  const dir = path.join(process.env.UPLOAD_DIR || "./uploads", "chunks");180  if (!existsSync(dir)) return 0;181  let removed = 0;182  const limit = Date.now() - maxAgeHours * 3600000;183  for (const entry of await readdir(dir)) {184    const p = path.join(dir, entry);185    try {186      const s = await stat(p);187      if (s.isDirectory() && s.mtimeMs < limit) { await rm(p, { recursive: true, force: true }); removed++; }188    } catch { /* ignore */ }189  }190  return removed;191}192