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
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.setStatuspackages/backend/src/modules/media/router.ts:500ctx.prisma.mediaAsset.updateManypackages/backend/src/modules/media/router.ts:535MediaAsset modifiéectx.prisma.mediaAsset.updateManypackages/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. |
Showing entries1to2entries
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.
/** * 📂 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 :
// 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 :
/** * 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.
// ⚠ 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#
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 :
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 |
Showing entries1to4entries
Cloudinary les fabriquait en lisant l’URL (/upload/w_400,f_auto/…). Le projet les fabrique lui-même, à la première demande :
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 sousAKFC/: 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=posterrenvoie une image d’affiche extraite parffmpeg.
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 :
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. |
Showing entries1to7entries
À 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.