Servir un fichier

Le cycle de vie d’un média (valider sans déplacer, ne pas dépublier ce qui est affiché), la route privée et sa règle de lecture par position, la route publique et ses deux gestes délibérés, les déclinaisons fabriquées à la demande, et les en-têtes qui empêchent un fichier de s’exécuter.

Précédemment. Un fichier est au stockage, sous une clé stable, et sa ligne est en base, en attente. Chaque champ de cette ligne vient du serveur, du stockage ou de la session.

Les parcours du chapitre#

Deux parcours traversent ce chapitre : valider un fichier, et l’afficher. Chaque onglet en déroule les étapes et ce qu’elles lisent en base ; chaque étape mène à son explication plus bas.

Acteur : Administrateur, ou éditeur d’un espace de groupe

  1. 1

    Il valide le fichier, ou le remet en attente ; seule la colonne change, et rien de ce qu’une page ou une galerie affiche n’est dépublié

    media.setStatusmembre

    • MediaAsset modifiée : status

    Lit : Gallery, GalleryItem, MediaAsset, 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).

    media.setStatus packages/backend/src/modules/media/router.ts:500
    • ctx.prisma.mediaAsset.updateMany packages/backend/src/modules/media/router.ts:535MediaAsset modifiée
    • ctx.prisma.mediaAsset.updateMany packages/backend/src/modules/media/router.ts:544MediaAsset modifiée

Le cycle de vie d’un média#

porte l’état d’un fichier :

Statut

Affiché

Signification

pending
En attente
Valeur de tout nouvel envoi. Le fichier n’est sélectionnable dans aucun contenu et n’est servi à aucun visiteur.
published
Validé
Un administrateur l’a validé : il peut être posé dans une page ou une galerie.

La corbeille n’est pas un troisième statut : y entrer est une opération à part (trash.trashToBin), avec sa propre quarantaine, que le chapitre consacré au finder détaille.

Valider, c’est changer une colonne#

Au temps de Cloudinary, valider une photo la déplaçait : de AKFC/pending/… vers AKFC/published/…. Le statut était écrit dans le chemin. Chaque validation renommait donc l’objet chez le fournisseur, changeait son URL, et devait mettre à jour toutes les références — avec, entre les deux, des états où la base et le stockage ne disaient plus la même chose.

Aujourd’hui, valider est un UPDATE :

media.setStatus — un UPDATE, pas un déplacement

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

