Un explorateur sur un sac de clés

Pourquoi un stockage plat ne suffit pas à nourrir un explorateur de fichiers, pourquoi relayer sa liste au navigateur est une fausse bonne idée, ce que la base sait que le stockage ignore, et comment un composant unique se branche sur des adaptateurs.

Précédemment. Les fichiers du club vivent dans un stockage objet, sous des clés comme AKFC/courses/tchoy-lee-fut/salut-final.jpg. Ils y arrivent par une URL signée, sont enregistrés dans , et servis par des routes qui vérifient les droits.

Donner à voir ce qui n’a pas de forme#

Jusqu’ici, nous avons fait entrer des choses dans le système : des données, des relations, des fichiers. La chorégraphie d’envoi est aboutie — le navigateur obtient une signature, le stockage reçoit, le serveur relit, la base enregistre.

Le problème, c’est que rien de tout cela ne se voit. Un administrateur qui ouvre la bibliothèque veut regarder ce qui attend sa validation. Il veut ouvrir un dossier, voir ce qu’il contient, glisser une photo d’un dossier à un autre, renommer, jeter. Il veut un Finder — au sens du Finder de macOS, de l’explorateur de Windows : un arbre qu’on parcourt à la souris.

Et c’est là que nous rencontrons, de plein fouet, une vérité posée dès le premier chapitre de cette partie : un stockage objet n’a pas de dossiers. Ce que nous avons déposé, ce sont des clés ; ce que l’administrateur attend, c’est une arborescence. Entre les deux, quelqu’un doit traduire.

Chapitre

La question

5. Un explorateur sur un sac de clés
Pourquoi le stockage ne suffit pas, et comment le Finder se découpe
6. Lire l’arbre
Comment le serveur fabrique, à chaque ouverture, l’arbre qu’affiche le navigateur
7. Modifier sans casser
Renommer, déplacer, jeter, restaurer : garder le stockage et la base d’accord

Ce que le stockage sait répondre#

Un stockage S3 ne sait pas dire « voici l’arbre ». Il sait répondre à une question plus étroite : « donne-moi les clés qui commencent par ce préfixe ». Avec une option qui change tout, Delimiter: "/", il regroupe ce qui suit le prochain / :

buildSubTree — d’une liste de clés à un niveau d’arbre

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Un préfixe, un délimiteur
TS
packages/backend/src/modules/storage/adapters/r2/r2StorageAdapter.ts
guided extract: lines 531-584focus line 14
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;}

C’est mieux que Cloudinary, qui ne rendait qu’un tableau plat de public_id et laissait au code le soin de reconstruire les niveaux. Mais c’est encore loin d’un explorateur.

Le réflexe naïf : relayer la liste au navigateur#

Puisque le stockage sait lister, pourquoi ne pas lui ouvrir une procédure et laisser le navigateur se débrouiller ?

Relayer le stockage tel quel

