Modifier sans casser
Renommer, déplacer, jeter, restaurer : quatre gestes qui touchent deux mondes sans transaction commune, le stockage et la base. Comment le Finder les garde d’accord, refuse d’écraser, protège ce que le code connaît, et ce qui reste fragile.
Précédemment. Le serveur fabrique l’arbre à chaque lecture : le stockage dit ce qui existe, la base ce qui doit exister et comment le nommer. Le navigateur garde un cache par dossier, vidé après chaque modification.
Les parcours du chapitre#
Deux parcours traversent ce chapitre : ranger la bibliothèque (renommer, déplacer, jeter), et gérer la corbeille (restaurer, supprimer). Chaque onglet en déroule les étapes et ce qu’elles écrivent en base.
Acteur : Administrateur, ou éditeur dans l’espace de son groupe
- 1
Il renomme un fichier ; seul son nom d’affichage change
storage.renamemembre- Folder modifiée : fullPath · SQL direct
- FolderLabel modifiée : path · SQL direct
- MediaAsset modifiée : displayName, fullPath
Lit : Category, Folder, MediaAsset, MemberGroup, MemberGroupMembership
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
storage.renamepackages/backend/src/modules/storage/router.ts:606adapter.movepackages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts:240prisma.$executeRaw (SQL)packages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts:273Folder modifiée · SQL directprisma.$executeRaw (SQL)packages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts:278FolderLabel modifiée · SQL directrewriteAssetPathspackages/backend/src/modules/storage/mediaPaths.ts:24db.$executeRaw (SQL)packages/backend/src/modules/storage/mediaPaths.ts:32MediaAsset modifiéedb.$executeRaw (SQL)packages/backend/src/modules/storage/mediaPaths.ts:39MediaAsset modifiée
ctx.prisma.mediaAsset.updateManypackages/backend/src/modules/storage/router.ts:686MediaAsset modifiée
- 2
Il déplace des fichiers et des dossiers ; rien ne s’écrase, et la structure suit
storage.movemembreLit : Category, Folder, MediaAsset, MemberGroup, MemberGroupMembership
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
storage.movepackages/backend/src/modules/storage/router.ts:754requiredRootPathspackages/backend/src/modules/storage/ensureContentRoots.service.ts:11prisma.category.findManypackages/backend/src/modules/storage/ensureContentRoots.service.ts:15Category lue
ctx.prisma.folder.findFirstpackages/backend/src/modules/storage/router.ts:835Folder luectx.prisma.mediaAsset.findUniquepackages/backend/src/modules/storage/router.ts:834MediaAsset lueassertCanWritePathspackages/backend/src/modules/memberGroups/assertCanWritePaths.service.ts:16isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
- 3
Il met un dossier à la corbeille, sauf s’il contient un fichier encore utilisé
trash.trashToBinmembre- Folder supprimée
- MediaAsset modifiée : fullPath · SQL direct
- TrashEntry créée : appRoot, displayName, id, kind, mediaKind, previousPath, providerCreatedAt, sizeBytes, status, storageRoot, trashedAt
- TrashEntry supprimée
Lit : Category, Discipline, Folder, Gallery, GalleryItem, MediaAsset, MemberDocument, MemberGroup, MemberGroupMembership, PageMediaReference
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
trash.trashToBinpackages/backend/src/modules/trash/router.ts:95trashToBinpackages/backend/src/modules/trash/services/trashToBin.service.ts:176prisma.folder.deleteManypackages/backend/src/modules/trash/services/trashToBin.service.ts:306Folder suppriméerewriteAssetPathspackages/backend/src/modules/storage/mediaPaths.ts:24db.$executeRaw (SQL)packages/backend/src/modules/storage/mediaPaths.ts:32MediaAsset modifiée · SQL directdb.$executeRaw (SQL)packages/backend/src/modules/storage/mediaPaths.ts:39MediaAsset modifiée · SQL direct
prisma.trashEntry.createpackages/backend/src/modules/trash/services/trashToBin.service.ts:262TrashEntry crééeprisma.trashEntry.deletepackages/backend/src/modules/trash/services/trashToBin.service.ts:291TrashEntry suppriméeundopackages/backend/src/modules/trash/services/trashToBin.service.ts:287prisma.trashEntry.deletepackages/backend/src/modules/trash/services/trashToBin.service.ts:291TrashEntry supprimée
Deux mondes, aucune transaction commune#
Chaque modification du Finder touche deux systèmes : le stockage, qui porte les octets, et la base, qui porte tout ce que le stockage ignore (statut, nom lisible, auteur, références depuis les pages). Une base de données sait rendre plusieurs écritures atomiques : c’est une transaction. Un stockage objet aussi, pour un seul objet. Mais rien ne rend atomiques ensemble une copie S3 et une mise à jour SQL.
Toute la conception qui suit découle de cette contrainte. Elle impose trois règles, qu’on retrouvera à chaque geste :
- ne rien écraser en silence : vérifier, avant la moindre écriture, que la cible est libre ;
- écrire dans un ordre réfléchi, et compenser : le stockage d’abord, la base ensuite ; si une étape échoue, on défait les précédentes, pour retrouver l’état de départ ;
- ne toucher la base que là où elle doit suivre : un renommage de fichier qui ne concerne que l’affichage ne touche pas le stockage du tout.
Geste | Le stockage | La base |
|---|---|---|
Renommer un fichier | rien | MediaAsset.displayName |
Renommer, déplacer un dossier | copie puis suppression de chaque objet | MediaAsset, Folder et FolderLabel : préfixe réécrit |
Déplacer un fichier | copie puis suppression | MediaAsset.fullPath |
Valider | rien | MediaAsset.status (chapitre 4) |
Mettre à la corbeille | déplacement vers bin/.trash/<id>/ | TrashEntry créée ; les lignes MediaAsset suivent leurs objets |
Restaurer | retour au chemin d’origine (ou à côté) | les lignes reviennent avec les objets ; TrashEntry « restaurée » |
Supprimer définitivement | suppression des objets et déclinaisons | lignes effacées ; TrashEntry « supprimée » |
Showing entries1to7entries
Renommer un fichier, ce n’est pas le déplacer#
Au temps de Cloudinary, renommer un fichier changeait son public_id : un renommage chez le fournisseur, une URL qui change, des références à suivre. Depuis que la clé est un slug stable calculé à l’envoi, renommer un fichier ne touche plus le stockage :
if (input.type === "file") { const result = await ctx.prisma.mediaAsset.updateMany({ where: { appRoot: ctx.appRoot, fullPath: input.path }, data: { displayName: cleanName }, }); if (result.count === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Aucun média trouvé pour ce fichier.", }); } return { success: true, path: input.path }; }Un simple UPDATE de displayName. Pas de copie, pas d’extension à deviner, pas de référence à mettre à jour. Deux fichiers peuvent porter le même nom affiché : leurs clés restent distinctes.
Renommer un dossier, en revanche, est un vrai déplacement : sa clé est la base de toutes les clés de son contenu. La procédure refuse avant de bouger quoi que ce soit :
storage.rename — refuser d’abord, déplacer ensuite
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { z } from "zod";import { assertOutsideAvatars, avatarsPrefix, outsideBin } from "@backend/modules/storage/mediaPaths";import { TRPCError } from "@trpc/server";/** * Ce qui compte comme extension en fin de nom : 1 à 8 caractères * alphanumériques. Exclut les points internes d'un libellé (« (ep. BAZZE) »). */const EXTENSION_PATTERN = /\.[A-Za-z0-9]{1,8}$/;/** * La corbeille est une FEUILLE dans l'arbre du finder : on n'expose jamais son * sous-arbre physique `.trash/<uuid>/…`. On elague recursivement les enfants du * noeud dont le chemin est exactement `binPath`. (La vue plate lit trash.listBin, * le drill-down passe par `list` — aucun ne depend de getTree.) *//** * La corbeille (`<racine>/bin/…`) n'appartient qu'au routeur `trash` : ni renommage ni déplacement * n'y entre ou n'en sort par `storage`. Restaurer et vider ont leurs propres gardes (TrashEntry). */function assertOutsideBin(appRoot: string, paths: readonly string[]): void { const bin = `${appRoot}/bin`; if (paths.some((p) => p === bin || p.startsWith(`${bin}/`))) { throw new TRPCError({ code: "FORBIDDEN", message: "La corbeille se gère depuis sa propre vue (restaurer, supprimer).", }); }}function stripBinSubtree(node: StorageFolderNode, binPath: string): void { for (const child of node.children ?? []) { if (child.type !== "folder") continue; if (child.path === binPath) { child.children = []; child.hasChildren = false; } else { stripBinSubtree(child, binPath); } }}import { enrichFilesWithStatus, enrichTreeWithStatus,} from "@backend/modules/storage/services/enrichStatus.service";import { router, protectedProcedure } from "@backend/trpc";import { isAdmin } from "@backend/trpc/middleware";import { storageProviderSchema, storageMoveIntentSchema, createR2UploadAuthorizationSchema, type StorageFolderNode,} from "@contracts/storage";import { uploadDestinationSchema, type UploadDestination } from "@contracts/storage/upload-destination.schema";import { uploadTypeError } from "@contracts/storage/allowedUploadTypes";import { getAdapter } from "@backend/modules/storage/providerRegistry";import { VirtualStorage } from "@backend/modules/storage/virtualStorage";import { isProtectedEntityFolderPath } from "@backend/modules/storage/protectedEntityFolder";import { isRootFolder } from "@backend/modules/storage/services/ensureRootFolders.service";import { planMoveOperations, executeMoveOperations,} from "@backend/modules/storage/resolveMoveIntent.service";import { countPersoImages } from "@backend/modules/media/services/countPersoImages.service";import { PERSO_PHOTO_QUOTA } from "@backend/modules/media/services/persoPhotoQuota.constants";import { listCommonRepositoryFolders } from "@backend/modules/media/services/listCommonRepositoryFolders.service";import { listMyCommonRepositoryContainers } from "@backend/modules/media/services/listMyCommonRepositoryContainers.service";import { commonRepositoryContainerPath } from "@backend/modules/media/services/commonRepositoryContainerPath.service";import { resolvePendingUploadFolder } from '@backend/modules/storage/services/resolvePendingUploadFolder.service';import { assertCanReadPath } from "@backend/modules/memberGroups/assertCanReadPath.service";import { assertCanWritePaths } from "@backend/modules/memberGroups/assertCanWritePaths.service";import { resolveGroupBaseFolder } from "@backend/modules/media/services/resolveGroupBaseFolder.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { scopePersoSpaceInTree } from "@backend/modules/storage/scopePersoSpace.service";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { mergeGroupSpaceFolders, mergeGroupSpaceFoldersIntoTree,} from "@backend/modules/storage/mergeGroupSpaceFolders.service";import { ensureContentRootsInTree, missingContentRootFolders, requiredRootPaths,} from "@backend/modules/storage/ensureContentRoots.service";import { applyGroupSpaceNamesToFolders, applyGroupSpaceNamesToTree,} from "@backend/modules/storage/applyGroupSpaceNames.service";import { assertUploadDestinationAllowed } from '@backend/modules/storage/assertUploadDestinationAllowed.service';import { buildUploadFileName } from '@backend/modules/storage/services/buildUploadFileName.service';/** * storageRouter — finder (lecture, renommage, déplacement) et envois vers R2. *//* -------------------------------------------------------------------------- *//* Schema R2 Phase 2 — inline *//* -------------------------------------------------------------------------- *//** * Destination metier — discriminée pour gérer les deux cas : * - existing-discipline : on a categoryId + disciplineId direct * - new-discipline : on a categoryId + proposedDisciplineName (admin validera plus tard) * * Forme identique à ce que `DragNDropForm` construit côté frontend. *//** * Remplacer un fichier déjà connu, c'est publier un contenu SANS repasser par la validation : la * ligne garde son statut. Un dépôt ouvert à tout membre (discipline, stage, événement, dépôt commun) * ne doit donc pas permettre de remplacer un fichier publié, ou déposé par quelqu'un d'autre. * Autorisé : un administrateur ; un éditeur dans l'espace de son groupe (il peut déjà y mettre à la * corbeille ; son droit d'écriture est vérifié par assertUploadDestinationAllowed) ; l'auteur d'un * fichier encore en attente. Vérifié à la signature ET à l'enregistrement : l'enregistrement met à * jour la ligne existante, auteur compris. */function mayReplace( existing: { status: string; uploaderUserId: string }, user: { id: string; isAdmin: boolean }, destination: UploadDestination,): boolean { return ( user.isAdmin || destination.kind === "group" || (existing.status === "pending" && existing.uploaderUserId === user.id) );}const registerR2UploadInputSchema = z.object({ path: z.string().min(1), expectedBytes: z.number().int().positive(), expectedMimeType: z.string().min(1), // Phase 2 — nouveaux champs requis pour créer la row MediaAsset destination: uploadDestinationSchema, originalFileName: z.string().min(1).max(255),});/* -------------------------------------------------------------------------- *//* Router *//* -------------------------------------------------------------------------- */export const storageRouter = router({ /* ====================================================================== */ /* Lecture (inchangé) */ /* ====================================================================== */ /** * Compteurs « à traiter » de la bibliothèque, pour la cloche du header : * assets en attente de classement (MediaAsset.status "pending") et * entrées de corbeille (TrashEntry IN_BIN). Réservée aux administrateurs : ce sont des * compteurs de toute la bibliothèque, et la cloche qui les affiche n'existe que pour eux. */ getAttentionCounts: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const [pending, bin, commonRepositoryPending, persoCounts] = await Promise.all([ // Hors corbeille : un fichier jeté garde sa ligne (pour sa restauration), pas son tour. ctx.prisma.mediaAsset.count({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) } }), ctx.prisma.trashEntry.count({ where: { status: "IN_BIN" } }), ctx.prisma.mediaAsset.count({ where: { status: "pending", appRoot: ctx.appRoot, fullPath: { startsWith: `${ctx.appRoot}/common-repository/` }, }, }), countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }), ]); return { pending, bin, commonRepositoryPending, persoPending: persoCounts.pending, }; }), /** * Ventilation des contenus « en attente » par dossier parent. * * Renvoie le `kind` du dossier plutôt qu'un libellé : la formulation * (« le stockage général », « votre stockage personnel »…) appartient à * l'UI, pas au backend. Les dossiers sont triés par volume décroissant. * Réservée aux administrateurs : elle nomme les dossiers de toute la bibliothèque, * espaces personnels compris. */ getPendingBreakdown: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const rows = await ctx.prisma.mediaAsset.findMany({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) }, select: { fullPath: true }, }); const counts = new Map<string, number>(); for (const row of rows) { const segments = row.fullPath.split("/"); segments.pop(); // le nom du fichier const folder = segments.join("/"); if (!folder) continue; counts.set(folder, (counts.get(folder) ?? 0) + 1); } const commonRepositoryRoot = `${ctx.appRoot}/common-repository`; const persosRoot = `${ctx.appRoot}/personal-space`; const entries = Array.from(counts.entries()).map(([path, count]) => { let kind: "common_repository" | "perso" | "folder" = "folder"; if (path === commonRepositoryRoot || path.startsWith(`${commonRepositoryRoot}/`)) { kind = "common_repository"; } else if (path.startsWith(`${persosRoot}/`)) { // Personnel de l'utilisateur courant uniquement : le dossier porte // son id. Ceux des autres restent des dossiers ordinaires. kind = path.includes(ctx.sessionClient.user.id) ? "perso" : "folder"; } // Nom lisible : pour le dossier personnel d'un AUTRE utilisateur, le // dernier segment vaut « photos » et n'apprend rien — on prend le // segment qui identifie la personne. let name = path.split("/").pop() ?? path; if (kind === "folder" && path.startsWith(`${persosRoot}/`)) { name = path.slice(persosRoot.length + 1).split("/")[0] ?? name; } return { path, count, kind, name }; }); entries.sort((a, b) => b.count - a.count); return { total: rows.length, entries }; }), /** * Statut du quota d'images de l'espace perso de l'admin courant (lecture * seule). Le dossier perso est dérivé de `ctx.sessionClient.user.id`, jamais d'un input. */ getPersoPhotoQuota: protectedProcedure.query(async ({ ctx }) => { const counts = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const remaining = Math.max(0, PERSO_PHOTO_QUOTA - counts.total); return { quota: PERSO_PHOTO_QUOTA, pending: counts.pending, published: counts.published, total: counts.total, remaining, }; }), /** * Sous-dossiers existants sous `general/` (pending + published), pour peupler * le select « dossier existant » de l'uploader général. */ listCommonRepositoryFolders: protectedProcedure.query(async ({ ctx }) => { return listCommonRepositoryFolders({ prisma: ctx.prisma, appRoot: ctx.appRoot }); }), // Sujets des dépôts de l'utilisateur courant (page membre) — scopé user. listMyCommonRepositoryContainers: protectedProcedure.query(async ({ ctx }) => { return listMyCommonRepositoryContainers({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); }), // Pose le libellé humain d'un conteneur de dépôt (appelé par le front après // un dépôt réussi). Le path est dérivé de ctx.sessionClient.user.id → un membre ne peut // labelliser QUE ses propres conteneurs. setCommonRepositoryLabel: protectedProcedure .input(z.object({ subject: z.string().trim().min(1).max(120), label: z.string().trim().min(1).max(200) })) .mutation(async ({ ctx, input }) => { const path = await commonRepositoryContainerPath({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, subject: input.subject, }); await ctx.prisma.folderLabel.upsert({ where: { path }, update: { displayName: input.label }, create: { path, displayName: input.label }, }); return { ok: true }; }), /** * Hiérarchie des espaces collaboratifs : chemin d'espace RÉEL + parent, * pour l'imbrication visuelle du finder admin (les chemins physiques * restent inchangés). Réservé aux admins. */ groupSpaceHierarchy: protectedProcedure.query(async ({ ctx }) => { if (!ctx.sessionClient.user.isAdmin) return []; const groups = await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }); return Promise.all( groups.map(async (g) => ({ groupId: g.id, name: g.name, parentGroupId: g.parentGroupId, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: g.id, }), })), ); }), /** * Espaces des groupes collaboratifs accessibles à l'utilisateur courant * (racines du finder côté membre) : chemin + droit par groupe. * * Passerelle admin : un administrateur est d'emblée ÉDITEUR de TOUS les * espaces collaboratifs (au-dessus du système de groupes), sans * appartenance manuelle. */ myCollaborativeSpaces: protectedProcedure.query(async ({ ctx }) => { const isAdmin = ctx.sessionClient.user.isAdmin; const entries: { groupId: string; name: string; access: "VIEWER" | "EDITOR"; parentGroupId: string | null; }[] = isAdmin ? ( await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }) ).map((g) => ({ groupId: g.id, name: g.name, access: "EDITOR" as const, parentGroupId: g.parentGroupId })) : await collaborativeEntriesForMember(ctx.prisma, ctx.sessionClient.user.id); return Promise.all( entries.map(async (e) => ({ ...e, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: e.groupId, }), })), ); }), list: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), cursor: z.string().optional(), limit: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; const listed = await reader.list({ path: input.path, cursor: input.cursor, limit: input.limit, }); // Les avatars ne sont pas des médias de la bibliothèque : leur dossier n'est jamais listé. const result = { ...listed, folders: listed.folders.filter((f) => f.path !== avatarsPrefix(ctx.appRoot)), }; // Le statut vit en DB depuis l'aplatissement : on le rapatrie ici. await enrichFilesWithStatus(ctx.prisma, ctx.appRoot, result.files); // Espaces de groupe visibles même vides : R2 n'a pas de // vrais dossiers, un espace sans asset s'évaporerait du listing. if (input.path === `${ctx.appRoot}/collaborative-group-spaces`) { const merged = await mergeGroupSpaceFolders({ result, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); return { ...merged, folders: await applyGroupSpaceNamesToFolders( merged.folders, ctx.prisma, ), }; } // Racine du finder : racines principales visibles même vides (grille). if (input.path === ctx.appRoot) { const missing = await missingContentRootFolders( result.folders.map((f) => f.path), ctx.prisma, ctx.appRoot, ); return { ...result, folders: await applyGroupSpaceNamesToFolders( [...result.folders, ...missing], ctx.prisma, ), }; } return result; }), getTree: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), depth: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; // Perso : quand la grille demande le conteneur personal-space // (adapter.list = getTree depth1), on lit en réalité le sous-espace de // l'utilisateur courant → il ne voit QUE son contenu (privé + illusion). const persoContainer = `${ctx.appRoot}/personal-space`; let readPath = input.path; if (input.path === persoContainer) { try { readPath = await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); } catch { readPath = input.path; } } const result = await reader.getTree({ path: readPath, depth: input.depth, }); // Corbeille = feuille : jamais de `.trash` dans l'arbre du finder. stripBinSubtree(result.root, `${ctx.appRoot}/bin`); // Avatars : hors de la bibliothèque, leur dossier (enfant direct de la racine) n'apparaît pas. if (result.root.children) { result.root.children = result.root.children.filter((c) => c.path !== avatarsPrefix(ctx.appRoot)); } await enrichTreeWithStatus(ctx.prisma, ctx.appRoot, result.root); // Espaces de groupe ET perso visibles même vides, dans l'arbre du finder. result.root = await mergeGroupSpaceFoldersIntoTree({ root: result.root, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Racines principales visibles même vides (courses/seminars/events/ // personal-space/collaborative-group-spaces/common-repository). // Uniquement à la RACINE : la grille fait adapter.list = getTree // depth1 sur CHAQUE dossier ; sans cette garde on injecterait les // racines de contenu dans tous les dossiers. if (input.path === ctx.appRoot) { result.root = await ensureContentRootsInTree( result.root, ctx.prisma, ctx.appRoot, ); } // Perso : dans l'arbre racine, aplatir personal-space sur le contenu du // sous-espace de l'utilisateur courant (masque les autres admins). if (input.path === ctx.appRoot) { result.root = scopePersoSpaceInTree( result.root, ctx.appRoot, ctx.sessionClient.user.id, ); } // Nom EXACT des dossiers d'espace de groupe (accents) — c'est getTree // que lit l'adapter du finder (et le picker). return { ...result, root: await applyGroupSpaceNamesToTree(result.root, ctx.prisma), }; }), // Libellés d'affichage des dossiers (découplés du chemin physique). folderLabels: protectedProcedure.query(({ ctx }) => ctx.prisma.folderLabel.findMany({ select: { path: true, displayName: true }, }), ), // Racines de contenu (label-only) : le front y route « Renommer » vers // l'édition du FolderLabel plutôt que le rename physique. contentRootPaths: protectedProcedure.query(({ ctx }) => requiredRootPaths(ctx.prisma, ctx.appRoot), ), setFolderLabel: protectedProcedure .input( z.object({ path: z.string().min(1), displayName: z.string().trim().max(255), }), ) .mutation(async ({ ctx, input }) => { // Un libellé change ce que TOUT LE MONDE voit : même droit qu'un renommage. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideAvatars(ctx.appRoot, [input.path]); const name = input.displayName.trim(); // Libellé vide = on retire l'override (retour au repli du listing). if (name === "") { await ctx.prisma.folderLabel.deleteMany({ where: { path: input.path } }); return { path: input.path, displayName: null as string | null }; } const row = await ctx.prisma.folderLabel.upsert({ where: { path: input.path }, create: { path: input.path, displayName: name }, update: { displayName: name }, }); return { path: row.path, displayName: row.displayName as string | null }; }), getNode: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getNode) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getNode().` ); } return reader.getNode(input.path); }), getMetadata: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getMetadata) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getMetadata().` ); } return reader.getMetadata(input.path); }), /** * Renomme un fichier ou un dossier — c'est-à-dire un `move` vers le même * parent sous un nouveau nom. * * `newBaseName` est le nom SANS extension : le client n'édite que la base * (l'extension affichée est reconstruite par `displayName`). L'extension de * la source est réappliquée telle quelle. */ rename: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), type: z.enum(["file", "folder"]).default("file"), newBaseName: z.string().min(1).max(255), }) ) .mutation(async ({ ctx, input }) => { // Écriture : un administrateur partout, un éditeur dans l'espace de son groupe. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideBin(ctx.appRoot, [input.path]); assertOutsideAvatars(ctx.appRoot, [input.path]); const cleanName = input.newBaseName.trim(); if (!cleanName) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas être vide.", }); } if (cleanName.includes("/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « / ».", }); } // « . » et « .. » feraient un chemin que toutes les gardes refusent (`includes("..")`) : le // dossier renommé deviendrait illisible pour tout le monde. if (cleanName === "." || cleanName.includes("..")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « .. ».", }); } const segments = input.path.split("/"); const currentName = segments.pop() ?? ""; const parent = segments.join("/"); if (!parent) { throw new TRPCError({ code: "BAD_REQUEST", message: "Impossible de renommer un élément racine.", }); } // Dossier-entité : nom PHYSIQUE immuable. Sa garde de suppression // repose sur son chemin ; le renommer (déplacement du binaire) // casserait cette protection. Refusé (un libellé d'affichage passera // par un mécanisme séparé). if (input.type === "folder" && isProtectedEntityFolderPath(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être renommé.", }); } // Racine de contenu (catégorie / stages / events / espaces / dépôt) : // nom PHYSIQUE immuable aussi. Son « Renommer » édite le FolderLabel // côté client ; un rename physique casserait le lien catégorie↔dossier // et les gardes. Refusé ici (défense en profondeur). if (input.type === "folder") { const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if (roots.includes(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Cette racine ne se renomme pas physiquement (libellé éditable).", }); } } // ─── FICHIER : on n'édite QUE le nom d'affichage ─────────────────── // // Depuis la slugification (increment 1), la clé de stockage est un slug // stable et opaque. Renommer un fichier ne déplace donc plus rien chez // le provider et ne devine plus d'extension (fin du bug historique) : // c'est un simple UPDATE de `displayName`, par le chemin exact. Deux fichiers peuvent // partager un nom d'affichage : pas de contrôle de collision ici, la clé reste unique. if (input.type === "file") { const result = await ctx.prisma.mediaAsset.updateMany({ where: { appRoot: ctx.appRoot, fullPath: input.path }, data: { displayName: cleanName }, }); if (result.count === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Aucun média trouvé pour ce fichier.", }); } return { success: true, path: input.path }; } // Extension de la SOURCE, réappliquée telle quelle. // // ⚠️ Un point dans un nom n'est PAS forcément un séparateur // d'extension : « CNI recto PORQUET (ep. BAZZE) Yvonne » en contient // un. Couper au dernier point produirait une pseudo-extension // « . BAZZE) Yvonne » et mutilerait le fichier au renommage. On // n'accepte donc qu'un suffixe AYANT LA FORME d'une extension. // Un DOSSIER n'a pas d'extension : « photos.2024 » renommé en // « archives » doit donner « archives », pas « archives.2024 ». const extensionMatch = input.type === "folder" ? null : EXTENSION_PATTERN.exec(currentName); const extension = extensionMatch ? extensionMatch[0] : ""; const targetPath = `${parent}/${cleanName}${extension}`; // Renommage en son propre nom : rien à faire, mais pas une erreur. if (targetPath === input.path) { return { success: true, path: input.path }; } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); // On n'écrase jamais un élément existant : un objet (ou un dossier non vide) chez le stockage, // une ligne MediaAsset, un dossier enregistré (même contrôle que storage.move). const [collision, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(targetPath) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: targetPath }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: targetPath }, select: { id: true } }), ]); if (collision || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: "Un élément porte déjà ce nom dans ce dossier.", }); } // `move` est optionnel sur l'interface d'adapter : on garde avant // d'appeler, comme pour `getNode` plus haut. if (!adapter.move) { throw new TRPCError({ code: "NOT_IMPLEMENTED", message: "Ce provider ne supporte pas le déplacement.", }); } await adapter.move({ source: { type: input.type, path: input.path }, target: { path: targetPath }, }); return { success: true, path: targetPath }; }), move: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), intent: storageMoveIntentSchema, }) ) .mutation(async ({ ctx, input }) => { // Écriture : la SOURCE et la CIBLE. Un éditeur ne sort rien de l'espace de son groupe, et // n'y fait rien entrer qui vienne d'ailleurs. { const source = input.intent.source; const sourcePaths = source.type === "selection" ? source.roots : [source.path]; const targetPaths = [input.intent.target.path]; await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [...sourcePaths, ...targetPaths], }); assertOutsideBin(ctx.appRoot, [...sourcePaths, ...targetPaths]); assertOutsideAvatars(ctx.appRoot, [...sourcePaths, ...targetPaths]); } // Dossier-entité : chemin immuable, pas de déplacement du binaire. // Même raison qu'au rename ; couvre aussi les sélections multiples. { const movedPaths = input.intent.source.type === "folder" ? [input.intent.source.path] : input.intent.source.type === "selection" ? input.intent.source.roots : []; // Les racines de contenu (cours, stages, événements, espaces…) et la corbeille non plus : // leur chemin est connu du code (destinations d'envoi, gardes, libellés). const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if ( movedPaths.some( (pth) => isProtectedEntityFolderPath(pth) || roots.includes(pth) || isRootFolder(ctx.appRoot, pth), ) ) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être déplacé.", }); } } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const planned = await planMoveOperations({ adapter, appRoot: ctx.appRoot, intent: input.intent }); // Une opération sur place (déposer un élément dans son propre dossier) n'a rien à faire : // l'adapter renommerait un objet sur lui-même, le stockage refuserait, et tout le geste échouerait. const effectiveOperations = planned.filter( (operation) => operation.source.path !== operation.target.path, ); // Rien ne s'écrase en silence : une copie S3 remplace l'objet qui porte déjà la clé cible, et // la ligne MediaAsset déplacée heurterait la contrainte d'unicité APRÈS la copie — le fichier // d'arrivée serait perdu. On vérifie donc chaque cible avant la moindre écriture : un objet // (ou un dossier non vide) chez le stockage, une ligne MediaAsset, un dossier enregistré. const targets = new Set<string>(); for (const operation of effectiveOperations) { const target = operation.target.path; // Deux éléments de la même sélection qui arriveraient au même nom. if (targets.has(target)) { throw new TRPCError({ code: "CONFLICT", message: `Deux éléments de la sélection s'appellent « ${target.slice(target.lastIndexOf("/") + 1)} ».`, }); } targets.add(target); const [node, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(target) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: target }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: target }, select: { id: true } }), ]); if (node || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: `« ${target.slice(target.lastIndexOf("/") + 1)} » existe déjà dans le dossier de destination.`, }); } } // Exécution séquentielle : des opérations concurrentes sur des // préfixes voisins pourraient se marcher dessus. await executeMoveOperations(adapter, effectiveOperations); return { operations: effectiveOperations }; }), /* ====================================================================== */ /* Upload R2 — Phase 2 (enrichi avec destination + originalFileName) */ /* ====================================================================== */ /** * Noms de fichiers qui existent DÉJÀ dans la destination (pour demander avant d'écraser). */ checkR2Conflicts: protectedProcedure .input(z.object({ destination: uploadDestinationSchema, fileNames: z.array(z.string().min(1).max(255)).max(100) })) .query(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination }); const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Deux fichiers du même lot peuvent donner la même clé (« Photo 1.JPG » et « photo-1.jpg ») : // envoyés en parallèle, le second écraserait le premier sans que personne ne l'ait demandé. // On les signale par leur position dans le lot (deux noms identiques ne se distinguent pas). const byPath = new Map<string, string>(); const duplicateIndexes: number[] = []; input.fileNames.forEach((name, index) => { const path = `${folder}/${buildUploadFileName(name)}`; if (byPath.has(path)) duplicateIndexes.push(index); else byPath.set(path, name); }); const found = await ctx.prisma.mediaAsset.findMany({ where: { fullPath: { in: [...byPath.keys()] } }, select: { fullPath: true } }); return { existing: found.map((f) => byPath.get(f.fullPath)).filter((name): name is string => Boolean(name)), duplicateIndexes, }; }), createR2Upload: protectedProcedure .input(createR2UploadAuthorizationSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin se calcule ICI, une seule fois (jamais côté client). const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const fileName = buildUploadFileName(input.originalFileName); const path = `${folder}/${fileName}`; // Formats autorisés seulement (extension ET type déclaré) : jamais de HTML, SVG ou // script, qu'un navigateur pourrait exécuter s'il était affiché. Le type est signé dans // l'URL d'envoi, puis revérifié par registerR2Upload sur l'objet réellement stocké. const typeError = uploadTypeError(input.originalFileName, input.mimeType); if (typeError) { throw new TRPCError({ code: "BAD_REQUEST", message: typeError }); } if (input.destination.kind === "perso" && !input.mimeType.startsWith("image/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier perso n'accepte que des images." }); } // Pas d'écrasement silencieux : confirmation explicite requise. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: path }, select: { id: true, status: true, uploaderUserId: true }, }); if (existing && !input.allowOverwrite) { throw new TRPCError({ code: "CONFLICT", message: `Un fichier « ${fileName} » existe déjà à cet endroit.` }); } // Confirmer ne suffit pas : remplacer, c'est publier sans validation (mayReplace). if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: `« ${fileName} » existe déjà et ne peut être remplacé que par un administrateur. Renommez votre fichier.`, }); } if (input.destination.kind === "perso" && !existing) { const { total } = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id }); if (total + 1 > PERSO_PHOTO_QUOTA) { throw new TRPCError({ code: "BAD_REQUEST", message: `Quota d'images perso atteint (max ${PERSO_PHOTO_QUOTA}).` }); } } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); const auth = await adapter.createUploadAuthorization({ path, mimeType: input.mimeType, maxBytes: input.maxBytes, }); // Le client a besoin du chemin résolu : il le renverra tel quel à // `registerR2Upload`. Le chemin ne retraverse jamais une règle de calcul. return { ...auth, path }; }), /** * Phase 2 — l'input transporte maintenant `destination` + `originalFileName` * pour permettre à l'adapter R2 de créer la row MediaAsset après HeadObject. */ registerR2Upload: protectedProcedure .input(registerR2UploadInputSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin enregistré doit être celui que createR2Upload a autorisé pour cette destination. const expectedFolder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const rest = input.path.slice(expectedFolder.length + 1); if (!input.path.startsWith(`${expectedFolder}/`) || rest === "" || rest.includes("/") || rest.includes("..")) { throw new TRPCError({ code: "FORBIDDEN", message: "Chemin d'envoi hors de la destination autorisée." }); } // L'enregistrement met à jour une ligne existante (auteur compris) : même règle qu'à la // signature, sans quoi un membre pourrait « adopter » le fichier en attente d'un autre, puis le // remplacer en tant qu'auteur. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { status: true, uploaderUserId: true }, }); if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce fichier existe déjà et ne peut être remplacé que par un administrateur.", }); } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); return adapter.registerUploadedAsset({ path: input.path, userId: ctx.sessionClient.user.id, expectedBytes: input.expectedBytes, expectedMimeType: input.expectedMimeType, destination: input.destination, originalFileName: input.originalFileName, }); }),});Déplacer, c’est copier puis supprimer#
S3 n’a pas d’opération « renommer » : déplacer un objet, c’est le copier sous sa nouvelle clé, puis supprimer l’ancien. Déplacer un dossier, c’est le faire pour chaque objet qui commence par son préfixe :
/** Copies simultanées au plus, pendant le déplacement d'un dossier. */const COPY_CONCURRENCY = 16;async function moveFolder( s3: S3Client, Bucket: string, srcPath: string, dstPath: string): Promise<void> { const srcPrefix = ensureTrailingSlash(srcPath); const dstPrefix = ensureTrailingSlash(dstPath); let continuationToken: string | undefined; do { const response = await s3.send( new ListObjectsV2Command({ Bucket, Prefix: srcPrefix, ContinuationToken: continuationToken, MaxKeys: 1000, }) ); const objects = (response.Contents ?? []).filter( (o): o is S3Object & { Key: string } => Boolean(o.Key) ); if (objects.length > 0) { // Par paquets : une page compte jusqu'à 1 000 objets, et autant de copies simultanées // saturent les connexions du client (et les limites de débit du stockage). for (let i = 0; i < objects.length; i += COPY_CONCURRENCY) { await Promise.all( objects.slice(i, i + COPY_CONCURRENCY).map((obj) => { const relativeKey = obj.Key.slice(srcPrefix.length); const newKey = dstPrefix + relativeKey; return s3.send( new CopyObjectCommand({ Bucket, Key: newKey, CopySource: buildCopySource(Bucket, obj.Key), }) ); }) ); } await s3.send( new DeleteObjectsCommand({ Bucket, Delete: { Objects: objects.map((o) => ({ Key: o.Key })), Quiet: true, }, }) ); } continuationToken = response.NextContinuationToken; } while (continuationToken);}Trois détails comptent :
- le
/final du préfixe (ensureTrailingSlash) : sans lui, déplacercoursdéplacerait aussicours-avance; - la pagination :
ListObjectsV2rend 1 000 clés au plus ; leContinuationTokenreprend où la page précédente s’est arrêtée ; - les copies par paquets de 16 : la première version lançait les 1 000 copies d’une page en même temps, ce qui sature les connexions du client et les limites de débit du stockage. Un
Promise.allsur une liste non bornée est une charge non bornée.
Puis la base suit, dans une seule transaction :
adapter.move — le stockage, la base, et le retour en arrière
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, type S3Client, type _Object as S3Object,} from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";import { rewriteAssetPaths } from "@backend/modules/storage/mediaPaths";import type { StorageAdapter, UploadCapableAdapter, ListOptions, ListResult, GetTreeOptions, GetTreeResult, StorageNode, StorageFolderNode, StorageFileNode, StorageMetadata, StorageMoveOperation, StoragePath,} from "@contracts/storage";import type { UploadDestination } from "@contracts/storage/upload-destination.schema";import { HARD_MAX_UPLOAD_BYTES } from "@contracts/storage/r2-upload.schema";import { getR2Client, getR2PresignClient, getR2Bucket } from "@backend/modules/storage/adapters/r2/client";import { probeMediaDimensions } from "@backend/modules/media/services/mediaDerivatives.service";/** * r2StorageAdapter — implémentation Cloudflare R2 complète * * Phase 2 (tracking R2) : `registerUploadedAsset` crée maintenant une row * `MediaAsset` après le HeadObject de validation. Cela permet au finder, à * la searchbar et au tri par Date/Expéditeur de voir les fichiers. * * (Le reste de la doc d'architecture est inchangée par rapport à la v1 — * voir l'historique git pour les notes sur le modèle R2 sans dossiers, * la pagination, le presigned PUT etc.) *//* -------------------------------------------------------------------------- *//* Types upload *//* -------------------------------------------------------------------------- */export type R2CreateUploadAuthorizationInput = { path: StoragePath; mimeType: string; maxBytes: number;};export type R2CreateUploadAuthorizationOutput = { uploadUrl: string; fields: Record<string, string>; expiresAt: string;};/** * Phase 2 : enrichi avec `destination` (categoryId + disciplineId|proposed) * et `originalFileName`. La destination est nécessaire pour créer la row * MediaAsset avec les bonnes FK. L'originalFileName aussi (sinon on devrait * deviner depuis le path, qui peut être slugifié et donc divergent). */export type R2RegisterUploadedAssetInput = { path: StoragePath; userId: string; expectedBytes: number; expectedMimeType: string; destination: UploadDestination; originalFileName: string;};export type R2RegisterUploadedAssetOutput = { ok: true; path: StoragePath; bytes: number; mimeType: string; mediaAssetId: string;};/* -------------------------------------------------------------------------- *//* Type complet de l'adapter *//* -------------------------------------------------------------------------- */export type R2StorageAdapter = StorageAdapter & UploadCapableAdapter< R2CreateUploadAuthorizationInput, R2CreateUploadAuthorizationOutput, R2RegisterUploadedAssetInput, R2RegisterUploadedAssetOutput >;export type R2StorageAdapterDeps = { prisma: PrismaClient; appRoot: string;};/* -------------------------------------------------------------------------- *//* Factory *//* -------------------------------------------------------------------------- */export function createR2StorageAdapter( deps: R2StorageAdapterDeps): R2StorageAdapter { const { prisma, appRoot } = deps; return { /* ====================================================================== */ /* Lecture */ /* ====================================================================== */ async list(options: ListOptions): Promise<ListResult> { const s3 = getR2Client(); const Bucket = getR2Bucket(); const Prefix = ensureTrailingSlash(options.path); const response = await s3.send( new ListObjectsV2Command({ Bucket, Prefix, Delimiter: "/", MaxKeys: options.limit ?? 1000, ContinuationToken: options.cursor, }) ); const folders: StorageFolderNode[] = (response.CommonPrefixes ?? []) .map((cp) => cp.Prefix) .filter((p): p is string => Boolean(p)) .map((fullPath) => { const normalizedPath = stripTrailingSlash(fullPath); return { type: "folder" as const, name: lastSegment(normalizedPath), path: normalizedPath, hasChildren: true, }; }); const files: StorageFileNode[] = (response.Contents ?? []) .filter( (obj): obj is S3Object & { Key: string } => Boolean(obj.Key) && obj.Key !== Prefix ) .map((obj) => objectToFileNode(obj)); return { folders, files, nextCursor: response.NextContinuationToken ?? null, }; }, async getTree(options: GetTreeOptions): Promise<GetTreeResult> { const s3 = getR2Client(); const Bucket = getR2Bucket(); const depth = options.depth ?? 1; const root = await buildSubTree(s3, Bucket, options.path, depth); return { root }; }, async getNode(path: StoragePath): Promise<StorageNode | null> { const s3 = getR2Client(); const Bucket = getR2Bucket(); try { const head = await s3.send( new HeadObjectCommand({ Bucket, Key: path }) ); return { type: "file" as const, name: lastSegment(path), path, metadata: { bytes: head.ContentLength, updatedAt: head.LastModified?.toISOString(), mimeType: head.ContentType ?? inferMimeFromPath(path), format: extensionOf(path), }, }; } catch (err) { if (!isNotFoundError(err)) throw err; } const Prefix = ensureTrailingSlash(path); const list = await s3.send( new ListObjectsV2Command({ Bucket, Prefix, Delimiter: "/", MaxKeys: 1, }) ); const hasAny = (list.Contents?.length ?? 0) > 0 || (list.CommonPrefixes?.length ?? 0) > 0; if (!hasAny) return null; return { type: "folder" as const, name: lastSegment(path), path, hasChildren: true, }; }, async getMetadata(path: StoragePath): Promise<StorageMetadata | null> { const s3 = getR2Client(); const Bucket = getR2Bucket(); try { const head = await s3.send( new HeadObjectCommand({ Bucket, Key: path }) ); return { bytes: head.ContentLength, updatedAt: head.LastModified?.toISOString(), mimeType: head.ContentType ?? inferMimeFromPath(path), format: extensionOf(path), }; } catch (err) { if (isNotFoundError(err)) return null; throw err; } }, /* ====================================================================== */ /* Écriture */ /* ====================================================================== */ async move(operation: StorageMoveOperation): Promise<void> { const s3 = getR2Client(); const Bucket = getR2Bucket(); const kind = operation.source.type; const from = operation.source.path; const to = operation.target.path; const moveObjects = (src: string, dst: string): Promise<void> => kind === "file" ? moveFile(s3, Bucket, src, dst) : moveFolder(s3, Bucket, src, dst); // Le stockage d'abord, la base ensuite. Une opération S3 et une transaction SQL ne se rendent // pas atomiques ensemble : on COMPENSE. Si la copie échoue en cours de route (un dossier se // copie par paquets), on ramène à la source ce qui est déjà arrivé ; si la base échoue après, // on ramène les objets. Dans les deux cas, l'état final est l'état initial, et l'erreur remonte. try { await moveObjects(from, to); } catch (err) { await moveObjects(to, from).catch((undoErr) => { console.error("[r2] déplacement interrompu, retour impossible", { from, to }, undoErr); }); throw err; } // Le dossier et tout ce qu'il contient changent de préfixe en base, en une transaction : les // fichiers (MediaAsset), les dossiers enregistrés (Folder : espaces, dossiers restaurés) et // les libellés d'affichage (FolderLabel). try { if (kind === "file") { await rewriteAssetPaths(prisma, appRoot, "file", from, to); } else { const cut = from.length + 1; const likeChildren = `${from}/`.replace(/[\\%_]/g, "\\$&") + "%"; await prisma.$transaction([ rewriteAssetPaths(prisma, appRoot, "folder", from, to), prisma.$executeRaw` UPDATE "Folder" SET "fullPath" = ${to} || SUBSTRING("fullPath" FROM ${cut}::int) WHERE "appRoot" = ${appRoot} AND ("fullPath" = ${from} OR "fullPath" LIKE ${likeChildren}); `, prisma.$executeRaw` UPDATE "FolderLabel" SET "path" = ${to} || SUBSTRING("path" FROM ${cut}::int) WHERE "path" = ${from} OR "path" LIKE ${likeChildren}; `, ]); } } catch (err) { await moveObjects(to, from).catch((undoErr) => { console.error("[r2] base non mise à jour, objets non ramenés", { from, to }, undoErr); }); throw err; } }, async delete(path: StoragePath): Promise<void> { const s3 = getR2Client(); const Bucket = getR2Bucket(); await s3.send(new DeleteObjectCommand({ Bucket, Key: path })); await deleteAllUnderPrefix(s3, Bucket, path); // Phase 2 : nettoyer les MediaAssets DB correspondantes await prisma.mediaAsset.deleteMany({ where: { appRoot, OR: [ { fullPath: path }, { fullPath: { startsWith: `${path}/` } }, ], }, }); }, /* ====================================================================== */ /* Upload */ /* ====================================================================== */ async createUploadAuthorization( input: R2CreateUploadAuthorizationInput ): Promise<R2CreateUploadAuthorizationOutput> { assertUploadPathSafe(input.path, appRoot); assertUploadConstraints(input); const s3 = getR2PresignClient(); const Bucket = getR2Bucket(); const expiresInSeconds = 5 * 60; const expiresAtMs = Date.now() + expiresInSeconds * 1000; const command = new PutObjectCommand({ Bucket, Key: input.path, ContentType: input.mimeType, }); const uploadUrl = await getSignedUrl(s3, command, { expiresIn: expiresInSeconds, }); return { uploadUrl, fields: {}, expiresAt: new Date(expiresAtMs).toISOString(), }; }, /** * Phase 2 — créer la row MediaAsset après validation HeadObject. * * Pipeline : * 1. HeadObject pour confirmer l'existence + valider taille/mime * 2. Résoudre categoryId/disciplineId depuis input.destination * 3. Créer la row MediaAsset avec : * - fullPath = input.path (clé universelle) * - originalFileName = input.originalFileName * - mimeType, bytes = depuis HeadObject (source de vérité serveur) * - categoryId/disciplineId/proposedDisciplineName = depuis destination * - uploaderUserId = input.userId (depuis ctx.sessionClient.user.id côté router) * - status = 'pending' (les uploads atterrissent toujours en pending) */ async registerUploadedAsset( input: R2RegisterUploadedAssetInput ): Promise<R2RegisterUploadedAssetOutput> { const s3 = getR2Client(); const Bucket = getR2Bucket(); // 1. HeadObject — confirme existence + sert de source de vérité const head = await s3 .send(new HeadObjectCommand({ Bucket, Key: input.path })) .catch((err) => { if (isNotFoundError(err)) { // BAD_REQUEST et non une Error nue : une Error deviendrait une erreur interne, dont le // message est remplacé par un message neutre en production (errorFormatter). throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier n'est pas arrivé jusqu'au stockage. Relancez l'envoi.", }); } throw err; }); const actualBytes = head.ContentLength ?? 0; const actualMime = head.ContentType ?? ""; // Un objet refusé ici n'est référencé par aucune ligne MediaAsset : on le retire du // bucket pour ne pas laisser de fichier orphelin. S'il remplaçait un fichier déjà connu // (écrasement confirmé), on le laisse : la ligne existante pointe toujours vers lui. const reject = async (message: string): Promise<never> => { const known = await prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { id: true } }); if (!known) { await s3.send(new DeleteObjectCommand({ Bucket, Key: input.path })).catch((err) => { console.error("[r2] objet refusé non supprimé", input.path, err); }); } // Une saisie refusée, pas une panne : le message doit arriver jusqu'à l'utilisateur. throw new TRPCError({ code: "BAD_REQUEST", message }); }; // L'URL signée fixe la clé, le type et l'expiration, pas la taille : la limite dure est // donc vérifiée ici, sur la taille réelle lue chez le stockage. if (actualBytes > HARD_MAX_UPLOAD_BYTES) { await reject(`Fichier trop volumineux (${actualBytes} octets, maximum ${HARD_MAX_UPLOAD_BYTES}).`); } if (actualBytes !== input.expectedBytes) { await reject( `Le fichier reçu ne correspond pas à celui annoncé (${actualBytes} octets au lieu de ` + `${input.expectedBytes}). Relancez l'envoi.` ); } if (actualMime !== input.expectedMimeType) { await reject( `Le type du fichier reçu (« ${actualMime} ») ne correspond pas à celui annoncé ` + `(« ${input.expectedMimeType} »). Relancez l'envoi.` ); } // 2. Résoudre destination → categoryId + disciplineId // // Cas existing-discipline : on a directement les FK. // Cas new-discipline : on garde proposedDisciplineName, disciplineId = null. let categoryId: number | null = null; let disciplineId: number | null = null; let proposedDisciplineName: string | null = null; switch (input.destination.kind) { case "existing-discipline": categoryId = input.destination.categoryId; disciplineId = input.destination.disciplineId; break; case "new-discipline": categoryId = input.destination.categoryId; proposedDisciplineName = input.destination.proposedDisciplineName; break; case "common_repository": // Espace club partagé, sans discipline ni catégorie // (categoryId reste null). break; case "event": // Contenus d'un événement : ni catégorie ni discipline unique — le // rattachement se fait par `eventId` et par les // liens de disciplines de l'événement. break; case "group": // Espace de groupe collaboratif : rattachement par le chemin // (`groups/…`), sans catégorie ni discipline (comme `general`). break; case "seminar": // Contenus d'un séminaire : rattachement par le chemin (`seminars/…`), // sans catégorie ni discipline (MediaAsset n'a pas de seminarId). break; case "perso": // Espace personnel : photos seulement (aussi contrôlé à la demande d'envoi). if (!actualMime.startsWith("image/")) { await reject("Le dossier perso n'accepte que des images."); } break; default: // Exhaustivité : un nouveau kind non traité fera échouer le build ici. input.destination satisfies never; throw new Error("Unhandled upload destination kind."); } // 3. Création de la MediaAsset. // // Idempotence : si l'user re-soumet le même fichier (même path), // la contrainte @unique sur fullPath bloque le create. On utilise // upsert pour faire évoluer la row existante plutôt que d'échouer. // (Le ré-envoi n'arrive qu'après confirmation « Écraser ».) // Événement : rattachement du fichier et des disciplines. // (Rattacher des disciplines est réservé aux administrateurs : assertUploadDestinationAllowed.) const eventId = input.destination.kind === "event" ? input.destination.eventId : null; if (input.destination.kind === "event" && input.destination.disciplineIds.length > 0) { const { eventId: targetEventId, disciplineIds } = input.destination; const ids = [...new Set(disciplineIds)]; const found = await prisma.discipline.count({ where: { id: { in: ids } } }); if (found !== ids.length) { await reject("Discipline(s) introuvable(s) pour cet événement."); } await prisma.eventDiscipline.createMany({ data: ids.map((disciplineId) => ({ eventId: targetEventId, disciplineId })), skipDuplicates: true, }); } // Dimensions (images, vidéos) : meilleur effort, jamais bloquant. const dims = (await probeMediaDimensions(input.path, actualMime, actualBytes)) ?? {}; const asset = await prisma.mediaAsset.upsert({ where: { fullPath: input.path }, create: { eventId, width: dims.width ?? null, height: dims.height ?? null, duration: dims.duration ?? null, fullPath: input.path, mimeType: actualMime, format: extensionOf(input.path), originalFileName: input.originalFileName, bytes: actualBytes, appRoot, status: "pending", categoryId, disciplineId, proposedDisciplineName, uploaderUserId: input.userId, }, update: { mimeType: actualMime, bytes: actualBytes, width: dims.width ?? null, height: dims.height ?? null, duration: dims.duration ?? null, originalFileName: input.originalFileName, // Pas de changement de catégorie/discipline sur re-upload (ce qui // serait peu intuitif — l'user qui re-soumet veut juste écraser). uploaderUserId: input.userId, }, }); return { ok: true as const, path: input.path, bytes: actualBytes, mimeType: actualMime, mediaAssetId: asset.id, }; }, };}/* -------------------------------------------------------------------------- *//* Helpers — listing récursif *//* -------------------------------------------------------------------------- */async function buildSubTree( s3: S3Client, Bucket: string, path: string, depth: number): Promise<StorageFolderNode> { const Prefix = ensureTrailingSlash(path); const normalizedPath = stripTrailingSlash(path); const response = await s3.send( new ListObjectsV2Command({ Bucket, Prefix, Delimiter: "/", MaxKeys: 1000, }) ); const subPrefixes = (response.CommonPrefixes ?? []) .map((cp) => cp.Prefix) .filter((p): p is string => Boolean(p)) .map(stripTrailingSlash); let subFolders: StorageFolderNode[]; if (depth > 0) { subFolders = await Promise.all( subPrefixes.map((p) => buildSubTree(s3, Bucket, p, depth - 1)) ); } else { subFolders = subPrefixes.map((p) => ({ type: "folder" as const, name: lastSegment(p), path: p, hasChildren: true, })); } const files: StorageFileNode[] = (response.Contents ?? []) .filter( (obj): obj is S3Object & { Key: string } => Boolean(obj.Key) && obj.Key !== Prefix ) .map((obj) => objectToFileNode(obj)); const children: StorageNode[] = [...subFolders, ...files]; return { type: "folder" as const, name: lastSegment(normalizedPath), path: normalizedPath, children, hasChildren: children.length > 0, };}function objectToFileNode(obj: S3Object & { Key: string }): StorageFileNode { return { type: "file" as const, name: lastSegment(obj.Key), path: obj.Key, metadata: { bytes: obj.Size, updatedAt: obj.LastModified?.toISOString(), mimeType: inferMimeFromPath(obj.Key), format: extensionOf(obj.Key), }, };}/* -------------------------------------------------------------------------- *//* Helpers — validation upload *//* -------------------------------------------------------------------------- */function assertUploadPathSafe(path: string, appRoot: string): void { if (!path) throw new Error("createUploadAuthorization: path vide"); if (path.startsWith("/")) throw new Error(`createUploadAuthorization: path ne doit pas commencer par "/" (reçu: "${path}")`); if (path.endsWith("/")) throw new Error(`createUploadAuthorization: path ne doit pas se terminer par "/" (reçu: "${path}")`); if (path.includes("..")) throw new Error(`createUploadAuthorization: segment ".." interdit (path traversal) dans "${path}"`); if (path.includes("//")) throw new Error(`createUploadAuthorization: "//" consécutifs interdits dans "${path}"`); if (!path.startsWith(`${appRoot}/`)) { throw new Error( `createUploadAuthorization: path doit commencer par "${appRoot}/" ` + `(reçu: "${path}"). Aucun upload hors de la racine applicative n'est autorisé.` ); }}function assertUploadConstraints(input: R2CreateUploadAuthorizationInput): void { if (!input.mimeType || input.mimeType.length === 0) { throw new Error("createUploadAuthorization: mimeType requis"); } if (input.maxBytes <= 0) { throw new Error(`createUploadAuthorization: maxBytes doit être > 0 (reçu: ${input.maxBytes})`); } if (input.maxBytes > HARD_MAX_UPLOAD_BYTES) { throw new Error( `createUploadAuthorization: maxBytes ${input.maxBytes} dépasse la limite ` + `dure de ${HARD_MAX_UPLOAD_BYTES} octets.` ); }}/* -------------------------------------------------------------------------- *//* Helpers — écriture *//* -------------------------------------------------------------------------- */async function moveFile( s3: S3Client, Bucket: string, srcKey: string, dstKey: string): Promise<void> { await s3.send( new CopyObjectCommand({ Bucket, Key: dstKey, CopySource: buildCopySource(Bucket, srcKey), }) ); await s3.send(new DeleteObjectCommand({ Bucket, Key: srcKey }));}/** Copies simultanées au plus, pendant le déplacement d'un dossier. */const COPY_CONCURRENCY = 16;async function moveFolder( s3: S3Client, Bucket: string, srcPath: string, dstPath: string): Promise<void> { const srcPrefix = ensureTrailingSlash(srcPath); const dstPrefix = ensureTrailingSlash(dstPath); let continuationToken: string | undefined; do { const response = await s3.send( new ListObjectsV2Command({ Bucket, Prefix: srcPrefix, ContinuationToken: continuationToken, MaxKeys: 1000, }) ); const objects = (response.Contents ?? []).filter( (o): o is S3Object & { Key: string } => Boolean(o.Key) ); if (objects.length > 0) { // Par paquets : une page compte jusqu'à 1 000 objets, et autant de copies simultanées // saturent les connexions du client (et les limites de débit du stockage). for (let i = 0; i < objects.length; i += COPY_CONCURRENCY) { await Promise.all( objects.slice(i, i + COPY_CONCURRENCY).map((obj) => { const relativeKey = obj.Key.slice(srcPrefix.length); const newKey = dstPrefix + relativeKey; return s3.send( new CopyObjectCommand({ Bucket, Key: newKey, CopySource: buildCopySource(Bucket, obj.Key), }) ); }) ); } await s3.send( new DeleteObjectsCommand({ Bucket, Delete: { Objects: objects.map((o) => ({ Key: o.Key })), Quiet: true, }, }) ); } continuationToken = response.NextContinuationToken; } while (continuationToken);}async function deleteAllUnderPrefix( s3: S3Client, Bucket: string, path: string): Promise<void> { const Prefix = ensureTrailingSlash(path); let continuationToken: string | undefined; do { const response = await s3.send( new ListObjectsV2Command({ Bucket, Prefix, ContinuationToken: continuationToken, MaxKeys: 1000, }) ); const objects = (response.Contents ?? []).filter( (o): o is S3Object & { Key: string } => Boolean(o.Key) ); if (objects.length > 0) { await s3.send( new DeleteObjectsCommand({ Bucket, Delete: { Objects: objects.map((o) => ({ Key: o.Key })), Quiet: true, }, }) ); } continuationToken = response.NextContinuationToken; } while (continuationToken);}function buildCopySource(Bucket: string, srcKey: string): string { const encodedKey = srcKey.split("/").map(encodeURIComponent).join("/"); return `${Bucket}/${encodedKey}`;}/* -------------------------------------------------------------------------- *//* Helpers — paths *//* -------------------------------------------------------------------------- */function lastSegment(path: string): string { const trimmed = stripTrailingSlash(path); const i = trimmed.lastIndexOf("/"); return i === -1 ? trimmed : trimmed.slice(i + 1);}function stripTrailingSlash(path: string): string { return path.endsWith("/") ? path.slice(0, -1) : path;}function ensureTrailingSlash(path: string): string { return path.endsWith("/") ? path : `${path}/`;}function extensionOf(path: string): string | undefined { const name = lastSegment(path); const i = name.lastIndexOf("."); if (i === -1 || i === name.length - 1) return undefined; return name.slice(i + 1).toLowerCase();}/* -------------------------------------------------------------------------- *//* Helpers — MIME inference & error detection *//* -------------------------------------------------------------------------- */function inferMimeFromPath(path: string): string { const ext = extensionOf(path); if (!ext) return "application/octet-stream"; switch (ext) { case "mp3": return "audio/mpeg"; case "wav": return "audio/wav"; case "ogg": return "audio/ogg"; case "m4a": return "audio/mp4"; case "flac": return "audio/flac"; case "aac": return "audio/aac"; case "opus": return "audio/opus"; case "pdf": return "application/pdf"; case "txt": return "text/plain"; case "md": case "markdown": return "text/markdown"; case "json": return "application/json"; case "csv": return "text/csv"; case "xml": return "application/xml"; case "html": return "text/html"; case "doc": return "application/msword"; case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; case "xls": return "application/vnd.ms-excel"; case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; case "ppt": return "application/vnd.ms-powerpoint"; case "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; case "zip": return "application/zip"; case "tar": return "application/x-tar"; case "gz": return "application/gzip"; case "rar": return "application/vnd.rar"; case "7z": return "application/x-7z-compressed"; default: return "application/octet-stream"; }}function isNotFoundError(err: unknown): boolean { if (typeof err !== "object" || err === null) return false; const e = err as { name?: string; $metadata?: { httpStatusCode?: number } }; if (e.name === "NotFound" || e.name === "NoSuchKey") return true; if (e.$metadata?.httpStatusCode === 404) return true; return false;}La réécriture des lignes est écrite une fois, et sert à tous les gestes qui changent la clé d’un objet — déplacer, jeter, restaurer :
export function rewriteAssetPaths( db: Db, appRoot: string, kind: "file" | "folder", from: string, to: string,): Prisma.PrismaPromise<number> { if (kind === "file") { return db.$executeRaw` UPDATE "MediaAsset" SET "fullPath" = ${to} WHERE "appRoot" = ${appRoot} AND "fullPath" = ${from}; `; } const cut = from.length + 1; const likeChildren = `${from}/`.replace(/[\\%_]/g, "\\$&") + "%"; return db.$executeRaw` UPDATE "MediaAsset" SET "fullPath" = ${to} || SUBSTRING("fullPath" FROM ${cut}::int) WHERE "appRoot" = ${appRoot} AND "fullPath" LIKE ${likeChildren}; `;}Prisma n’offre pas de remplacement de sous-chaîne dans updateMany : d’où le SQL brut, paramétré — le gabarit étiqueté $executeRaw transforme chaque valeur interpolée en paramètre de requête, jamais en texte concaténé (fiche 6.5). Dans un LIKE, _ et % sont des jokers : un dossier « cours_2026 » correspondrait aussi à « coursX2026 ». On les échappe ; seul le % final reste un joker. Et SUBSTRING remplace le début du chemin, sous-chemin préservé.
La structure se déplace avec ce qu’on déplace#
Une sélection peut mêler fichiers et dossiers. Le service de résolution en fait des opérations :
resolveSource — une sélection en opérations
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { TRPCError } from "@trpc/server";import type { StorageAdapter, StorageMoveIntent, StorageMoveOperation, StorageMoveSource, StorageNode,} from "@contracts/storage";/** * resolveMoveIntent.service.ts * * Couche 2 du pipeline de move : résout une intention riche en N opérations atomiques, puis les * exécute via l'adapter de stockage. * * Couche 3 — INTENTION StorageMoveIntent (entrée) * Couche 2 — RÉSOLUTION ce service * Couche 1 — OPÉRATION StorageMoveOperation (sortie) * * Deux phases séparées, pour que le router puisse filtrer ou garder entre les deux : * `planMoveOperations` (lit et calcule, n'écrit rien), puis `executeMoveOperations`. * * ─── La structure se déplace avec ce qu'on déplace ──────────────────────── * * La cible d'un élément est `<dossier cible>/<chemin de l'élément relatif au PARENT de sa racine>`. * Déplacer le dossier `AKFC/events/forum` dans `AKFC/courses/kali` produit * `AKFC/courses/kali/forum/…`, sous-dossiers compris. Une sélection qui contient un dossier * le déplace ENTIER (une seule opération dossier) ; on ne descend aux fichiers que si une * exclusion vise l'intérieur de ce dossier, et chacun garde alors son chemin relatif. * * Il ne fait pas : d'appel direct au stockage (tout passe par l'adapter), de validation zod (faite * par le router), ni de logique de base (l'adapter réaligne les lignes `MediaAsset`). */export type ResolveMoveIntentParams = { adapter: StorageAdapter; appRoot: string; intent: StorageMoveIntent;};/** Phase 1 : l'intention en opérations atomiques, sans rien exécuter. */export async function planMoveOperations( params: ResolveMoveIntentParams): Promise<StorageMoveOperation[]> { const { adapter, intent } = params; if (!adapter.move) { throw new Error("Adapter does not support move()."); } const targetFolder = trimSlashes(intent.target.path); const items = await resolveSource(intent.source, adapter); return items.map((item) => { // Un dossier ne se dépose ni dans lui-même, ni dans l'un de ses descendants : la boucle // déplacerait ses propres objets sous un préfixe qu'elle est en train de vider. if ( item.type === "folder" && (targetFolder === item.path || targetFolder.startsWith(`${item.path}/`)) ) { throw new TRPCError({ code: "BAD_REQUEST", message: "Un dossier ne peut pas être déplacé dans lui-même.", }); } return { source: { type: item.type, path: item.path }, target: { path: `${targetFolder}/${item.relative}` }, }; });}/** * Phase 2 : exécution des opérations planifiées. Séquentielle : des opérations concurrentes sur * des préfixes voisins pourraient se marcher dessus. * * Tout ou rien, par compensation : un geste multiple n'a pas de transaction commune (chaque * opération touche le stockage ET la base). Si l'opération k échoue — l'adapter a déjà ramené ses * propres objets —, on défait les opérations 1..k-1 en sens inverse, de la plus récente à la plus * ancienne. L'utilisateur retrouve la bibliothèque telle qu'avant son geste, et un message qui le * dit. Ce que la compensation ne couvre pas : un arrêt du serveur au milieu du geste. */export async function executeMoveOperations( adapter: StorageAdapter, operations: readonly StorageMoveOperation[]): Promise<void> { const move = adapter.move?.bind(adapter); if (!move) { throw new Error("Adapter does not support move()."); } const done: StorageMoveOperation[] = []; for (const operation of operations) { try { await move(operation); done.push(operation); } catch (err) { const notUndone: string[] = []; for (const prev of [...done].reverse()) { await move({ source: { type: prev.source.type, path: prev.target.path }, target: { path: prev.source.path }, }).catch((undoErr) => { notUndone.push(prev.source.path); console.error("[move] retour impossible", prev, undoErr); }); } console.error("[move] échec, geste annulé", operation, err); // SERVICE_UNAVAILABLE et non INTERNAL_SERVER_ERROR : c'est le stockage (ou la base) qui a // failli, pas la requête — et le message, qui dit ce qui reste à faire, doit arriver jusqu'à // l'administrateur (une erreur interne voit son message remplacé en production). throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: notUndone.length === 0 ? "Le déplacement a échoué ; rien n'a été déplacé." : `Le déplacement a échoué, et ${notUndone.length} élément(s) n'ont pas pu être remis à leur place.`, cause: err, }); } }}/* -------------------------------------------------------------------------- *//* Résolution de la source *//* -------------------------------------------------------------------------- */type ResolvedItem = { type: "file" | "folder"; path: string; /** Chemin à recréer sous le dossier cible : relatif au parent de la racine sélectionnée. */ relative: string;};async function resolveSource( source: StorageMoveSource, adapter: StorageAdapter): Promise<ResolvedItem[]> { if (source.type === "file" || source.type === "folder") { const path = trimSlashes(source.path); return [{ type: source.type, path, relative: lastSegment(path) }]; } const excluded = (source.excluded ?? []).map(trimSlashes); const isExcluded = (path: string): boolean => excluded.some((ex) => path === ex || path.startsWith(`${ex}/`)); const out: ResolvedItem[] = []; const seen = new Set<string>(); const push = (item: ResolvedItem): void => { if (seen.has(item.path)) return; seen.add(item.path); out.push(item); }; for (const root of source.roots.map(trimSlashes)) { if (isExcluded(root)) continue; const name = lastSegment(root); const rootNode = adapter.getNode ? await adapter.getNode(root) : null; if (rootNode?.type === "file") { push({ type: "file", path: root, relative: name }); continue; } // Dossier sans exclusion à l'intérieur : déplacé d'un bloc. if (!excluded.some((ex) => ex.startsWith(`${root}/`))) { push({ type: "folder", path: root, relative: name }); continue; } // Dossier avec exclusions : fichier par fichier, chacun gardant son chemin relatif. if (!adapter.getTree) { throw new Error("Adapter does not support getTree()."); } const subtree = await adapter.getTree({ path: root, depth: Infinity }); walkTree(subtree.root, (node) => { if (node.type !== "file" || isExcluded(node.path)) return; push({ type: "file", path: node.path, relative: `${name}/${node.path.slice(root.length + 1)}` }); }); } return out;}function walkTree(node: StorageNode, visit: (node: StorageNode) => void): void { visit(node); if (node.type === "folder" && node.children) { for (const child of node.children) walkTree(child, visit); }}function trimSlashes(path: string): string { return path.replace(/^\/+|\/+$/g, "");}function lastSegment(path: string): string { const parts = path.split("/").filter(Boolean); return parts[parts.length - 1] ?? path;}La première version, héritée de l’époque des dossiers de statut, développait tout dossier sélectionné en ses fichiers, et posait chacun sous <cible>/<nom du fichier> : les sous-dossiers disparaissaient, leur contenu était aplati dans la cible, et deux photo.jpg de sous-dossiers différents se percutaient. C’est le genre de bogue qu’aucun test « un fichier dans un dossier » ne voit.
Le même service refuse de déposer un dossier dans lui-même ou dans l’un de ses descendants : la boucle de copie déplacerait ses propres objets sous un préfixe qu’elle est en train de vider.
La procédure de déplacement#
storage.move — tout vérifier, puis tout exécuter
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { z } from "zod";import { assertOutsideAvatars, avatarsPrefix, outsideBin } from "@backend/modules/storage/mediaPaths";import { TRPCError } from "@trpc/server";/** * Ce qui compte comme extension en fin de nom : 1 à 8 caractères * alphanumériques. Exclut les points internes d'un libellé (« (ep. BAZZE) »). */const EXTENSION_PATTERN = /\.[A-Za-z0-9]{1,8}$/;/** * La corbeille est une FEUILLE dans l'arbre du finder : on n'expose jamais son * sous-arbre physique `.trash/<uuid>/…`. On elague recursivement les enfants du * noeud dont le chemin est exactement `binPath`. (La vue plate lit trash.listBin, * le drill-down passe par `list` — aucun ne depend de getTree.) *//** * La corbeille (`<racine>/bin/…`) n'appartient qu'au routeur `trash` : ni renommage ni déplacement * n'y entre ou n'en sort par `storage`. Restaurer et vider ont leurs propres gardes (TrashEntry). */function assertOutsideBin(appRoot: string, paths: readonly string[]): void { const bin = `${appRoot}/bin`; if (paths.some((p) => p === bin || p.startsWith(`${bin}/`))) { throw new TRPCError({ code: "FORBIDDEN", message: "La corbeille se gère depuis sa propre vue (restaurer, supprimer).", }); }}function stripBinSubtree(node: StorageFolderNode, binPath: string): void { for (const child of node.children ?? []) { if (child.type !== "folder") continue; if (child.path === binPath) { child.children = []; child.hasChildren = false; } else { stripBinSubtree(child, binPath); } }}import { enrichFilesWithStatus, enrichTreeWithStatus,} from "@backend/modules/storage/services/enrichStatus.service";import { router, protectedProcedure } from "@backend/trpc";import { isAdmin } from "@backend/trpc/middleware";import { storageProviderSchema, storageMoveIntentSchema, createR2UploadAuthorizationSchema, type StorageFolderNode,} from "@contracts/storage";import { uploadDestinationSchema, type UploadDestination } from "@contracts/storage/upload-destination.schema";import { uploadTypeError } from "@contracts/storage/allowedUploadTypes";import { getAdapter } from "@backend/modules/storage/providerRegistry";import { VirtualStorage } from "@backend/modules/storage/virtualStorage";import { isProtectedEntityFolderPath } from "@backend/modules/storage/protectedEntityFolder";import { isRootFolder } from "@backend/modules/storage/services/ensureRootFolders.service";import { planMoveOperations, executeMoveOperations,} from "@backend/modules/storage/resolveMoveIntent.service";import { countPersoImages } from "@backend/modules/media/services/countPersoImages.service";import { PERSO_PHOTO_QUOTA } from "@backend/modules/media/services/persoPhotoQuota.constants";import { listCommonRepositoryFolders } from "@backend/modules/media/services/listCommonRepositoryFolders.service";import { listMyCommonRepositoryContainers } from "@backend/modules/media/services/listMyCommonRepositoryContainers.service";import { commonRepositoryContainerPath } from "@backend/modules/media/services/commonRepositoryContainerPath.service";import { resolvePendingUploadFolder } from '@backend/modules/storage/services/resolvePendingUploadFolder.service';import { assertCanReadPath } from "@backend/modules/memberGroups/assertCanReadPath.service";import { assertCanWritePaths } from "@backend/modules/memberGroups/assertCanWritePaths.service";import { resolveGroupBaseFolder } from "@backend/modules/media/services/resolveGroupBaseFolder.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { scopePersoSpaceInTree } from "@backend/modules/storage/scopePersoSpace.service";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { mergeGroupSpaceFolders, mergeGroupSpaceFoldersIntoTree,} from "@backend/modules/storage/mergeGroupSpaceFolders.service";import { ensureContentRootsInTree, missingContentRootFolders, requiredRootPaths,} from "@backend/modules/storage/ensureContentRoots.service";import { applyGroupSpaceNamesToFolders, applyGroupSpaceNamesToTree,} from "@backend/modules/storage/applyGroupSpaceNames.service";import { assertUploadDestinationAllowed } from '@backend/modules/storage/assertUploadDestinationAllowed.service';import { buildUploadFileName } from '@backend/modules/storage/services/buildUploadFileName.service';/** * storageRouter — finder (lecture, renommage, déplacement) et envois vers R2. *//* -------------------------------------------------------------------------- *//* Schema R2 Phase 2 — inline *//* -------------------------------------------------------------------------- *//** * Destination metier — discriminée pour gérer les deux cas : * - existing-discipline : on a categoryId + disciplineId direct * - new-discipline : on a categoryId + proposedDisciplineName (admin validera plus tard) * * Forme identique à ce que `DragNDropForm` construit côté frontend. *//** * Remplacer un fichier déjà connu, c'est publier un contenu SANS repasser par la validation : la * ligne garde son statut. Un dépôt ouvert à tout membre (discipline, stage, événement, dépôt commun) * ne doit donc pas permettre de remplacer un fichier publié, ou déposé par quelqu'un d'autre. * Autorisé : un administrateur ; un éditeur dans l'espace de son groupe (il peut déjà y mettre à la * corbeille ; son droit d'écriture est vérifié par assertUploadDestinationAllowed) ; l'auteur d'un * fichier encore en attente. Vérifié à la signature ET à l'enregistrement : l'enregistrement met à * jour la ligne existante, auteur compris. */function mayReplace( existing: { status: string; uploaderUserId: string }, user: { id: string; isAdmin: boolean }, destination: UploadDestination,): boolean { return ( user.isAdmin || destination.kind === "group" || (existing.status === "pending" && existing.uploaderUserId === user.id) );}const registerR2UploadInputSchema = z.object({ path: z.string().min(1), expectedBytes: z.number().int().positive(), expectedMimeType: z.string().min(1), // Phase 2 — nouveaux champs requis pour créer la row MediaAsset destination: uploadDestinationSchema, originalFileName: z.string().min(1).max(255),});/* -------------------------------------------------------------------------- *//* Router *//* -------------------------------------------------------------------------- */export const storageRouter = router({ /* ====================================================================== */ /* Lecture (inchangé) */ /* ====================================================================== */ /** * Compteurs « à traiter » de la bibliothèque, pour la cloche du header : * assets en attente de classement (MediaAsset.status "pending") et * entrées de corbeille (TrashEntry IN_BIN). Réservée aux administrateurs : ce sont des * compteurs de toute la bibliothèque, et la cloche qui les affiche n'existe que pour eux. */ getAttentionCounts: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const [pending, bin, commonRepositoryPending, persoCounts] = await Promise.all([ // Hors corbeille : un fichier jeté garde sa ligne (pour sa restauration), pas son tour. ctx.prisma.mediaAsset.count({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) } }), ctx.prisma.trashEntry.count({ where: { status: "IN_BIN" } }), ctx.prisma.mediaAsset.count({ where: { status: "pending", appRoot: ctx.appRoot, fullPath: { startsWith: `${ctx.appRoot}/common-repository/` }, }, }), countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }), ]); return { pending, bin, commonRepositoryPending, persoPending: persoCounts.pending, }; }), /** * Ventilation des contenus « en attente » par dossier parent. * * Renvoie le `kind` du dossier plutôt qu'un libellé : la formulation * (« le stockage général », « votre stockage personnel »…) appartient à * l'UI, pas au backend. Les dossiers sont triés par volume décroissant. * Réservée aux administrateurs : elle nomme les dossiers de toute la bibliothèque, * espaces personnels compris. */ getPendingBreakdown: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const rows = await ctx.prisma.mediaAsset.findMany({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) }, select: { fullPath: true }, }); const counts = new Map<string, number>(); for (const row of rows) { const segments = row.fullPath.split("/"); segments.pop(); // le nom du fichier const folder = segments.join("/"); if (!folder) continue; counts.set(folder, (counts.get(folder) ?? 0) + 1); } const commonRepositoryRoot = `${ctx.appRoot}/common-repository`; const persosRoot = `${ctx.appRoot}/personal-space`; const entries = Array.from(counts.entries()).map(([path, count]) => { let kind: "common_repository" | "perso" | "folder" = "folder"; if (path === commonRepositoryRoot || path.startsWith(`${commonRepositoryRoot}/`)) { kind = "common_repository"; } else if (path.startsWith(`${persosRoot}/`)) { // Personnel de l'utilisateur courant uniquement : le dossier porte // son id. Ceux des autres restent des dossiers ordinaires. kind = path.includes(ctx.sessionClient.user.id) ? "perso" : "folder"; } // Nom lisible : pour le dossier personnel d'un AUTRE utilisateur, le // dernier segment vaut « photos » et n'apprend rien — on prend le // segment qui identifie la personne. let name = path.split("/").pop() ?? path; if (kind === "folder" && path.startsWith(`${persosRoot}/`)) { name = path.slice(persosRoot.length + 1).split("/")[0] ?? name; } return { path, count, kind, name }; }); entries.sort((a, b) => b.count - a.count); return { total: rows.length, entries }; }), /** * Statut du quota d'images de l'espace perso de l'admin courant (lecture * seule). Le dossier perso est dérivé de `ctx.sessionClient.user.id`, jamais d'un input. */ getPersoPhotoQuota: protectedProcedure.query(async ({ ctx }) => { const counts = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const remaining = Math.max(0, PERSO_PHOTO_QUOTA - counts.total); return { quota: PERSO_PHOTO_QUOTA, pending: counts.pending, published: counts.published, total: counts.total, remaining, }; }), /** * Sous-dossiers existants sous `general/` (pending + published), pour peupler * le select « dossier existant » de l'uploader général. */ listCommonRepositoryFolders: protectedProcedure.query(async ({ ctx }) => { return listCommonRepositoryFolders({ prisma: ctx.prisma, appRoot: ctx.appRoot }); }), // Sujets des dépôts de l'utilisateur courant (page membre) — scopé user. listMyCommonRepositoryContainers: protectedProcedure.query(async ({ ctx }) => { return listMyCommonRepositoryContainers({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); }), // Pose le libellé humain d'un conteneur de dépôt (appelé par le front après // un dépôt réussi). Le path est dérivé de ctx.sessionClient.user.id → un membre ne peut // labelliser QUE ses propres conteneurs. setCommonRepositoryLabel: protectedProcedure .input(z.object({ subject: z.string().trim().min(1).max(120), label: z.string().trim().min(1).max(200) })) .mutation(async ({ ctx, input }) => { const path = await commonRepositoryContainerPath({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, subject: input.subject, }); await ctx.prisma.folderLabel.upsert({ where: { path }, update: { displayName: input.label }, create: { path, displayName: input.label }, }); return { ok: true }; }), /** * Hiérarchie des espaces collaboratifs : chemin d'espace RÉEL + parent, * pour l'imbrication visuelle du finder admin (les chemins physiques * restent inchangés). Réservé aux admins. */ groupSpaceHierarchy: protectedProcedure.query(async ({ ctx }) => { if (!ctx.sessionClient.user.isAdmin) return []; const groups = await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }); return Promise.all( groups.map(async (g) => ({ groupId: g.id, name: g.name, parentGroupId: g.parentGroupId, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: g.id, }), })), ); }), /** * Espaces des groupes collaboratifs accessibles à l'utilisateur courant * (racines du finder côté membre) : chemin + droit par groupe. * * Passerelle admin : un administrateur est d'emblée ÉDITEUR de TOUS les * espaces collaboratifs (au-dessus du système de groupes), sans * appartenance manuelle. */ myCollaborativeSpaces: protectedProcedure.query(async ({ ctx }) => { const isAdmin = ctx.sessionClient.user.isAdmin; const entries: { groupId: string; name: string; access: "VIEWER" | "EDITOR"; parentGroupId: string | null; }[] = isAdmin ? ( await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }) ).map((g) => ({ groupId: g.id, name: g.name, access: "EDITOR" as const, parentGroupId: g.parentGroupId })) : await collaborativeEntriesForMember(ctx.prisma, ctx.sessionClient.user.id); return Promise.all( entries.map(async (e) => ({ ...e, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: e.groupId, }), })), ); }), list: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), cursor: z.string().optional(), limit: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; const listed = await reader.list({ path: input.path, cursor: input.cursor, limit: input.limit, }); // Les avatars ne sont pas des médias de la bibliothèque : leur dossier n'est jamais listé. const result = { ...listed, folders: listed.folders.filter((f) => f.path !== avatarsPrefix(ctx.appRoot)), }; // Le statut vit en DB depuis l'aplatissement : on le rapatrie ici. await enrichFilesWithStatus(ctx.prisma, ctx.appRoot, result.files); // Espaces de groupe visibles même vides : R2 n'a pas de // vrais dossiers, un espace sans asset s'évaporerait du listing. if (input.path === `${ctx.appRoot}/collaborative-group-spaces`) { const merged = await mergeGroupSpaceFolders({ result, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); return { ...merged, folders: await applyGroupSpaceNamesToFolders( merged.folders, ctx.prisma, ), }; } // Racine du finder : racines principales visibles même vides (grille). if (input.path === ctx.appRoot) { const missing = await missingContentRootFolders( result.folders.map((f) => f.path), ctx.prisma, ctx.appRoot, ); return { ...result, folders: await applyGroupSpaceNamesToFolders( [...result.folders, ...missing], ctx.prisma, ), }; } return result; }), getTree: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), depth: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; // Perso : quand la grille demande le conteneur personal-space // (adapter.list = getTree depth1), on lit en réalité le sous-espace de // l'utilisateur courant → il ne voit QUE son contenu (privé + illusion). const persoContainer = `${ctx.appRoot}/personal-space`; let readPath = input.path; if (input.path === persoContainer) { try { readPath = await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); } catch { readPath = input.path; } } const result = await reader.getTree({ path: readPath, depth: input.depth, }); // Corbeille = feuille : jamais de `.trash` dans l'arbre du finder. stripBinSubtree(result.root, `${ctx.appRoot}/bin`); // Avatars : hors de la bibliothèque, leur dossier (enfant direct de la racine) n'apparaît pas. if (result.root.children) { result.root.children = result.root.children.filter((c) => c.path !== avatarsPrefix(ctx.appRoot)); } await enrichTreeWithStatus(ctx.prisma, ctx.appRoot, result.root); // Espaces de groupe ET perso visibles même vides, dans l'arbre du finder. result.root = await mergeGroupSpaceFoldersIntoTree({ root: result.root, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Racines principales visibles même vides (courses/seminars/events/ // personal-space/collaborative-group-spaces/common-repository). // Uniquement à la RACINE : la grille fait adapter.list = getTree // depth1 sur CHAQUE dossier ; sans cette garde on injecterait les // racines de contenu dans tous les dossiers. if (input.path === ctx.appRoot) { result.root = await ensureContentRootsInTree( result.root, ctx.prisma, ctx.appRoot, ); } // Perso : dans l'arbre racine, aplatir personal-space sur le contenu du // sous-espace de l'utilisateur courant (masque les autres admins). if (input.path === ctx.appRoot) { result.root = scopePersoSpaceInTree( result.root, ctx.appRoot, ctx.sessionClient.user.id, ); } // Nom EXACT des dossiers d'espace de groupe (accents) — c'est getTree // que lit l'adapter du finder (et le picker). return { ...result, root: await applyGroupSpaceNamesToTree(result.root, ctx.prisma), }; }), // Libellés d'affichage des dossiers (découplés du chemin physique). folderLabels: protectedProcedure.query(({ ctx }) => ctx.prisma.folderLabel.findMany({ select: { path: true, displayName: true }, }), ), // Racines de contenu (label-only) : le front y route « Renommer » vers // l'édition du FolderLabel plutôt que le rename physique. contentRootPaths: protectedProcedure.query(({ ctx }) => requiredRootPaths(ctx.prisma, ctx.appRoot), ), setFolderLabel: protectedProcedure .input( z.object({ path: z.string().min(1), displayName: z.string().trim().max(255), }), ) .mutation(async ({ ctx, input }) => { // Un libellé change ce que TOUT LE MONDE voit : même droit qu'un renommage. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideAvatars(ctx.appRoot, [input.path]); const name = input.displayName.trim(); // Libellé vide = on retire l'override (retour au repli du listing). if (name === "") { await ctx.prisma.folderLabel.deleteMany({ where: { path: input.path } }); return { path: input.path, displayName: null as string | null }; } const row = await ctx.prisma.folderLabel.upsert({ where: { path: input.path }, create: { path: input.path, displayName: name }, update: { displayName: name }, }); return { path: row.path, displayName: row.displayName as string | null }; }), getNode: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getNode) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getNode().` ); } return reader.getNode(input.path); }), getMetadata: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getMetadata) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getMetadata().` ); } return reader.getMetadata(input.path); }), /** * Renomme un fichier ou un dossier — c'est-à-dire un `move` vers le même * parent sous un nouveau nom. * * `newBaseName` est le nom SANS extension : le client n'édite que la base * (l'extension affichée est reconstruite par `displayName`). L'extension de * la source est réappliquée telle quelle. */ rename: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), type: z.enum(["file", "folder"]).default("file"), newBaseName: z.string().min(1).max(255), }) ) .mutation(async ({ ctx, input }) => { // Écriture : un administrateur partout, un éditeur dans l'espace de son groupe. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideBin(ctx.appRoot, [input.path]); assertOutsideAvatars(ctx.appRoot, [input.path]); const cleanName = input.newBaseName.trim(); if (!cleanName) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas être vide.", }); } if (cleanName.includes("/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « / ».", }); } // « . » et « .. » feraient un chemin que toutes les gardes refusent (`includes("..")`) : le // dossier renommé deviendrait illisible pour tout le monde. if (cleanName === "." || cleanName.includes("..")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « .. ».", }); } const segments = input.path.split("/"); const currentName = segments.pop() ?? ""; const parent = segments.join("/"); if (!parent) { throw new TRPCError({ code: "BAD_REQUEST", message: "Impossible de renommer un élément racine.", }); } // Dossier-entité : nom PHYSIQUE immuable. Sa garde de suppression // repose sur son chemin ; le renommer (déplacement du binaire) // casserait cette protection. Refusé (un libellé d'affichage passera // par un mécanisme séparé). if (input.type === "folder" && isProtectedEntityFolderPath(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être renommé.", }); } // Racine de contenu (catégorie / stages / events / espaces / dépôt) : // nom PHYSIQUE immuable aussi. Son « Renommer » édite le FolderLabel // côté client ; un rename physique casserait le lien catégorie↔dossier // et les gardes. Refusé ici (défense en profondeur). if (input.type === "folder") { const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if (roots.includes(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Cette racine ne se renomme pas physiquement (libellé éditable).", }); } } // ─── FICHIER : on n'édite QUE le nom d'affichage ─────────────────── // // Depuis la slugification (increment 1), la clé de stockage est un slug // stable et opaque. Renommer un fichier ne déplace donc plus rien chez // le provider et ne devine plus d'extension (fin du bug historique) : // c'est un simple UPDATE de `displayName`, par le chemin exact. Deux fichiers peuvent // partager un nom d'affichage : pas de contrôle de collision ici, la clé reste unique. if (input.type === "file") { const result = await ctx.prisma.mediaAsset.updateMany({ where: { appRoot: ctx.appRoot, fullPath: input.path }, data: { displayName: cleanName }, }); if (result.count === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Aucun média trouvé pour ce fichier.", }); } return { success: true, path: input.path }; } // Extension de la SOURCE, réappliquée telle quelle. // // ⚠️ Un point dans un nom n'est PAS forcément un séparateur // d'extension : « CNI recto PORQUET (ep. BAZZE) Yvonne » en contient // un. Couper au dernier point produirait une pseudo-extension // « . BAZZE) Yvonne » et mutilerait le fichier au renommage. On // n'accepte donc qu'un suffixe AYANT LA FORME d'une extension. // Un DOSSIER n'a pas d'extension : « photos.2024 » renommé en // « archives » doit donner « archives », pas « archives.2024 ». const extensionMatch = input.type === "folder" ? null : EXTENSION_PATTERN.exec(currentName); const extension = extensionMatch ? extensionMatch[0] : ""; const targetPath = `${parent}/${cleanName}${extension}`; // Renommage en son propre nom : rien à faire, mais pas une erreur. if (targetPath === input.path) { return { success: true, path: input.path }; } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); // On n'écrase jamais un élément existant : un objet (ou un dossier non vide) chez le stockage, // une ligne MediaAsset, un dossier enregistré (même contrôle que storage.move). const [collision, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(targetPath) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: targetPath }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: targetPath }, select: { id: true } }), ]); if (collision || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: "Un élément porte déjà ce nom dans ce dossier.", }); } // `move` est optionnel sur l'interface d'adapter : on garde avant // d'appeler, comme pour `getNode` plus haut. if (!adapter.move) { throw new TRPCError({ code: "NOT_IMPLEMENTED", message: "Ce provider ne supporte pas le déplacement.", }); } await adapter.move({ source: { type: input.type, path: input.path }, target: { path: targetPath }, }); return { success: true, path: targetPath }; }), move: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), intent: storageMoveIntentSchema, }) ) .mutation(async ({ ctx, input }) => { // Écriture : la SOURCE et la CIBLE. Un éditeur ne sort rien de l'espace de son groupe, et // n'y fait rien entrer qui vienne d'ailleurs. { const source = input.intent.source; const sourcePaths = source.type === "selection" ? source.roots : [source.path]; const targetPaths = [input.intent.target.path]; await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [...sourcePaths, ...targetPaths], }); assertOutsideBin(ctx.appRoot, [...sourcePaths, ...targetPaths]); assertOutsideAvatars(ctx.appRoot, [...sourcePaths, ...targetPaths]); } // Dossier-entité : chemin immuable, pas de déplacement du binaire. // Même raison qu'au rename ; couvre aussi les sélections multiples. { const movedPaths = input.intent.source.type === "folder" ? [input.intent.source.path] : input.intent.source.type === "selection" ? input.intent.source.roots : []; // Les racines de contenu (cours, stages, événements, espaces…) et la corbeille non plus : // leur chemin est connu du code (destinations d'envoi, gardes, libellés). const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if ( movedPaths.some( (pth) => isProtectedEntityFolderPath(pth) || roots.includes(pth) || isRootFolder(ctx.appRoot, pth), ) ) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être déplacé.", }); } } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const planned = await planMoveOperations({ adapter, appRoot: ctx.appRoot, intent: input.intent }); // Une opération sur place (déposer un élément dans son propre dossier) n'a rien à faire : // l'adapter renommerait un objet sur lui-même, le stockage refuserait, et tout le geste échouerait. const effectiveOperations = planned.filter( (operation) => operation.source.path !== operation.target.path, ); // Rien ne s'écrase en silence : une copie S3 remplace l'objet qui porte déjà la clé cible, et // la ligne MediaAsset déplacée heurterait la contrainte d'unicité APRÈS la copie — le fichier // d'arrivée serait perdu. On vérifie donc chaque cible avant la moindre écriture : un objet // (ou un dossier non vide) chez le stockage, une ligne MediaAsset, un dossier enregistré. const targets = new Set<string>(); for (const operation of effectiveOperations) { const target = operation.target.path; // Deux éléments de la même sélection qui arriveraient au même nom. if (targets.has(target)) { throw new TRPCError({ code: "CONFLICT", message: `Deux éléments de la sélection s'appellent « ${target.slice(target.lastIndexOf("/") + 1)} ».`, }); } targets.add(target); const [node, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(target) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: target }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: target }, select: { id: true } }), ]); if (node || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: `« ${target.slice(target.lastIndexOf("/") + 1)} » existe déjà dans le dossier de destination.`, }); } } // Exécution séquentielle : des opérations concurrentes sur des // préfixes voisins pourraient se marcher dessus. await executeMoveOperations(adapter, effectiveOperations); return { operations: effectiveOperations }; }), /* ====================================================================== */ /* Upload R2 — Phase 2 (enrichi avec destination + originalFileName) */ /* ====================================================================== */ /** * Noms de fichiers qui existent DÉJÀ dans la destination (pour demander avant d'écraser). */ checkR2Conflicts: protectedProcedure .input(z.object({ destination: uploadDestinationSchema, fileNames: z.array(z.string().min(1).max(255)).max(100) })) .query(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination }); const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Deux fichiers du même lot peuvent donner la même clé (« Photo 1.JPG » et « photo-1.jpg ») : // envoyés en parallèle, le second écraserait le premier sans que personne ne l'ait demandé. // On les signale par leur position dans le lot (deux noms identiques ne se distinguent pas). const byPath = new Map<string, string>(); const duplicateIndexes: number[] = []; input.fileNames.forEach((name, index) => { const path = `${folder}/${buildUploadFileName(name)}`; if (byPath.has(path)) duplicateIndexes.push(index); else byPath.set(path, name); }); const found = await ctx.prisma.mediaAsset.findMany({ where: { fullPath: { in: [...byPath.keys()] } }, select: { fullPath: true } }); return { existing: found.map((f) => byPath.get(f.fullPath)).filter((name): name is string => Boolean(name)), duplicateIndexes, }; }), createR2Upload: protectedProcedure .input(createR2UploadAuthorizationSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin se calcule ICI, une seule fois (jamais côté client). const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const fileName = buildUploadFileName(input.originalFileName); const path = `${folder}/${fileName}`; // Formats autorisés seulement (extension ET type déclaré) : jamais de HTML, SVG ou // script, qu'un navigateur pourrait exécuter s'il était affiché. Le type est signé dans // l'URL d'envoi, puis revérifié par registerR2Upload sur l'objet réellement stocké. const typeError = uploadTypeError(input.originalFileName, input.mimeType); if (typeError) { throw new TRPCError({ code: "BAD_REQUEST", message: typeError }); } if (input.destination.kind === "perso" && !input.mimeType.startsWith("image/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier perso n'accepte que des images." }); } // Pas d'écrasement silencieux : confirmation explicite requise. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: path }, select: { id: true, status: true, uploaderUserId: true }, }); if (existing && !input.allowOverwrite) { throw new TRPCError({ code: "CONFLICT", message: `Un fichier « ${fileName} » existe déjà à cet endroit.` }); } // Confirmer ne suffit pas : remplacer, c'est publier sans validation (mayReplace). if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: `« ${fileName} » existe déjà et ne peut être remplacé que par un administrateur. Renommez votre fichier.`, }); } if (input.destination.kind === "perso" && !existing) { const { total } = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id }); if (total + 1 > PERSO_PHOTO_QUOTA) { throw new TRPCError({ code: "BAD_REQUEST", message: `Quota d'images perso atteint (max ${PERSO_PHOTO_QUOTA}).` }); } } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); const auth = await adapter.createUploadAuthorization({ path, mimeType: input.mimeType, maxBytes: input.maxBytes, }); // Le client a besoin du chemin résolu : il le renverra tel quel à // `registerR2Upload`. Le chemin ne retraverse jamais une règle de calcul. return { ...auth, path }; }), /** * Phase 2 — l'input transporte maintenant `destination` + `originalFileName` * pour permettre à l'adapter R2 de créer la row MediaAsset après HeadObject. */ registerR2Upload: protectedProcedure .input(registerR2UploadInputSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin enregistré doit être celui que createR2Upload a autorisé pour cette destination. const expectedFolder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const rest = input.path.slice(expectedFolder.length + 1); if (!input.path.startsWith(`${expectedFolder}/`) || rest === "" || rest.includes("/") || rest.includes("..")) { throw new TRPCError({ code: "FORBIDDEN", message: "Chemin d'envoi hors de la destination autorisée." }); } // L'enregistrement met à jour une ligne existante (auteur compris) : même règle qu'à la // signature, sans quoi un membre pourrait « adopter » le fichier en attente d'un autre, puis le // remplacer en tant qu'auteur. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { status: true, uploaderUserId: true }, }); if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce fichier existe déjà et ne peut être remplacé que par un administrateur.", }); } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); return adapter.registerUploadedAsset({ path: input.path, userId: ctx.sessionClient.user.id, expectedBytes: input.expectedBytes, expectedMimeType: input.expectedMimeType, destination: input.destination, originalFileName: input.originalFileName, }); }),});Tout ou rien, par compensation#
Un geste multiple n’a pas de transaction commune : chaque opération touche le stockage et la base. La première version exécutait les opérations l’une après l’autre et s’arrêtait à la première erreur — les précédentes restaient faites, et l’administrateur se retrouvait avec une sélection à moitié déplacée. On ne peut pas rendre l’ensemble atomique ; on peut le compenser :
export async function executeMoveOperations( adapter: StorageAdapter, operations: readonly StorageMoveOperation[]): Promise<void> { const move = adapter.move?.bind(adapter); if (!move) { throw new Error("Adapter does not support move()."); } const done: StorageMoveOperation[] = []; for (const operation of operations) { try { await move(operation); done.push(operation); } catch (err) { const notUndone: string[] = []; for (const prev of [...done].reverse()) { await move({ source: { type: prev.source.type, path: prev.target.path }, target: { path: prev.source.path }, }).catch((undoErr) => { notUndone.push(prev.source.path); console.error("[move] retour impossible", prev, undoErr); }); } console.error("[move] échec, geste annulé", operation, err); // SERVICE_UNAVAILABLE et non INTERNAL_SERVER_ERROR : c'est le stockage (ou la base) qui a // failli, pas la requête — et le message, qui dit ce qui reste à faire, doit arriver jusqu'à // l'administrateur (une erreur interne voit son message remplacé en production). throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: notUndone.length === 0 ? "Le déplacement a échoué ; rien n'a été déplacé." : `Le déplacement a échoué, et ${notUndone.length} élément(s) n'ont pas pu être remis à leur place.`, cause: err, }); } }}Si l’opération k échoue, l’adapter a déjà ramené ses propres objets ; on défait alors les opérations 1 à k − 1 en sens inverse, de la plus récente à la plus ancienne — l’ordre compte, car une opération peut dépendre de la place libérée par la précédente. L’administrateur retrouve la bibliothèque telle qu’avant son geste, et un message qui le dit.
Le code d’erreur est SERVICE_UNAVAILABLE, pas INTERNAL_SERVER_ERROR : c’est le stockage ou la base qui a failli, pas la requête (fiche 5.3) — et le message, qui dit si quelque chose est resté en route, doit arriver jusqu’à l’écran, alors qu’une erreur interne voit le sien remplacé en production.
Ce que la compensation ne couvre pas : un arrêt du serveur au milieu d’un geste. Plus personne n’est là pour défaire. C’est la limite de toute compensation applicative ; la lever demanderait un journal des opérations en cours, rejoué au démarrage.
Côté navigateur, un glisser-déposer n’a pas de formulaire où afficher une erreur : longtemps, un refus finissait dans la console, et l’administrateur voyait son fichier « revenir » sans explication. Le refus s’affiche désormais dans le bandeau du Finder (actionError du store), avec le message du serveur — d’où l’importance qu’il soit une TRPCError en français, et pas une Error dont le texte serait masqué en production.
La corbeille, une quarantaine#
La corbeille n’est pas un statut : c’est un rangement. trash.trashToBin déplace l’élément dans une zone cachée, AKFC/bin/.trash/<identifiant>/, et crée une ligne qui garde son nom et son chemin d’origine :
trashToBin — réserver, puis déplacer
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { TRPCError } from "@trpc/server";import type { PrismaClient } from "@prisma/client";import { randomUUID } from "crypto";import type { TrashToBinInput, TrashToBinOutput } from "@contracts/trash/trash.mutations";import type { TrashEntryDTO } from "@contracts/trash/trash.dto";import { buildPreviousPathShort, normalizePath, bigIntToSafeNumber } from "@backend/modules/trash/utils";import { deriveMediaKind, type MediaKind } from "@backend/modules/media/helpers/deriveMediaKind";import { isProtectedDisciplineFolderPath } from "@backend/modules/storage/protectedDisciplineFolder";import { isProtectedEntityFolderPath } from "@backend/modules/storage/protectedEntityFolder";import { requiredRootPaths } from "@backend/modules/storage/ensureContentRoots.service";import { rewriteAssetPaths } from "@backend/modules/storage/mediaPaths";import { assertTrashDoesntRemoveUsedAssets } from "@backend/modules/trash/services/trashedAssets.service";import { r2Exists, r2GetInfo, r2ListByPrefix, r2MoveFile, r2MoveFolder,} from "@backend/modules/trash/services/r2TrashOps";/** * trashToBin.service.ts * * Ticket 3 : envoyer du contenu vers la corbeille. * * Design validé : * - Bin est une liste plate (pas de structure parent conservée). * - Le stockage R2 réel est caché : `${appRoot}/bin/.trash/<uuid>/...`. * - On crée une TrashEntry avec : * - previousPath (tooltip + restore cible) * - displayName (nom affiché dans le bin) * - storageRoot (prefix réel caché) * - sizeBytes (somme bytes du sous-arbre / fichier) * - providerCreatedAt (max created_at) * * IMPORTANT : * - Aucune action ne doit toucher à `.trash` en dehors du trashRouter. * - Ce service NE fait que "normal -> bin". */type ListedAsset = { bytes?: number; created_at?: string;};function lastSegment(path: string): string { const p = normalizePath(path); const parts = p.split("/").filter(Boolean); return parts[parts.length - 1] ?? p;}/** Ni la racine, ni la corbeille elle-même, ni ce qu'elle contient déjà. */function assertNotStatusOrRoot(appRoot: string, fullPath: string) { const p = normalizePath(fullPath); if (p === appRoot) { throw new TRPCError({ code: "BAD_REQUEST", message: "La racine ne peut pas être mise à la corbeille." }); } if (p === `${appRoot}/bin` || p.startsWith(`${appRoot}/bin/`)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Cet élément est déjà dans la corbeille." }); }}/** Objets R2 sous un préfixe (taille, date) — pour les agrégats d'un dossier. */async function listAssetsByPrefix(prefix: string): Promise<ListedAsset[]> { const objects = await r2ListByPrefix(prefix); return objects.map((o) => ({ bytes: o.bytes, created_at: o.createdAt?.toISOString() }));}/** * Type MIME d'un fichier connu en base. Seul moyen de reconnaître l'audio : * R2 n'expose que taille et date. */async function knownMimeType( prisma: PrismaClient, appRoot: string, path: string,): Promise<string | null> { const asset = await prisma.mediaAsset.findFirst({ where: { appRoot, fullPath: path }, select: { mimeType: true }, }); return asset?.mimeType ?? null;}/** Taille et date d'un fichier R2 ; throw s'il n'existe pas. */async function fileInfo(path: string): Promise<{ bytes?: number; createdAt?: Date }> { const info = await r2GetInfo(path); if (!info) throw new TRPCError({ code: "NOT_FOUND", message: `Fichier introuvable : « ${lastSegment(path)} ».` }); return info;}async function detectKind(params: { prisma: PrismaClient; appRoot: string; fullPath: string }): Promise<"folder" | "file"> { const { prisma, appRoot, fullPath } = params; const p = normalizePath(fullPath); // 1) Si on a un registre folder DB, c'est un folder. const folder = await prisma.folder.findFirst({ where: { appRoot, fullPath: p }, select: { id: true }, }); if (folder) return "folder"; // 2) Si R2 connaît l'objet exact => file. if (await r2Exists(p)) return "file"; // 3) Si on a au moins un asset sous prefix => folder. const assets = await listAssetsByPrefix(`${p}/`); if (assets.length > 0) return "folder"; throw new TRPCError({ code: "NOT_FOUND", message: `Élément introuvable : « ${lastSegment(p)} ».` });}function computeAggregateFromAssets(assets: Array<{ bytes?: number; created_at?: string }>): { sizeBytes?: bigint; providerCreatedAt?: Date;} { let total = 0; let hasBytes = false; let maxCreated: Date | null = null; for (const a of assets) { if (typeof a.bytes === "number") { total += a.bytes; hasBytes = true; } if (a.created_at) { const d = new Date(a.created_at); if (!Number.isNaN(d.valueOf())) { if (!maxCreated || d > maxCreated) maxCreated = d; } } } return { sizeBytes: hasBytes ? BigInt(total) : undefined, providerCreatedAt: maxCreated ?? undefined, };}type NormalizedSource = { kind: "folder" | "file"; fullPath: string };async function normalizeSources(params: { prisma: PrismaClient; input: TrashToBinInput }): Promise<NormalizedSource[]> { const { prisma, input } = params; const out: NormalizedSource[] = []; const seen = new Set<string>(); for (const s of input.sources) { if (s.kind === "selection") { for (const root of s.roots) { const p = normalizePath(root); if (seen.has(p)) continue; seen.add(p); // On détecte le kind pour être robuste. const kind = await detectKind({ prisma, appRoot: input.appRoot, fullPath: p }); out.push({ kind, fullPath: p }); } continue; } const p = normalizePath(s.fullPath); if (seen.has(p)) continue; seen.add(p); out.push({ kind: s.kind, fullPath: p }); } return out;}export async function trashToBin(params: { prisma: PrismaClient; input: TrashToBinInput;}): Promise<TrashToBinOutput> { const { prisma, input } = params; const appRoot = input.appRoot; // Normalise la liste et déduplique. const sources = await normalizeSources({ prisma, input }); // 🔒 Protection : les dossiers adossés à une ENTITÉ (espaces de groupe/perso, // avatars, et les conteneurs groups/persos/avatars) ne se suppriment PAS // depuis le finder — uniquement via le gestionnaire de l'entité. On refuse // AVANT tout déplacement (rien n'est déplacé si un seul est protégé). for (const source of sources) { if ( source.kind === "folder" && isProtectedEntityFolderPath(normalizePath(source.fullPath)) ) { throw new TRPCError({ code: "FORBIDDEN", message: `Dossier protégé : « ${lastSegment(normalizePath(source.fullPath))} » est géré par une entité (groupe, espace perso, avatars) et ne peut pas être supprimé depuis le finder. Supprimez l'entité correspondante dans son gestionnaire.`, }); } } // 🔒 Protection disciplines/catégories (DB-aware) : conteneur de catégorie, // sous-conteneur `new`, ou dossier de discipline → non supprimables depuis // le finder (passer par le gestionnaire de disciplines). Les racines de contenu // (stages, événements, espaces, dépôt commun) non plus : elles reçoivent les envois. const contentRoots = await requiredRootPaths(prisma, appRoot); for (const source of sources) { if (source.kind !== "folder") continue; const path = normalizePath(source.fullPath); if (contentRoots.includes(path)) { throw new TRPCError({ code: "FORBIDDEN", message: `« ${lastSegment(path)} » est une racine de la bibliothèque : elle ne se met pas à la corbeille.`, }); } if (await isProtectedDisciplineFolderPath(prisma, appRoot, path)) { throw new TRPCError({ code: "FORBIDDEN", message: `Dossier protégé : « ${lastSegment(path)} » correspond à une discipline ou une catégorie et ne peut pas être supprimé depuis le finder. Supprimez-la dans le gestionnaire de disciplines.`, }); } } // 🔒 Fichier encore utilisé (page, galerie, documents des membres) : on refuse avant tout // déplacement, sinon le contenu afficherait un média cassé tant qu'il est dans la corbeille. await assertTrashDoesntRemoveUsedAssets(prisma, appRoot, sources); const results: TrashEntryDTO[] = []; for (const source of sources) { // Ni la racine, ni la corbeille, ni ce qu'elle contient déjà. assertNotStatusOrRoot(appRoot, source.fullPath); const normalized = normalizePath(source.fullPath); const displayName = lastSegment(normalized); const id = randomUUID(); // NOTE : même pour un file, on stocke sous `.trash/<id>/<displayName>` // (bin navigable caché, collisions impossibles) const storageRoot = normalizePath(`${appRoot}/bin/.trash/${id}/${displayName}`); // 1) Calcul des agrégats (sizeBytes + createdAt) et du mediaKind let sizeBytes: bigint | undefined; let providerCreatedAt: Date | undefined; let mediaKind: MediaKind | undefined; if (source.kind === "file") { const info = await fileInfo(normalized); sizeBytes = typeof info.bytes === "number" ? BigInt(info.bytes) : undefined; providerCreatedAt = info.createdAt; mediaKind = deriveMediaKind(await knownMimeType(prisma, appRoot, normalized)); } else { // ✅ trailing slash pour éviter les collisions de prefix (ex: cours1 vs cours10) const assets = await listAssetsByPrefix(`${normalized}/`); const agg = computeAggregateFromAssets(assets); sizeBytes = agg.sizeBytes; providerCreatedAt = agg.providerCreatedAt; // mediaKind reste undefined pour un folder (pas de type unique applicable) } // 2) Création TrashEntry (avant move) : on "réserve" l'id et on garde la provenance. await prisma.trashEntry.create({ data: { id, appRoot, kind: source.kind, status: "IN_BIN", displayName, previousPath: normalized, storageRoot, trashedAt: new Date(), sizeBytes, providerCreatedAt, mediaKind, }, }); // 3) Déplacement physique vers storageRoot, puis la base suit : les lignes MediaAsset // accompagnent leurs objets dans la quarantaine (statut, nom d'origine, description et // auteur reviendront avec eux à la restauration, même à côté du chemin d'origine), et les // lignes Folder du sous-arbre disparaissent de l'ancien endroit. // // Pas de transaction commune entre le stockage et la base : on COMPENSE. Si l'une des deux // étapes échoue, les objets reviennent à leur place et la TrashEntry réservée est retirée. const moveObjects = (from: string, to: string): Promise<void> => source.kind === "file" ? r2MoveFile(from, to) : r2MoveFolder(`${from}/`, `${to}/`); const undo = async (): Promise<void> => { await moveObjects(storageRoot, normalized).catch((err) => { console.error("[trash] objets non ramenés", normalized, err); }); await prisma.trashEntry.delete({ where: { id } }).catch(() => undefined); }; try { await moveObjects(normalized, storageRoot); } catch (err) { await undo(); throw err; } try { await prisma.$transaction([ rewriteAssetPaths(prisma, appRoot, source.kind, normalized, storageRoot), // Le sous-arbre, c'est le dossier LUI-MÊME et ce qui est SOUS LUI — pas tout ce qui // commence par les mêmes lettres (`AKFC/courses/kali` n'emporte pas `kali-2`). prisma.folder.deleteMany({ where: { appRoot, OR: source.kind === "folder" ? [{ fullPath: normalized }, { fullPath: { startsWith: `${normalized}/` } }] : [{ fullPath: normalized }], }, }), ]); } catch (err) { await undo(); throw err; } results.push({ id, appRoot, kind: source.kind, status: "IN_BIN", displayName, previousPath: normalized, previousPathShort: buildPreviousPathShort(normalized), trashedAt: new Date().toISOString(), sizeBytes: bigIntToSafeNumber(sizeBytes), createdAt: providerCreatedAt ? providerCreatedAt.toISOString() : undefined, mediaKind, }); } return { trashed: results, message: `${results.length} item(s) moved to bin`, };}Avant la boucle, trois refus, tous avant le moindre déplacement — rien n’est jeté si un seul élément est refusé :
- les dossiers-entités et les dossiers de disciplines ou de catégories, qui se suppriment par leur gestionnaire (supprimer un groupe supprime son espace, après vérification qu’il est vide) ;
- les racines de contenu (stages, événements, espaces, dépôt commun) ;
- les fichiers encore utilisés :
export async function assertTrashDoesntRemoveUsedAssets( prisma: PrismaClient, appRoot: string, sources: readonly TrashSource[],): Promise<void> { if (sources.length === 0) return; const used = await prisma.mediaAsset.findMany({ where: { AND: [ assetsWhere(appRoot, sources), { OR: [ { pageReferences: { some: {} } }, { galleryItems: { some: {} } }, { memberDocument: { isNot: null } }, ], }, ], }, select: { fullPath: true, displayName: true, originalFileName: true, pageReferences: { select: { pageType: true, pageId: true } }, galleryItems: { select: { gallery: { select: { title: true } } } }, memberDocument: { select: { id: true } }, }, take: 20, }); if (used.length === 0) return; const lines = used.map((asset) => { const uses = [ ...asset.pageReferences.map((r) => `${String(r.pageType)} #${r.pageId}`), ...asset.galleryItems.map((g) => `galerie « ${g.gallery.title} »`), ...(asset.memberDocument ? ["documents des membres"] : []), ]; const name = asset.displayName?.trim() || asset.originalFileName || asset.fullPath; return ` - ${name} → utilisé par : ${uses.join(", ")}`; }); // CONFLICT : l'opération heurte l'état actuel (des contenus affichent ces fichiers). Une Error // nue deviendrait une erreur interne, au message masqué en production. throw new TRPCError({ code: "CONFLICT", message: `Mise à la corbeille impossible : ${used.length} fichier(s) encore utilisé(s).\n` + `Retire-les d'abord des contenus qui les affichent, puis réessaie.\n` + lines.join("\n"), });}Pourquoi refuser, plutôt que laisser faire ? Parce qu’une page référence un fichier par l’identifiant de sa ligne, et que cette ligne part avec l’objet dans la quarantaine : la page afficherait un média cassé tant que dure la quarantaine. Le refus nomme chaque fichier et ce qui l’utilise : l’administrateur sait quoi retirer, et où.
Une ligne en quarantaine garde son statut. Elle ne doit pourtant plus compter nulle part : les compteurs « à traiter » et la recherche excluent la corbeille (outsideBin), et un fichier jeté ne peut plus être posé dans une page ni résolu pour l’affichage.
L’immuabilité de la quarantaine#
export function isTrashStoragePath(appRoot: string, path: string): boolean { const p = normalizePath(path); return p.startsWith(`${appRoot}/bin/.trash/`);}La quarantaine n’est modifiable que par le routeur trash : le routeur storage refuse d’y renommer ou d’y déplacer quoi que ce soit, dans un sens comme dans l’autre (assertOutsideBin), et l’arbre ne montre jamais son contenu. Sans ce refus, un glisser-déposer depuis la vue de la corbeille sortirait un fichier de quarantaine sans passer par la restauration : sa resterait « dans la corbeille », pointant vers un emplacement vide. La restauration et la suppression définitive revérifient, avant d’agir, que la d’une entrée est bien sous bin/.trash/ : une ligne falsifiée ou corrompue ne doit pas pouvoir faire supprimer un dossier de la bibliothèque. Deux routeurs, deux zones : on ne mélange pas le rangement ordinaire et la quarantaine.
Restaurer#
Restaurer replace l’élément à son chemin d’origine, et ses lignes reviennent avec lui. Mais le chemin d’origine a pu être réoccupé entre-temps :
async function computeRestoredToPath(params: { prisma: PrismaClient; appRoot: string; kind: "file" | "folder"; previousPath: string; trashedAt: Date;}): Promise<{ restoredToPath: string; wasCollision: boolean }> { const { prisma, appRoot, kind, previousPath, trashedAt } = params; const base = normalizePath(previousPath); const collides = await pathCollides({ prisma, appRoot, kind, targetPath: base }); if (!collides) return { restoredToPath: base, wasCollision: false }; const suffix = formatBinSuffix(trashedAt); let candidate = kind === "folder" ? suffixFolderPath(base, suffix) : suffixFilePath(base, suffix); // Collision persistante (rare) => compteur let i = 2; while (await pathCollides({ prisma, appRoot, kind, targetPath: candidate })) { candidate = kind === "folder" ? addCounterToFolderPath(candidate, i) : addCounterToFilePath(candidate, i); i++; } return { restoredToPath: candidate, wasCollision: true };}En cas de collision, l’élément est restauré à côté, avec un suffixe daté de sa mise à la corbeille (« photo (bin 2026-10-30 14-05).jpg »), puis un compteur si nécessaire. On n’écrase jamais ce qui a pris sa place.
// Move physique, puis les lignes MediaAsset reviennent avec leurs objets — à leur chemin // d'origine, ou À CÔTÉ en cas de collision : le fichier restauré garde son statut, son nom // d'origine et sa description où qu'il atterrisse. (Un élément jeté avant que les lignes // suivent leurs objets n'en a aucune sous `storageRoot` : la réécriture ne touche alors rien, // et ses lignes, restées au chemin d'origine, le retrouvent s'il y revient.) // // Pas de transaction commune entre le stockage et la base : si la base échoue, les objets // retournent en quarantaine et l'entrée reste « dans la corbeille ». const storageRoot = normalizePath(entry.storageRoot); const target = normalizePath(restoredToPath); const moveObjects = (from: string, to: string): Promise<void> => kind === "file" ? r2MoveFile(from, to) : r2MoveFolder(`${from}/`, `${to}/`); await moveObjects(storageRoot, target); try { await rewriteAssetPaths(prisma, appRoot, kind, storageRoot, target); } catch (err) { await moveObjects(target, storageRoot).catch((undoErr) => { console.error("[trash] restauration : objets non ramenés en quarantaine", entry.id, undoErr); }); throw err; }C’est ici que le choix « les lignes suivent leurs objets » paie. La première version laissait les lignes au chemin d’origine pendant la quarantaine : restauré à sa place, un fichier les retrouvait ; restauré à côté, il n’en avait plus aucune — un nouvel envoi au même chemin avait repris la sienne. Il apparaissait sans statut ni nom d’origine, et ne pouvait plus être choisi dans une page. Désormais, les lignes accompagnent les objets dans la quarantaine et en reviennent avec eux, où qu’ils atterrissent.
Les éléments jetés avant ce changement ont laissé leurs lignes au chemin d’origine. Le code les accepte sans migration : leur réécriture ne trouve rien sous , et leurs lignes, restées en place, les retrouvent s’ils y reviennent.
Supprimer pour de bon#
La suppression définitive (une entrée, ou toute la corbeille) efface les objets, leurs déclinaisons (vignettes, affiches), et passe la à « supprimée ». Reste la base : les lignes de l’élément n’ont plus de fichier.
export async function deleteAssetRowsOfTrashedItem( prisma: PrismaClient, appRoot: string, item: { kind: "file" | "folder"; previousPath: string; storageRoot: string },): Promise<number> { const notReferenced: Prisma.MediaAssetWhereInput = { pageReferences: { none: {} } }; const inBin = await prisma.mediaAsset.deleteMany({ where: { AND: [assetsWhere(appRoot, [{ kind: item.kind, fullPath: item.storageRoot }]), notReferenced] }, }); const previous = normalizePath(item.previousPath); const rows = await prisma.mediaAsset.findMany({ where: { AND: [assetsWhere(appRoot, [{ kind: item.kind, fullPath: previous }]), notReferenced] }, select: { id: true, fullPath: true }, }); if (rows.length === 0) return inBin.count; const prefix = item.kind === "file" ? previous : `${previous}/`; const live = new Set((await r2ListByPrefix(prefix)).map((o) => o.key)); const orphanIds = rows.filter((r) => !live.has(r.fullPath)).map((r) => r.id); if (orphanIds.length === 0) return inBin.count; const { count } = await prisma.mediaAsset.deleteMany({ where: { id: { in: orphanIds } } }); return inBin.count + count;}Les lignes qui ont suivi leurs objets dans la quarantaine sont effacées directement. Pour les éléments jetés avant ce changement, une ligne restée au chemin d’origine n’est effacée que si aucun objet n’existe plus à son chemin : un fichier redéposé au même endroit entre-temps a repris la ligne (un upsert sur fullPath), et elle lui appartient désormais. La décision se prend en interrogeant le stockage, pas en supposant.
Le stockage et la base doivent rester d’accord. Chaque geste du Finder écrit des deux côtés, dans un ordre choisi, après avoir vérifié que rien ne sera écrasé. Une opération qui ne toucherait que l’un des deux laisserait des fichiers sans statut, ou des lignes qui pointent vers rien — et rien, ensuite, ne permettrait de les distinguer des vrais.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Que fait « Renommer » sur un fichier ? | Un UPDATE de displayName ; la clé ne change pas, le stockage n’est pas touché. |
Et sur un dossier ? | Un déplacement (copie puis suppression), refusé pour les dossiers que le code connaît, pour « .. », et si la cible est occupée. |
Qu’est-ce qui suit un dossier déplacé ? | MediaAsset, Folder et FolderLabel, en une transaction, préfixe échappé dans le LIKE (rewriteAssetPaths). |
Comment évite-t-on d’écraser ? | Cible vérifiée partout (stockage, MediaAsset, Folder) et doublons de la sélection refusés, avant toute copie. |
Et si une étape échoue ? | Compensation : l’adapter ramène ses objets, le geste multiple défait ses opérations en sens inverse. Seul un arrêt du serveur en plein geste n’est pas couvert. |
Où va un fichier jeté ? | Dans bin/.trash/<uuid>/, avec une TrashEntry qui garde son chemin d’origine ; refusé s’il est encore utilisé. |
Que devient sa ligne MediaAsset ? | Elle suit l’objet dans la quarantaine (hors compteurs, hors recherche), revient avec lui à la restauration, même à côté, et disparaît avec lui à la suppression. |
Showing entries1to7entries
À suivre#
Les médias sont envoyés, rangés, validés. Un média validé n’est pourtant visible de personne tant qu’il n’est posé nulle part : la route publique exige qu’il soit placé dans une page ou une galerie publique.
Ce placement est le travail du constructeur de pages. Une page y est faite de blocs (texte riche, image et texte, galerie…), édités dans le tableau de bord et rendus côté serveur sur le site. Chaque enregistrement doit tenir à jour la liste des médias qu’une page utilise, pour que la route publique sache quoi servir, et pour qu’on ne puisse pas retirer un média encore affiché.
Partie suivante : le contenu éditorial. Le constructeur de pages : un format de blocs commun aux pages, aux actualités et aux fiches, son éditeur, son rendu serveur, et la synchronisation des références de médias qui relie une page à ce qu’elle affiche.