Qui peut valider
TS
packages/backend/src/modules/media/router.ts
guided extract: lines 500-569focus lines 19-23
/** * 📂 Module `media` — accès aux métadonnées MediaAsset depuis le finder * * Matching EXACT par `fullPath` : la clé de stockage EST le chemin. (L'époque Cloudinary tolérait un * chemin sans extension, `startsWith(path + '.')` ; sur R2 un chemin porte toujours la sienne, et * cette tolérance faisait correspondre « photo.jpg » à « photo.jpg.zip ».) * * Les résultats sont **toujours indexés par le path d'entrée** (UI-facing), * pas par le fullPath DB. C'est ce que le frontend attend depuis Phase 1. */import { assertCanTrashPaths } from "@backend/modules/memberGroups/assertCanTrashPaths.service";import { outsideBin } from '@backend/modules/storage/mediaPaths';import { assertCanReadPaths } from "@backend/modules/memberGroups/assertCanReadPaths.service";import { canReadStorageKey } from "@backend/modules/media/services/canReadStorageKey.service";import { z } from 'zod';import { TRPCError } from '@trpc/server';import { router, protectedProcedure } from '@backend/trpc';import { sameAppRoot } from '@backend/trpc/middleware';import type { ResolvedMedia } from '@contracts/page';import { deriveMediaKind } from '@backend/modules/media/helpers/deriveMediaKind';import { buildMediaProxyUrl } from '@backend/modules/media/helpers/media-url';import { assertStatusChangeDoesntUnpublishReferencedAssets } from '@backend/modules/media/services/assertStatusChangeDoesntUnpublishReferencedAssets.service';/* -------------------------------------------------------------------------- *//*                                  HELPERS                                   *//* -------------------------------------------------------------------------- */function composeDisplayName(user: {  firstName: string | null;  lastName: string | null;  pseudo: string | null;}): string {  if (user.pseudo && user.pseudo.trim()) return user.pseudo.trim();  if (user.firstName && user.lastName) return `${user.firstName} ${user.lastName}`;  if (user.firstName) return user.firstName;  if (user.lastName) return user.lastName;  // Pas d'adresse e-mail en repli : ces listes servent aussi aux membres (espaces de groupe).  return "Membre";}function parentOf(path: string, appRoot: string): string {  if (path === appRoot) return appRoot;  const lastSlash = path.lastIndexOf('/');  return lastSlash > 0 ? path.slice(0, lastSlash) : appRoot;}/** Dernier segment d'un fullPath → nom de fichier. */function fileNameOf(fullPath: string): string {  return fullPath.split('/').pop() ?? fullPath;}/* -------------------------------------------------------------------------- *//*                                 ROUTER                                     *//* -------------------------------------------------------------------------- *//** Procédure connectée dont l'`appRoot` éventuel doit être celui du serveur. */const storageProcedure = protectedProcedure.use(sameAppRoot);export const mediaRouter = router({  /**   * Lookup batch des MediaAsset rows pour une liste de paths (matching exact).   */  getByPaths: storageProcedure    .input(      z.object({        appRoot: z.string().min(1),        paths: z.array(z.string()).max(500),      }),    )    .query(async ({ input, ctx }) => {      if (input.paths.length === 0) return {};      // Statut, auteur, description : la même règle que la lecture du dossier.      await assertCanReadPaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: input.paths });      const assets = await ctx.prisma.mediaAsset.findMany({        where: {          appRoot: input.appRoot,          fullPath: { in: input.paths },        },        include: {          uploader: {            select: {              id: true,              firstName: true,              lastName: true,              pseudo: true,            },          },        },      });      const byPath: Record<        string,        {          status: string;          createdAt: string;          uploadedBy: string;          uploaderId: string;          mimeType: string;          width: number | null;          height: number | null;          duration: number | null;          description: string | null;          bytes: number;        }      > = {};      for (const asset of assets) {        byPath[asset.fullPath] = {          // Source de vérité visée du statut (cf. MediaMeta.status).          status: asset.status,          createdAt: asset.uploadedAt.toISOString(),          uploadedBy: composeDisplayName(asset.uploader),          uploaderId: asset.uploader.id,          mimeType: asset.mimeType,          width: asset.width,          height: asset.height,          duration: asset.duration,          description: asset.description,          bytes: asset.bytes,        };      }      return byPath;    }),  /**   * 🔗 Résout une liste de paths (côté UI/finder) vers leur `mediaId`   * stable correspondant.   *   * Retourne un Record indexé par le path d'entrée. Si un path ne   * correspond à aucun asset, la valeur est `null` plutôt qu'omise.   */  resolveByPaths: storageProcedure    .input(      z.object({        appRoot: z.string().min(1),        paths: z.array(z.string().min(1)).max(500),      }),    )    .query(async ({ input, ctx }) => {      if (input.paths.length === 0) {        return {} as Record<string, string | null>;      }      await assertCanReadPaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: input.paths });      const assets = await ctx.prisma.mediaAsset.findMany({        where: { appRoot: input.appRoot, fullPath: { in: input.paths } },        select: { id: true, fullPath: true },      });      const byPath: Record<string, string | null> = {};      for (const p of input.paths) byPath[p] = null;      for (const asset of assets) byPath[asset.fullPath] = asset.id;      return byPath;    }),  /**   * 🔗 Résout une liste de `mediaId` (cuid stable) vers les   * informations nécessaires au rendu d'un asset (URL + metadata).   *   * ─── Politique : `published` uniquement ────────────────────────────   *   * On filtre `status === 'published'`. Un asset en `pending` ou en   * `bin` n'est jamais résolu — le caller reçoit `null` pour cet id   * et affichera son placeholder « média indisponible ».   *   * La forme de retour est le type canonique `ResolvedMedia`   * (`@contracts/page`) — source unique partagée avec le PageRenderer,   * le MediaListEditor (qui l'infère via inferRouterOutputs) et le   * MediaItemsEditor.   *   * Retourne un Record indexé par mediaId. Les ids absents de la DB   * ou non-published rendent `null`.   *   * ─── Ce que l'appelant peut lire ───────────────────────────────────   *   * Un administrateur résout tout. Un autre membre (un instructeur qui rédige sa présentation)   * ne résout que les fichiers qu'il peut LIRE (`canReadStorageKey` : son espace, ses groupes) ;   * les autres rendent `null`, comme un fichier introuvable. Sans ce filtre, un identifiant   * suffisait à apprendre le chemin et le nom d'origine de n'importe quel fichier validé.   */  resolveByIds: storageProcedure    .input(      z.object({        mediaIds: z.array(z.string().min(1)).max(500),      }),    )    .query(async ({ input, ctx }) => {      if (input.mediaIds.length === 0) {        return {} as Record<string, ResolvedMedia | null>;      }      const found = await ctx.prisma.mediaAsset.findMany({        where: {          id: { in: input.mediaIds },          status: 'published',          // Un fichier jeté (sa ligne a suivi l'objet dans la corbeille) ne se résout plus.          ...outsideBin(ctx.appRoot),        },        select: {          id: true,          fullPath: true,          displayName: true,          originalFileName: true,          mimeType: true,          width: true,          height: true,          duration: true,        },      });      const assets = ctx.sessionClient.user.isAdmin        ? found        : (            await Promise.all(              found.map(async (asset) =>                (await canReadStorageKey({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, key: asset.fullPath }))                  ? asset                  : null,              ),            )          ).filter((asset) => asset !== null);      const byId: Record<string, ResolvedMedia | null> = {};      for (const id of input.mediaIds) byId[id] = null;      for (const asset of assets) {        const kind = deriveMediaKind(asset.mimeType);        const baseUrl = buildMediaProxyUrl(asset);        byId[asset.id] = {          url: baseUrl,          kind,          posterUrl: kind === 'video' ? `${baseUrl}&as=poster` : null,          mimeType: asset.mimeType,          fileName:            asset.displayName?.trim() ||            asset.originalFileName ||            fileNameOf(asset.fullPath),          width: asset.width,          height: asset.height,          duration: asset.duration,        };      }      return byId;    }),  /**   * 🔎 Recherche récursive — files + folders (status + métier).   * Matche par `fullPath` (clé universelle).   */  searchRecursive: storageProcedure    .input(      z.object({        appRoot: z.string().min(1),        prefix: z.string().min(1),        query: z.string().min(1).max(200),        caseSensitive: z.boolean().default(false),        wholeWord: z.boolean().default(false),        useRegex: z.boolean().default(false),        limit: z.number().int().positive().max(500).default(200),      }),    )    .query(async ({ input, ctx }) => {      // On ne cherche que là où l'on peut lire : sans cette garde, la recherche listait les      // fichiers de toute la bibliothèque (espaces personnels et autres groupes compris).      await assertCanReadPaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.prefix] });      // Une expression régulière fournie par l'utilisateur s'exécute sur le serveur : une expression      // mal construite (« (a+)+$ ») peut le bloquer de longues secondes. Réservée aux administrateurs.      if (input.useRegex && !ctx.sessionClient.user.isAdmin) {        throw new TRPCError({          code: "FORBIDDEN",          message: "La recherche par expression régulière est réservée aux administrateurs.",        });      }      // 1. Compile le matcher selon les flags      let matcher: ((s: string) => boolean) | null = null;      try {        if (input.useRegex) {          const flags = input.caseSensitive ? '' : 'i';          const re = new RegExp(input.query, flags);          matcher = (s: string) => re.test(s);        } else {          const escaped = input.query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');          const pattern = input.wholeWord ? `\\b${escaped}\\b` : escaped;          const flags = input.caseSensitive ? '' : 'i';          const re = new RegExp(pattern, flags);          matcher = (s: string) => re.test(s);        }      } catch {        return { results: [], truncated: false };      }      // 2. Lecture par paquets, triés par chemin, jusqu'à avoir assez de fichiers qui correspondent.      //      // Le filtre (nom, description, expéditeur, éventuellement une expression régulière) s'exécute      // ici et non en base : lire « les N premiers » puis filtrer manquerait tout fichier qui      // correspond au-delà de ces N. On lit donc tout le sous-arbre, paquet par paquet, dans une      // limite de SCAN_MAX lignes (le résultat est alors signalé tronqué).      const SCAN_BATCH = 500;      const SCAN_MAX = 10_000;      const candidates: Array<Awaited<ReturnType<typeof fetchBatch>>[number]> = [];      async function fetchBatch(cursor: string | undefined) {        return ctx.prisma.mediaAsset.findMany({          where: {            appRoot: input.appRoot,            OR: [{ fullPath: input.prefix }, { fullPath: { startsWith: `${input.prefix}/` } }],            // Depuis la racine, la corbeille n'est pas fouillée : ses fichiers ont leur propre vue.            ...(input.prefix.startsWith(`${input.appRoot}/bin`) ? {} : outsideBin(input.appRoot)),          },          include: {            uploader: { select: { id: true, firstName: true, lastName: true, pseudo: true } },          },          orderBy: { fullPath: 'asc' },          take: SCAN_BATCH,          ...(cursor ? { cursor: { fullPath: cursor }, skip: 1 } : {}),        });      }      let scanTruncated = false;      for (let cursor: string | undefined; ; ) {        const batch = await fetchBatch(cursor);        candidates.push(...batch);        if (batch.length < SCAN_BATCH) break;        if (candidates.length >= SCAN_MAX) {          scanTruncated = true;          break;        }        cursor = batch[batch.length - 1].fullPath;      }      // 3a. FICHIERS qui matchent : nom d'origine, nom d'affichage, description, expéditeur.      const filteredFiles = candidates.filter((asset) => {        if (matcher!(asset.originalFileName)) return true;        if (asset.displayName && matcher!(asset.displayName)) return true;        if (asset.description && matcher!(asset.description)) return true;        const displayName = composeDisplayName(asset.uploader);        if (matcher!(displayName)) return true;        return false;      });      // 3b. DOSSIERS MÉTIER : dérivés des fullPaths des assets sous le prefix      const folderPaths = new Set<string>();      for (const asset of candidates) {        if (!asset.fullPath) continue;        const fullPath = asset.fullPath;        const relative = fullPath.startsWith(`${input.prefix}/`)          ? fullPath.slice(input.prefix.length + 1)          : fullPath === input.prefix            ? ''            : null;        if (relative === null) continue;        const segments = relative.split('/');        if (segments.length <= 1) continue;        let acc = input.prefix;        for (let i = 0; i < segments.length - 1; i++) {          acc = `${acc}/${segments[i]}`;          folderPaths.add(acc);        }      }      const matchedFolders: Array<{ path: string; name: string }> = [];      for (const path of folderPaths) {        const lastSlash = path.lastIndexOf('/');        const name = lastSlash >= 0 ? path.slice(lastSlash + 1) : path;        if (matcher!(name)) {          matchedFolders.push({ path, name });        }      }      // 3c. La corbeille, vrai dossier de la racine, reste cherchable.      const STATUS_FOLDERS = ['bin'];      const statusFolders: Array<{ path: string; name: string }> = [];      if (input.prefix === input.appRoot) {        for (const folder of STATUS_FOLDERS) {          if (matcher!(folder)) {            statusFolders.push({              path: `${input.appRoot}/${folder}`,              name: folder,            });          }        }      }      // 4. Concat + truncation      matchedFolders.sort((a, b) => a.path.localeCompare(b.path));      const totalCount = statusFolders.length + matchedFolders.length + filteredFiles.length;      const truncated = scanTruncated || totalCount > input.limit;      const folderResultsCount = statusFolders.length + matchedFolders.length;      const fileBudget = Math.max(0, input.limit - folderResultsCount);      const limitedFiles = filteredFiles.slice(0, fileBudget);      // 5. Projection      const folderResults = [...statusFolders, ...matchedFolders].map((f) => ({        kind: 'folder' as const,        id: `folder:${f.path}`,        path: f.path,        parentPath: parentOf(f.path, input.appRoot),        name: f.name,        mimeType: null,        format: null,        bytes: null,        createdAt: null,        uploadedBy: null,        uploaderId: null,        description: null,        width: null,        height: null,        duration: null,      }));      const fileResults = limitedFiles.map((asset) => {        // `id` reste le cuid `MediaAsset.id` ; le chemin EST la clé de stockage.        const path = asset.fullPath;        const lastSlash = path.lastIndexOf('/');        const parentPath = lastSlash > 0 ? path.slice(0, lastSlash) : input.appRoot;        const name =          asset.displayName?.trim() ||          asset.originalFileName ||          (lastSlash > 0 ? path.slice(lastSlash + 1) : path);        return {          kind: 'file' as const,          id: asset.id,          path,          parentPath,          name,          mimeType: asset.mimeType,          format: asset.format,          bytes: asset.bytes,          createdAt: asset.uploadedAt.toISOString(),          uploadedBy: composeDisplayName(asset.uploader),          uploaderId: asset.uploader.id,          description: asset.description,          width: asset.width,          height: asset.height,          duration: asset.duration,        };      });      return { results: [...folderResults, ...fileResults], truncated };    }),  /**   * ✏️ Mise à jour de la description d'un fichier.   */  updateDescription: storageProcedure    .input(      z.object({        appRoot: z.string().min(1),        path: z.string().min(1),        description: z.string().max(2000),      }),    )    .mutation(async ({ input, ctx }) => {      // Même droit que la mise à la corbeille : un administrateur partout, un membre      // seulement dans l'espace d'un groupe dont il est éditeur.      await assertCanTrashPaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] });      const result = await ctx.prisma.mediaAsset.updateMany({        where: {          appRoot: input.appRoot,          fullPath: input.path,        },        data: {          description: input.description.trim() || null,        },      });      if (result.count === 0) {        throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." });      }      return { ok: true, updatedAt: new Date().toISOString() };    }),  /**   * 🔀 Changement de statut — **publier n'est pas un déplacement**.   *   * Au temps de Cloudinary, le statut était un segment du chemin (`AKFC/pending/…`,   * `AKFC/published/…`) : publier déplaçait le binaire, changeait son URL et toutes ses   * références. Ici, c'est un UPDATE de `MediaAsset.status`, la source de vérité : atomique, sans   * aller-retour avec le stockage, sans binaire qui bouge.   *   *   - `file`   : la ligne dont `fullPath` est ce chemin. Zéro ligne est une anomalie (le statut   *                n'aurait pas pris sans que personne le sache) : NOT_FOUND.   *   - `folder` : toutes les lignes SOUS ce dossier (le `/` final évite que `cours` emporte   *                `cours-avance`). Zéro ligne est légitime : un dossier vide n'a rien à publier.   *   * La corbeille n'est pas un statut : y entrer, c'est `trash.trashToBin`.   */  setStatus: storageProcedure    .input(      z.object({        appRoot: z.string().min(1),        sources: z          .array(            z.object({              kind: z.enum(["file", "folder"]),              path: z.string().min(1),            }),          )          .min(1),        status: z.enum(["pending", "published"]),      }),    )    .mutation(async ({ input, ctx }) => {      // Valider ou remettre en attente : un administrateur partout, un membre seulement      // dans l'espace d'un groupe dont il est éditeur (même règle que la corbeille).      await assertCanTrashPaths({        prisma: ctx.prisma,        userId: ctx.sessionClient.user.id,        paths: input.sources.map((s) => s.path),      });      // AVANT le moindre UPDATE : on ne dépublie pas ce qu'une page ou une galerie affiche.      await assertStatusChangeDoesntUnpublishReferencedAssets(        ctx.prisma,        input.sources,        input.status,        input.appRoot,      );      const results = await Promise.all(        input.sources.map(async (source) => {          if (source.kind === "file") {            const { count } = await ctx.prisma.mediaAsset.updateMany({              where: { appRoot: input.appRoot, fullPath: source.path },              data: { status: input.status },            });            return { source, count };          }          // Dossier : tout ce qui vit SOUS lui. Le `/` final n'est pas cosmétique — sans lui,          // publier `cours` publierait aussi `cours-avance`.          const { count } = await ctx.prisma.mediaAsset.updateMany({            where: {              appRoot: input.appRoot,              fullPath: { startsWith: `${source.path}/` },            },            data: { status: input.status },          });          return { source, count };        }),      );      const silent = results.filter(        (r) => r.source.kind === "file" && r.count === 0,      );      if (silent.length > 0) {        throw new TRPCError({          code: "NOT_FOUND",          message: `Fichier introuvable : ${silent.map((r) => r.source.path).join(", ")}.`,        });      }      return {        ok: true,        updated: results.reduce((n, r) => n + r.count, 0),      };    }),});

Le statut a cessé d’être un cache dérivé du chemin pour devenir la source de vérité. Valider est atomique, sans aller-retour avec le stockage, sans binaire qui bouge.

La transition a eu un coût, et une fin. Pendant des mois, le finder a « plié » les anciens chemins : il montrait AKFC/courses/x en fusionnant AKFC/pending/courses/x et AKFC/published/courses/x, et chaque écriture redescendait vers les emplacements réels — un millier de lignes de code de passage. Il a été retiré d’un coup, après une seule vérification : une requête en production qui comptait les fichiers et les dossiers encore rangés sous pending/ ou published/, et rendait deux zéros. Un code de transition se supprime sur une preuve, pas sur une impression.

Ne pas dépublier ce qui est affiché#

La garde cherche, pour les fichiers publiés qui seraient remis en attente, ce qui les affiche — les pages et les galeries :

Les usages d’un fichier, nommés
TS
packages/backend/src/modules/media/services/assertStatusChangeDoesntUnpublishReferencedAssets.service.ts
  // 3) Les usages : pages et galeries.  const assetIds = affectedAssets.map((a) => a.id);  const [refs, galleryItems] = await Promise.all([    db.pageMediaReference.findMany({      where: { mediaAssetId: { in: assetIds } },      select: { mediaAssetId: true, pageType: true, pageId: true },    }),    db.galleryItem.findMany({      where: { mediaAssetId: { in: assetIds } },      select: { mediaAssetId: true, gallery: { select: { title: true } } },    }),  ]);  if (refs.length === 0 && galleryItems.length === 0) return;  // 4) Diagnostic : on nomme ce qui affiche chaque fichier, pour que  //    l'administrateur sache quoi retirer.  const usesByAssetId = new Map<string, string[]>();  const addUse = (assetId: string, use: string) => {    usesByAssetId.set(assetId, [...(usesByAssetId.get(assetId) ?? []), use]);  };  for (const ref of refs) addUse(ref.mediaAssetId, `${String(ref.pageType)} #${ref.pageId}`);  for (const item of galleryItems) addUse(item.mediaAssetId, `galerie « ${item.gallery.title} »`);  const blockedAssets = affectedAssets.filter((a) => usesByAssetId.has(a.id));  const lines = blockedAssets.map(    (asset) => `  - ${asset.fullPath} → utilisé par : ${(usesByAssetId.get(asset.id) ?? []).join(', ')}`,  );  throw new TRPCError({    code: 'CONFLICT',    message:      `Dépublication impossible : ${blockedAssets.length} média(s) encore utilisé(s).\n` +      `Retire-les des pages et des galeries qui les affichent, puis réessaie.\n` +      lines.join('\n'),  });

Une galerie n’affiche que les fichiers publiés : dépublier une photo l’en retirerait en silence. La garde la traite donc comme une page.

Le refus est un CONFLICT, pas une Error. La différence n’est pas de style : une Error sortant d’une procédure devient une erreur interne, dont le message est remplacé en production par un message neutre. L’administrateur lirait « une erreur est survenue », sans savoir quelles pages retirer. CONFLICT dit exactement ce qui se passe — l’opération heurte l’état actuel des données — et laisse passer le message.

Une racine qui n’est pas un choix du client#

Plusieurs procédures du stockage reçoivent encore appRoot dans leur entrée, un héritage. Or les gardes s’en servent pour protéger la racine ou la corbeille (${appRoot}/bin/.trash/…). Une racine inventée par le client fausserait ces calculs. Un middleware l’aligne sur celle du serveur :

sameAppRoot
TS
packages/backend/src/trpc/middleware.ts
/** * Plusieurs procédures du stockage reçoivent encore `appRoot` dans leur entrée (héritage). La racine * de l'application n'est pas un choix du client : elle doit être celle du serveur. Sinon, les gardes * qui protègent la racine, la corbeille ou un préfixe (`${appRoot}/bin/.trash/…`) seraient calculées * sur une racine inventée. Sans effet pour une entrée qui ne porte pas `appRoot`. */export const sameAppRoot = t.middleware(async ({ ctx, getRawInput, next }) => {  const raw = await getRawInput();  if (raw && typeof raw === "object" && "appRoot" in raw && raw.appRoot !== ctx.appRoot) {    throw new TRPCError({ code: "BAD_REQUEST", message: "Racine d'application inconnue." });  }  return next();});

Le middleware lit l’entrée brute (getRawInput), avant validation : il s’applique à toutes les procédures des routeurs media et trash, et reste sans effet pour celles dont l’entrée ne porte pas de racine.

Servir un fichier privé#

Le bucket est entièrement privé : aucun objet n’a d’URL publique permanente. Pour afficher un fichier, l’interface pointe vers une route de l’application, /api/media/r2/<clé>, qui décide, puis délègue.

La route privée — décider ici, livrer ailleurs

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

Une session
TS
apps/web/src/app/api/media/r2/[...path]/route.ts
guided extract: lines 56-116focus lines 6-9
// ⚠ Fichier introuvable : apps/web/src/app/api/media/r2/[...path]/route.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.

La symétrie avec l’envoi est complète. À l’aller, le serveur signe un PUT et le fichier ne le traverse pas ; au retour, il signe un GET et le fichier ne le traverse pas davantage. Dans les deux sens, le serveur décide, et le stockage transporte.

La règle de lecture, par position#

canReadStorageKey
TS
packages/backend/src/modules/media/services/canReadStorageKey.service.ts
export async function canReadStorageKey(params: {  prisma: PrismaClient;  userId: string;  key: string;}): Promise<boolean> {  const { prisma, userId, key } = params;  if (await isAdminByGroup(prisma, userId)) return true;  const parts = key.split("/").filter(Boolean);  // parts[0] = appRoot ; parts[1] = l'espace ; parts[2] = son propriétaire.  const rest = parts.slice(1);  const [space, owner] = rest;  if (!space || !owner || rest.length < 3) return false;  const id = owner.slice(owner.lastIndexOf("-") + 1);  if (!id || owner.lastIndexOf("-") === -1) return false;  if (space === "personal-space") return id === userId;  if (space === "collaborative-group-spaces") {    const access = await resolveGroupAccessForUser(prisma, userId, id);    return access !== "NONE";  }  return false;}

Un administrateur lit tout. Un membre lit son propre espace personnel, et l’espace des groupes collaboratifs dont il fait partie, lecteur ou éditeur. Tout le reste est refusé — y compris les fichiers qu’il a lui-même déposés ailleurs, en attente : déposer n’est pas publier, et publier n’est pas montrer.

Le chemin est analysé par position : le segment qui suit la racine désigne l’espace, le suivant son propriétaire, dont l’identifiant est la fin du segment, après le dernier tiret. Chercher simplement un segment personal-space n’importe où serait une faille : un dossier nommé ainsi au fond de courses/… ouvrirait son contenu. Un dossier, rappelons-le, n’est qu’un préfixe ; la seule frontière fiable est une position dans ce préfixe.

Servir un fichier public#

Les pages publiques affichent des images à des visiteurs anonymes. Elles passent par une autre route, /api/media/public/r2/<clé>, sans session, mais avec une garde stricte :

La garde de la route publique
TS
apps/web/src/app/api/media/public/r2/[...path]/route.ts
empty code block

Un fichier n’est servi publiquement qu’après trois gestes délibérés d’un administrateur : il l’a validé, il l’a placé dans un contenu ou dans une galerie publique, et ce contenu est publié. Le statut seul ne suffit pas : une photo personnelle validée n’est pas exposée tant qu’elle n’est posée nulle part, une galerie réservée aux membres n’ouvre rien au public, et les fichiers d’un brouillon restent réservés aux administrateurs, qui le voient par la route privée. isPubliclyPlaced dit, pour chaque sorte de contenu, ce que « publié » veut dire (partie « Contenu éditorial »).

Les déclinaisons#

Une photo d’appareil pèse plusieurs mégaoctets ; une vignette de finder n’en a besoin que de quelques kilo-octets. Les deux routes acceptent un paramètre ?variant= :

Déclinaison

Taille

Usage

thumb
150 × 150, recadrée
vignettes du finder et des listes
small
300 px de large
cartes, petites colonnes
medium
600 px
contenu courant
large
1 200 px
grandes images, plein écran

Cloudinary les fabriquait en lisant l’URL (/upload/w_400,f_auto/…). Le projet les fabrique lui-même, à la première demande :

ensureImageVariant
TS
packages/backend/src/modules/media/services/mediaDerivatives.service.ts
export async function ensureImageVariant(fullPath: string, variant: MediaVariant): Promise<string | null> {  if (variant === "original" || !isTransformableImage(fullPath)) return null;  const sh = await loadSharp();  if (!sh) return null;  const src = await originalEtag(fullPath);  if (!src || src.bytes > MAX_SOURCE_BYTES) return null;  const key = `${derivedFolder(fullPath)}${src.etag}-${variant}.avif`;  if (await exists(key)) return key;  const obj = await getR2Client().send(new GetObjectCommand({ Bucket: getR2Bucket(), Key: fullPath }));  if (!obj.Body) return null;  const raw = Buffer.from(await obj.Body.transformToByteArray());  const input = isHeif(fullPath) ? await decodeHeif(raw) : raw;  if (!input) return null;  const out = await resize(sh, input, variant).avif(AVIF_OPTIONS).toBuffer();  await put(key, out, "image/avif");  return key;}

Trois décisions se lisent dans ces lignes.

  • Rangées hors de la racine. Les déclinaisons vivent sous _derived/, pas sous AKFC/ : le finder ne les voit jamais, et aucune route ne les sert par leur clé.
  • Nommées par l’ETag de l’original. L’ETag est l’empreinte que le stockage donne à un objet ; il change quand le fichier est remplacé ou recadré. La clé de la déclinaison change avec lui, et une nouvelle vignette se fabrique d’elle-même — sans invalidation à écrire, sans risque de servir l’ancienne. Les déclinaisons orphelines sont balayées chaque jour.
  • Bornées. Au-delà de 60 Mio, on sert l’original plutôt que de charger le serveur. Les photos HEIC des iPhone sont d’abord décodées. Pour une vidéo, ?as=poster renvoie une image d’affiche extraite par ffmpeg.
resolveDeliveryKey
TS
packages/backend/src/modules/media/services/mediaDerivatives.service.ts
export async function resolveDeliveryKey(fullPath: string, params: URLSearchParams): Promise<string | null> {  const variant = parseMediaVariant(params.get("variant"));  if (params.get("as") === "poster") return ensureVideoPoster(fullPath, variant ?? "large");  if (!variant) return fullPath;  try {    return (await ensureImageVariant(fullPath, variant)) ?? fullPath;  } catch (err) {    console.error("[media/derivatives] déclinaison impossible, original servi :", fullPath, err instanceof Error ? err.message : err);    return fullPath;  }}

Si la fabrication échoue (outil absent, fichier trop gros, image illisible), la route sert l’original : une déclinaison est une optimisation, jamais une condition d’affichage.

Contrairement aux originaux, une déclinaison n’est pas servie par redirection mais en flux, avec un en-tête de cache d’une heure. L’adresse …?variant=thumb étant stable, le navigateur la garde en cache ; une redirection vers une URL signée, qui change à chaque requête, ne le permettrait pas. Un navigateur qui n’accepte pas l’AVIF reçoit à la volée une version WebP, et la réponse le signale par Vary: Accept pour que les caches ne mélangent pas les deux.

Pour afficher un média, on demande toujours la plus petite déclinaison suffisante : thumb dans une grille, medium dans un contenu. Le premier affichage la fabrique ; les suivants la servent directement.

Un fichier qui ne s’exécute pas#

Un fichier servi par notre origine — un document de membre, le logo du site — est lu dans le même contexte que nos pages, avec nos cookies. S’il était interprété comme une page HTML, un script qu’il contiendrait s’exécuterait chez nous. Les originaux redirigés vers le stockage n’ont pas ce risque : ils sont lus sur l’origine du stockage, qui n’a accès à rien de notre site.

La route des documents de membres sert donc en ligne les seuls types sans danger, et tout le reste en pièce jointe :

Afficher, ou télécharger
TS
apps/web/src/app/api/media/member-document/[id]/route.ts
empty code block
Les en-têtes de la réponse
TS
apps/web/src/app/api/media/member-document/[id]/route.ts
empty code block

nosniff interdit au navigateur de deviner un autre type que celui annoncé ; la politique sandbox interdit à la réponse d’exécuter quoi que ce soit. Un PDF en est exempté, parce que le lecteur intégré des navigateurs refuse de s’afficher dans un document « sandboxé » — et un lecteur PDF de navigateur n’exécute rien dans le contexte de nos pages. Les envois sont déjà limités aux formats autorisés : ces en-têtes couvrent les fichiers plus anciens, et tout oubli futur. Le logo du site, seul SVG accepté, est servi de la même façon : affiché dans une balise <img>, un SVG n’exécute jamais ses scripts ; ouvert directement, sandbox et nosniff l’en empêchent.

Une liste blanche à l’envoi et des en-têtes défensifs à la livraison : deux couches pour un même risque, parce qu’aucune ne couvre les fichiers que l’autre a laissé passer.

Récapitulatif#

Question

Réponse du projet

Que fait une validation ?
Elle change MediaAsset.status ; le binaire, sa clé et les références ne bougent pas.
Peut-on dépublier un fichier affiché ?
Non : la garde refuse (CONFLICT) et nomme les pages et galeries qui l’affichent.
Qui peut lire un fichier privé ?
Un administrateur ; un membre pour l’espace de ses groupes et son espace personnel (canReadStorageKey, par position).
Quand un fichier est-il public ?
Quand il est validé ET placé dans une page ou une galerie publique.
Par où passent les octets ?
Originaux : redirection vers une URL signée, depuis le stockage. Déclinaisons : en flux, cachables.
Comment une déclinaison se renouvelle-t-elle ?
Sa clé contient l’ETag de l’original : un fichier remplacé produit de nouvelles déclinaisons.
Qu’est-ce qui empêche un fichier de s’exécuter ?
Une liste blanche de formats à l’envoi ; nosniff, sandbox et pièce jointe pour ce que sert notre origine.

À suivre#

Les fichiers sont désormais rangés sous des clés plates, AKFC/courses/tchoy-lee-fut/salut-final.jpg, dans un stockage qui ne connaît pas les dossiers. Pourtant, les administrateurs naviguent dans une arborescence, renomment des dossiers, déplacent des fichiers, voient « Espace personnel » au lieu de personal-space, et ne voient que les espaces auxquels ils ont droit.

Chapitre suivant : le Finder. Comment une liste de clés devient un arbre navigable, comment la base et le stockage se répartissent les rôles, et comment un déplacement reste cohérent des deux côtés.

Sur cette page