ts
Anti-pattern
listRaw: adminProcedure  .input(z.object({ prefix: z.string() }))  .query(async ({ input }) => {    const res = await s3.send(new ListObjectsV2Command({ Bucket, Prefix: input.prefix, Delimiter: "/" }));    return { folders: res.CommonPrefixes, files: res.Contents }; // des clés, et rien d’autre  }),

À première vue, cela marche : le navigateur reçoit des dossiers et des fichiers, il les affiche. Mais quatre choses manquent, et aucune ne se rattrape côté navigateur :

  • le nom lisible. La clé est un slug (salut-final-2025.jpg) ; le nom que le membre a choisi (« Salut final (2025).JPG ») est dans , ou dans un displayName posé plus tard ;
  • le statut et l’expéditeur. En attente ou validé, déposé par qui : la base les sait, le stockage non ;
  • les dossiers vides. Un espace de groupe tout juste créé n’a encore aucun fichier, donc aucune clé, donc aucun CommonPrefix : il serait invisible, alors que le groupe existe et que ses membres doivent pouvoir y déposer ;
  • les droits. Le stockage ne connaît ni les membres, ni les groupes : un relais brut montrerait tout à tout le monde, ou rien à personne.

Il y a enfin une raison d’architecture. Transformer une liste de clés en arborescence est un travail de synthèse qui doit être fait une fois, au bon endroit. Si le navigateur s’en charge, la future application mobile le refera, un peu différemment, et les deux arbres divergeront.

Un Finder n’est pas une vue du stockage : c’est une traduction. Le stockage parle « clés », l’administrateur pense « arborescence », et c’est notre serveur, avec ce qu’il sait de la base, qui invente entre les deux une topologie qui n’existe nulle part.

La base n’est pas un cache, elle est une source#

Prenons le cas qui résiste le plus : le dossier vide. Un groupe collaboratif vient d’être créé ; son espace, AKFC/collaborative-group-spaces/kung-fu-enfants-c…, n’a encore reçu aucun fichier.

Dans un vrai système de fichiers, la réponse serait évidente : on crée un répertoire, il existe. Dans un stockage objet, « créer un dossier vide » ne veut rien dire : il n’y a rien à créer. Les contournements viennent vite à l’esprit, et chacun échoue pour une raison différente :

  • déposer un fichier fantôme (.keep) pour que le préfixe existe : il pollue les listings et les quotas, doit suivre chaque renommage, et installe un invariant fragile — « ce dossier existe parce qu’un certain fichier existe » ;
  • garder les dossiers vides en mémoire du serveur : ils disparaissent au redémarrage du conteneur, et ne sont pas partagés si le site tourne sur plusieurs instances ;
  • laisser chaque navigateur s’en souvenir : aucun utilisateur ne verrait ce qu’un autre a préparé.

Il ne reste qu’une voie : ce que le stockage ne peut pas représenter, la base le sait. Pas comme un cache du stockage — le stockage n’a littéralement rien à en dire — mais comme une source autonome. Et dans AKFC, la base le sait déjà, par ses entités : un groupe collaboratif existe dans MemberGroup, un membre dans User, une catégorie dans Category. L’arbre peut donc garantir que les dossiers qui doivent exister apparaissent, même vides : les racines de contenu, l’espace de chaque groupe accessible, l’espace personnel de l’utilisateur.

Deux tables complètent ce que les entités ne disent pas :

Folder : les dossiers que la base enregistre
PRISMA
prisma/schema.prisma
model Folder {  /// Identifiant CUID.  id        String       @id @default(cuid())  /// Racine applicative (voir MediaAsset.appRoot).  appRoot   String  /// Chemin complet du dossier, unique par racine.  fullPath  String  /// Statut déduit du chemin (statusFromPath), à l'inscription et au  /// renommage du dossier : vestige de l'époque où le statut était un  /// segment du chemin.  status    FolderStatus  /// Inscription du dossier.  createdAt DateTime     @default(now())  /// Dernière modification (tenue à jour par Prisma).  updatedAt DateTime     @updatedAt  @@unique([appRoot, fullPath])  @@index([appRoot])  @@index([appRoot, status])}
FolderLabel : un nom d’affichage, séparé du chemin
PRISMA
prisma/schema.prisma
model FolderLabel {  /// Chemin complet du dossier, identité du libellé (ex. « AKFC/courses »,  /// « AKFC/collaborative-group-spaces/{slug}-{id} »).  path        String   @id  /// Nom affiché.  displayName String  /// Dernière modification (tenue à jour par Prisma).  updatedAt   DateTime @updatedAt}
  • enregistre les dossiers dont le serveur a besoin de connaître l’existence indépendamment de leur contenu : la corbeille, l’espace d’un groupe au moment de sa création, les dossiers restaurés depuis la corbeille. Sa colonne est un vestige de l’époque où le statut était un segment du chemin ; elle n’est plus lue pour décider de rien.
  • associe un chemin à un nom d’affichage : courses s’affiche « Cours », personal-space « Espace personnel », le dossier d’une discipline porte son nom avec ses accents. Le chemin, lui, ne change jamais — la règle déjà rencontrée pour les disciplines : un nom d’affichage n’est pas une clé.

Le stockage connaît les fichiers ; la base connaît les entités (groupes, membres, catégories) et ce qu’on a voulu nommer. Aucune des deux n’est subordonnée à l’autre : l’arbre du Finder est leur fusion, refaite à chaque lecture.

Trois couches#

Le Finder répartit le travail en trois couches, chacune ignorant ce que fait l’autre.

Couche

Où

Rôle

Le composant
apps/web/src/features/finder-core
Affichage et interactions : grille, liste, arbre, fil d’Ariane, sélection, glisser-déposer, aperçu, recherche. Ne sait rien du stockage.
L’adaptateur
apps/web/src/features/finder-adapters
Traduit les demandes du composant (lister, déplacer…) en appels tRPC, et les réponses en nœuds affichables.
Le backend
packages/backend/src/modules/storage
Lit le stockage, le complète avec la base, applique les droits, exécute les opérations.

Le contrat entre le composant et l’adaptateur est une interface de packages/contracts :

FileAdapter
TS
packages/contracts/src/finder/adapter.ts
export interface FileAdapter {  /**   * Liste le contenu d'un dossier donné par son chemin logique.   * @param options   */  list(options: ListOptions): Promise<ListResult>;  /**   * (optionnel) Récupère un sous-arbre dépliable.   *   * Pour la TreeView qui doit pouvoir tenir plusieurs branches dépliées   * simultanément avec leurs enfants. Un adapter qui n'expose qu'une   * vue plate peut omettre cette méthode — la TreeView retombera alors   * sur des appels `list` cascadés.   */  getTree?(options: GetTreeOptions): Promise<GetTreeResult>;  /**   * (optionnel) Récupérer un node précis   */  getNode?(path: string): Promise<FinderNode | null>;  /**   * (optionnel) Récupère les métadonnées riches d'un asset.   *   * Pour la sidebar de preview détaillée (taille, dates, format complet).   * `null` si le path n'existe pas ou ne porte pas de métadonnées.   */  getMetadata?(path: string): Promise<FinderNodeMetadata | null>;  /**   * Déplace un ou plusieurs items vers une cible.   *   * Verbe canonique pour tous les déplacements — y compris ceux d'un seul   * élément (auquel cas `items` contient un seul MoveItem). Le DnD du   * Finder, qu'il porte une sélection unique ou multiple, passe toujours   * par cette méthode.   *   * La cible est un dossier concret (`target: { type: 'folder', path }`). Changer le statut d'un   * fichier n'est pas un déplacement : c'est `media.setStatus`.   *   * Optionnel : un adapter en lecture seule peut ne pas l'implémenter.   */  moveItems?(options: MoveOptions): Promise<void>;  /**   * (optionnel) delete   * @param path   */  delete?(path: string): Promise<void>;}

Seule list est obligatoire. Un adaptateur en lecture seule n’implémente ni moveItems ni delete, et le composant masque les actions correspondantes. Ce découpage permet d’utiliser le même composant à trois endroits :

  • la bibliothèque du tableau de bord (<Finder adapter={finderStorageAdapter} rootPath={APP_ROOT} />), qui parle aux procédures storage.* ;
  • la page Mes espaces des membres, avec memberFinder.adapter.ts, dont la racine est virtuelle — elle n’existe pas dans le stockage — et ne liste que les espaces de groupe accessibles au membre ;
  • le sélecteur de médias (MediaPicker), qui monte le Finder en mode sélection pour choisir les images d’une page ou d’une galerie.
Une racine qui n’est pas un chemin
TS
apps/web/src/features/finder-adapters/member/memberFinder.adapter.ts
empty code block

Le panier du sélecteur vit dans packages/finder-core (usePickerCartStore), un paquet sans aucun code d’affichage : il pourra être repris tel quel par la future application mobile.

Le composant ne connaît qu’un contrat ; chaque usage fournit son adaptateur. Ajouter un nouvel explorateur (un espace documentaire, une version mobile) revient à écrire un adaptateur, pas un nouveau Finder.

Le nœud du Finder#

Ce qui circule entre les trois couches est un FinderNode :

FinderNode
TS
packages/contracts/src/finder/node.ts
export interface FinderNode {  id: string;             // unique (peut être path ou uuid selon adapter)  name: string;  path: string;           // chemin logique (source of truth)  type: NodeType;    hasChildren?: boolean;  // utile pour lazy loading  size?: number;          // optionnel (files)  mimeType?: string;      // optionnel  /**   * Enfants du node — uniquement renseigné quand le node est issu d'un   * `getTree(depth > 1)` qui a chargé la sous-arborescence.   *   * Convention sémantique :   *   - `undefined` : non chargé (par exemple à la profondeur max d'un getTree)   *   - `[]`        : vide pour de vrai   *   - non vide    : enfants chargés   *   * `hasChildren` ci-dessus reste utile pour signaler explicitement la   * présence d'enfants même quand `children` est `undefined` (cas où on   * sait qu'il y a quelque chose mais on ne l'a pas encore chargé).   */  children?: FinderNode[];  /**   * 👉 important pour DnD   */  isDraggable?: boolean;  isDroppable?: boolean;  // extensible sans casser le core  meta?: MediaMeta;}

Trois champs méritent qu’on s’y arrête.

est l’identité d’un nœud. Pour la bibliothèque, c’est la clé de stockage elle-même, celle qui sert aux opérations et aux jointures avec . Le contrat reste plus général — un adaptateur peut présenter un chemin qui n’existe pas chez un fournisseur, comme la racine virtuelle de Mes espaces — mais il ne reconstruit jamais un chemin à partir d’un nom affiché.

children distingue trois états, qu’un simple tableau confondrait :

children

Signification

undefined
Pas encore chargé (limite de profondeur atteinte) : l’arbre demandera la suite quand on dépliera le dossier.
[]
Vide pour de vrai.
[ … ]
Enfants chargés.

meta porte ce que la base ajoute au stockage : le statut, le nom de l’expéditeur, l’URL d’aperçu, le format. C’est l’adaptateur qui l’assemble :

mapFileToFinderNode
TS
apps/web/src/features/finder-adapters/storage/finderStorage.adapter.ts
empty code block

L’URL d’aperçu ne pointe pas vers le stockage, mais vers la route privée de l’application (/api/media/r2/…?variant=medium) : c’est elle qui vérifie le droit de lecture et sert la déclinaison, comme au chapitre précédent.

Au temps de Cloudinary, le chemin d’un fichier contenait son statut (AKFC/pending/…), et le Finder a longtemps « plié » ces chemins pour n’en montrer qu’un. Ce pliage a été retiré : le statut est une métadonnée (meta.status), et le chemin est la clé, point. Si un ancien commentaire parle de « chemin logique » opposé à un « chemin physique », c’est de cette époque qu’il vient.

Récapitulatif#

Question

Réponse du projet

Que sait répondre le stockage ?
Les clés sous un préfixe, regroupées au prochain « / » (ListObjectsV2 + Delimiter) : un niveau à la fois, sans dossier vide.
Pourquoi ne pas relayer cette liste ?
Il y manque le nom lisible, le statut, l’expéditeur, les dossiers vides et les droits ; et la synthèse serait refaite par chaque client.
D’où viennent les dossiers vides ?
Des entités en base (groupes, membres, catégories), complétées par Folder ; les libellés viennent de FolderLabel.
Comment le Finder est-il découpé ?
Un composant, des adaptateurs (bibliothèque, Mes espaces, sélecteur), un backend ; le contrat FileAdapter les relie.
Qu’est-ce qui identifie un nœud ?
Son path : pour la bibliothèque, la clé de stockage. Jamais un nom affiché.

À suivre#

Nous savons ce que le stockage rend, ce que la base sait, et comment le Finder se découpe. Reste à assembler : comment, à chaque ouverture d’un dossier, le serveur fabrique l’arbre que le navigateur affiche — et dans quel ordre il le fait.

Chapitre suivant : lire l’arbre. La procédure storage.getTree passe par passe, l’enrichissement groupé, les espaces réinjectés, la recherche, et le cache du navigateur.

Sur cette page