Signer un envoi
Qui décide où va un fichier : une destination au lieu d’un chemin, un dossier que le serveur déduit seul de la base et de la session, les contrôles avant de signer (format, droit, écrasement, quota), et ce que l’URL signée couvre — ou ne couvre pas.
Précédemment. Un fichier ne passe pas par notre serveur : le navigateur l’envoie directement au stockage, avec une URL présignée que le backend fabrique sans jamais révéler son secret. Reste à savoir ce que l’on signe.
Les parcours du chapitre#
Un seul parcours traverse ce chapitre : un membre choisit des fichiers et une destination, et obtient pour chacun une autorisation d’envoi. L’onglet en déroule les étapes, relevées dans le code ; chacune mène à son explication plus bas.
Acteur : Membre connecté (éditeur pour un espace de groupe)
- 1
Avant d’envoyer, le formulaire demande quels noms existent déjà à cet endroit, ou se percutent dans le lot
storage.checkR2ConflictsmembreLit : Category, Discipline, Event, Folder, MediaAsset, MemberGroup, MemberGroupMembership, Seminar, User
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
storage.checkR2Conflictspackages/backend/src/modules/storage/router.ts:859resolvePendingUploadFolderpackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:41prisma.category.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:111Category lueprisma.discipline.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:123Discipline lueprisma.event.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:80Event lueresolvePersoBaseFolderpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:25prisma.folder.findFirstpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:38Folder lueprisma.mediaAsset.findFirstpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:45MediaAsset lueprisma.user.findUniquepackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:53User lue
resolveGroupBaseFolderpackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:27prisma.folder.findManypackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:47Folder lueprisma.memberGroup.findUniquepackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:34MemberGroup lue
prisma.seminar.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:66Seminar luecommonRepositoryContainerPathpackages/backend/src/modules/media/services/commonRepositoryContainerPath.service.ts:17prisma.user.findUniquepackages/backend/src/modules/media/services/commonRepositoryContainerPath.service.ts:24User lue
ctx.prisma.mediaAsset.findManypackages/backend/src/modules/storage/router.ts:879MediaAsset lueassertUploadDestinationAllowedpackages/backend/src/modules/storage/assertUploadDestinationAllowed.service.ts:25assertCanWriteGroupSpacepackages/backend/src/modules/memberGroups/assertCanWriteGroupSpace.service.ts:15isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
- 2
Pour chaque fichier, le navigateur décrit une destination ; le serveur vérifie, calcule la clé et signe une URL d’envoi
storage.createR2UploadmembreLit : Category, Discipline, Event, Folder, MediaAsset, MemberGroup, MemberGroupMembership, Seminar, User
Le chemin dans le code
Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).
storage.createR2Uploadpackages/backend/src/modules/storage/router.ts:886resolvePendingUploadFolderpackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:41prisma.category.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:111Category lueprisma.discipline.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:123Discipline lueprisma.event.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:80Event lueresolvePersoBaseFolderpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:25prisma.folder.findFirstpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:38Folder lueprisma.mediaAsset.findFirstpackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:45MediaAsset lueprisma.user.findUniquepackages/backend/src/modules/media/services/resolvePersoBaseFolder.service.ts:53User lue
resolveGroupBaseFolderpackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:27prisma.folder.findManypackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:47Folder lueprisma.memberGroup.findUniquepackages/backend/src/modules/media/services/resolveGroupBaseFolder.service.ts:34MemberGroup lue
prisma.seminar.findUniquepackages/backend/src/modules/storage/services/resolvePendingUploadFolder.service.ts:66Seminar luecommonRepositoryContainerPathpackages/backend/src/modules/media/services/commonRepositoryContainerPath.service.ts:17prisma.user.findUniquepackages/backend/src/modules/media/services/commonRepositoryContainerPath.service.ts:24User lue
ctx.prisma.mediaAsset.findUniquepackages/backend/src/modules/storage/router.ts:917MediaAsset luecountPersoImagespackages/backend/src/modules/media/services/countPersoImages.service.ts:21prisma.mediaAsset.groupBypackages/backend/src/modules/media/services/countPersoImages.service.ts:28MediaAsset lue
assertUploadDestinationAllowedpackages/backend/src/modules/storage/assertUploadDestinationAllowed.service.ts:25assertCanWriteGroupSpacepackages/backend/src/modules/memberGroups/assertCanWriteGroupSpace.service.ts:15isAdminByGrouppackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:8prisma.memberGroupMembership.findFirstpackages/backend/src/modules/memberGroups/isAdminByGroup.service.ts:12MemberGroup lueMemberGroupMembership lue
resolveGroupAccessForUserpackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:15prisma.memberGroup.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:35MemberGroup lueprisma.memberGroupMembership.findUniquepackages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts:27MemberGroupMembership lue
Qui décide de la destination ?#
Avant même de parler de signature, il faut regarder une question qu’on oublie souvent parce qu’elle semble triviale : qui décide de l’endroit où va un fichier ?
Dans un système naïf, c’est le client. Il envoie « je veux déposer ce fichier sous AKFC/courses/tchoy-lee-fut/ », et le backend signe ce qu’on lui demande. Il suffit alors d’ouvrir les outils du navigateur pour remplacer le chemin par AKFC/personal-space/quelquun-d-autre-c…/photos/ ou par la clé d’une photo déjà publiée sur la page d’accueil — et le backend la signerait.
Signer le chemin que le client demande
createUpload: protectedProcedure .input(z.object({ path: z.string(), mimeType: z.string() })) .mutation(({ input }) => // Le serveur signe ce qu’on lui donne : n’importe quelle clé du bucket. getSignedUrl(s3, new PutObjectCommand({ Bucket, Key: input.path, ContentType: input.mimeType }), { expiresIn: 300 }), ),On pourrait valider ce chemin : vérifier un préfixe, interdire .., consulter une liste de dossiers permis. Mais chaque règle oubliée devient une brèche, et la logique de nommage se retrouve dupliquée entre le navigateur, qui calcule le chemin, et le serveur, qui le vérifie. Dans un système sérieux, c’est l’inverse : le client ne fournit jamais de chemin.
Ce que le client a le droit de demander#
Le contrat d’entrée de createR2Upload ne contient pas de chemin. Il contient une destination : une intention métier, exprimée par une union discriminée partagée entre le navigateur et le serveur.
export const uploadDestinationSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("existing-discipline"), categoryId: z.number().int().positive(), disciplineId: z.number().int().positive(), }), z.object({ kind: z.literal("new-discipline"), categoryId: z.number().int().positive(), proposedDisciplineName: z.string().trim().min(1).max(120), }), // ── Destinations découplées de la discipline (fondation) ── // `general` : contenus du club sans discipline ni catégorie. Fait office // d'espace partagé de fait entre admins (pas de permissions : // club petit, confiance). z.object({ kind: z.literal("common_repository"), // Sous-dossier OPTIONNEL sous `general/` (existant ou créé à la volée). // Absent → dépôt à la racine de `general/`. containerName: z.string().trim().min(1).max(120).optional(), }), // `perso` : espace personnel de l'admin. Aucune identité transportée ici — // le dossier cible est dérivé côté serveur de `ctx.user.id`, si // bien qu'un admin ne peut uploader QUE dans son propre dossier. z.object({ kind: z.literal("perso"), }), // `event` : contenus d'un événement (forum des associations, démonstration). // L'événement est créé par les admins ; le membre le choisit ici. Les // `disciplineIds` ENRICHISSENT les disciplines de l'événement (elles // décrivent l'ÉVÉNEMENT, pas chaque photo). z.object({ kind: z.literal("event"), eventId: z.number().int().positive(), disciplineIds: z.array(z.number().int().positive()).default([]), }), // `stage` : contenus d'un stage existant (interne ou externe au club). // Le stage est créé par les admins ; on en choisit un ici. Rattachement // par le chemin `seminars/…` (MediaAsset n'a pas de seminarId). z.object({ kind: z.literal("seminar"), seminarId: z.number().int().positive(), }), // Espace d'un groupe collaboratif : dépôt réservé aux éditeurs/admins // (garde côté router). Rattachement par le chemin `groups/…`. z.object({ kind: z.literal("group"), groupId: z.string(), }),]);Prenons une seconde pour regarder ce que ce schéma interdit aussi clairement que ce qu’il permet. Un membre peut dire « je dépose pour cette discipline », « pour ce stage », « pour cet événement », « dans mon espace », « dans l’espace de ce groupe ». Il ne peut pas dire « dans le dossier AKFC/personal-space/… ». Il ne peut même pas dire « dans mon espace personnel, celui de l’utilisateur 42 » : la variante perso ne transporte aucun identifiant. Le mot « dossier » n’existe pas dans son vocabulaire.
Cette distinction est fondamentale. Une validation sur un champ path rejetterait les mauvaises valeurs, à condition de n’en oublier aucune. Un contrat sans champ path rend les mauvaises valeurs impossibles à exprimer.
Un bon contrat zod ne se contente pas de valider des formats : il encode les décisions métier dans le type, et rend les demandes illégitimes impossibles à formuler.
L’union discriminée apporte un second bénéfice, cette fois au compilateur. Chaque service qui traite une destination se termine par un switch exhaustif (input.destination satisfies never) : ajouter une variante au contrat fait échouer la compilation partout où elle n’est pas encore traitée. C’est ainsi que les destinations event, seminar et group ont pu s’ajouter aux disciplines d’origine sans qu’un cas soit oublié.
Destination | Pour qui | Ce qu’elle désigne |
|---|---|---|
existing-discipline | tout membre connecté | une discipline existante, avec sa catégorie |
new-discipline | tout membre connecté | une discipline proposée, que l’administrateur créera s’il l’accepte |
seminar, event | tout membre connecté | un stage, un événement ; seul un administrateur peut y rattacher des disciplines |
common_repository | tout membre connecté | le dépôt commun, avec un sujet facultatif |
perso | tout membre connecté | son propre espace, images seulement, dans la limite d’un quota |
group | éditeur du groupe, ou administrateur | l’espace d’un groupe collaboratif |
Showing entries1to6entries
Déposer est un droit de membre ; publier est un pouvoir d’administrateur. Tout envoi naît en attente (pending) : il n’apparaît dans aucun contenu tant qu’un administrateur ne l’a pas validé. C’est ce qui permet d’ouvrir le dépôt à tous sans ouvrir la publication — le chapitre 3 y revient.
Du besoin métier au chemin réel#
Une fois l’intention exprimée, le backend la traduit en dossier. Cette traduction est entièrement sous la responsabilité du serveur : le client n’y a aucune voix, ni directe, ni indirecte.
resolvePendingUploadFolder — de la destination au dossier
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { TRPCError } from "@trpc/server";import type { PrismaClient } from "@prisma/client";import slugify from "slugify";import { categorySegment, disciplineSegment } from "@backend/modules/storage/storageSegments";import type { UploadDestination } from "@contracts/storage/upload-destination.schema";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { resolveGroupBaseFolder } from "@backend/modules/media/services/resolveGroupBaseFolder.service";import { commonRepositoryContainerPath } from "@backend/modules/media/services/commonRepositoryContainerPath.service";/** * resolvePendingUploadFolder.service.ts * * Traduit une intention d'envoi (`UploadDestination`) en dossier de stockage. Le client ne * fournit jamais de chemin : tout ce qui compose le dossier est relu en base ou vient de la * session. * * Le nom est historique : le statut d'un fichier ne vit plus dans son chemin (un envoi naît * `pending` parce que l'enregistrement écrit `status: "pending"`), et le dossier rendu ici est * l'emplacement définitif du fichier. * * - existing-discipline : `${appRoot}/<segment de catégorie>/<segment de discipline>`, segments * FIGÉS à la création (storageSegments.ts) ; la discipline doit * appartenir à la catégorie annoncée ; * - new-discipline : `${appRoot}/<segment de catégorie>/new/<slug du nom proposé>` ; * - common_repository : `${appRoot}/common-repository/<sujet ou depot>_<personne>-<userId>` ; * - perso : `${appRoot}/personal-space/<personne>-<userId>/photos`, l'id venant * de la SESSION : on n'écrit que dans son propre espace ; * - event, seminar : `${appRoot}/events/<slug>`, `${appRoot}/seminars/<slug>` ; * - group : l'espace du groupe collaboratif (resolveGroupBaseFolder). * * Une entité absente ou incohérente est une erreur de la requête (NOT_FOUND, BAD_REQUEST), * pas une panne du serveur : le message remonte tel quel au formulaire. */const SLUG_OPTIONS = { lower: true, strict: true } as const;function slug(input: string): string { return slugify(input, SLUG_OPTIONS);}export async function resolvePendingUploadFolder(params: { prisma: PrismaClient; destination: UploadDestination; appRoot: string; /** * Identité de l'admin qui uploade (issue de `ctx.sessionClient.user.id`). Requise pour la * destination `perso` (dossier dérivé de cet id). Ignorée pour les autres. */ userId: string;}): Promise<string> { const { prisma, destination, appRoot, userId } = params; /* ── Destination club générique (sans discipline ni catégorie) ── */ if (destination.kind === "common_repository") { // Chemin du conteneur = source unique partagée avec setCommonRepositoryLabel. return commonRepositoryContainerPath({ prisma, appRoot, userId, subject: destination.containerName, }); } /* ── Destination seminar ── */ if (destination.kind === "seminar") { const seminar = await prisma.seminar.findUnique({ where: { id: destination.seminarId }, select: { id: true, slug: true }, }); if (!seminar) { throw new TRPCError({ code: "NOT_FOUND", message: "Stage introuvable." }); } // `Seminar.slug` est nullable (le temps du backfill) → fallback sur l'id. const seminarSlug = seminar.slug ? slug(seminar.slug) : `seminar-${seminar.id}`; return `${appRoot}/seminars/${seminarSlug || `seminar-${seminar.id}`}`; } /* ── Destination événement ── */ if (destination.kind === "event") { const event = await prisma.event.findUnique({ where: { id: destination.eventId }, select: { id: true, slug: true }, }); if (!event) { throw new TRPCError({ code: "NOT_FOUND", message: "Événement introuvable." }); } // `Event.slug` est nullable (le temps du backfill) → fallback sur l'id, // qui reste stable et unique. const eventSlug = event.slug ? slug(event.slug) : `event-${event.id}`; return `${appRoot}/events/${eventSlug || `event-${event.id}`}`; } /* ── Destination personnelle de l'admin (dérivée de userId) ── */ if (destination.kind === "perso") { const base = await resolvePersoBaseFolder({ prisma, appRoot, userId }); // Les photos vivent dans le sous-dossier `photos/` de l'espace perso, pour // une structure homogène avec les futures zones R2 (documents/, audio/). return `${base}/photos`; } /* ── Destination espace d'un groupe collaboratif (dérivée du groupe) ── */ if (destination.kind === "group") { // resolveGroupBaseFolder valide l'existence + le caractère collaboratif. return resolveGroupBaseFolder({ prisma, appRoot, groupId: destination.groupId }); } /* ── Destinations couplées à une discipline (historique) ── */ const category = await prisma.category.findUnique({ where: { id: destination.categoryId }, select: { id: true, type: true, storageSegment: true }, }); if (!category) { throw new TRPCError({ code: "NOT_FOUND", message: "Catégorie introuvable." }); } const categorySeg = categorySegment(category); if (destination.kind === "existing-discipline") { const discipline = await prisma.discipline.findUnique({ where: { id: destination.disciplineId }, select: { id: true, categoryId: true, name: true, storageSegment: true }, }); if (!discipline) { throw new TRPCError({ code: "NOT_FOUND", message: "Discipline introuvable." }); } if (discipline.categoryId !== destination.categoryId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Cette discipline n'appartient pas à la catégorie choisie." }); } return `${appRoot}/${categorySeg}/${disciplineSegment(discipline)}`; } // kind === "new-discipline" const proposedSlug = slug(destination.proposedDisciplineName); if (!proposedSlug) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom proposé doit contenir au moins une lettre ou un chiffre." }); } return `${appRoot}/${categorySeg}/new/${proposedSlug}`;}Dans tous les cas, le chemin ne vient pas du client : il est dérivé de la base et de la session, après relecture. Le client peut envoyer n’importe quel disciplineId ; s’il n’existe pas, ou n’appartient pas à la catégorie annoncée, la procédure échoue avant d’avoir signé quoi que ce soit.
Le nom resolvePendingUploadFolder est historique. Au temps de Cloudinary, tout envoi atterrissait sous un dossier pending/, et la validation déplaçait le fichier vers published/. Aujourd’hui, le statut vit dans une colonne () et le dossier rendu ici est l’emplacement définitif du fichier : valider ne déplace plus rien.
Un espace personnel qui survit à un changement de nom#
Le dossier personnel porte le nom de son propriétaire, pour rester lisible dans le finder : AKFC/personal-space/stephane-bazze-c…/photos. Le suffixe -<userId> le rend unique. Mais un nom change : un membre corrige son prénom dans son profil. Si le dossier était recalculé à chaque envoi, ce membre se verrait ouvrir un second espace vide, et ses photos déjà déposées disparaîtraient de sa vue.
Le résolveur réutilise donc un espace existant avant d’en nommer un nouveau :
const container = `${appRoot}/personal-space/`; // Espace déjà ouvert : son segment est l'enfant DIRECT du conteneur qui finit par `-${userId}`. const spaceOf = (fullPath: string): string | null => { const segment = fullPath.slice(container.length).split("/")[0]; return segment.endsWith(`-${userId}`) ? `${container}${segment}` : null; }; const folder = await prisma.folder.findFirst({ where: { appRoot, fullPath: { startsWith: container, endsWith: `-${userId}` } }, orderBy: { createdAt: "asc" }, select: { fullPath: true }, }); const fromFolder = folder && !folder.fullPath.slice(container.length).includes("/") ? spaceOf(folder.fullPath) : null; if (fromFolder) return fromFolder; const asset = await prisma.mediaAsset.findFirst({ where: { appRoot, fullPath: { startsWith: container, contains: `-${userId}/` } }, orderBy: { createdAt: "asc" }, select: { fullPath: true }, }); const fromAsset = asset ? spaceOf(asset.fullPath) : null; if (fromAsset) return fromAsset;C’est la même règle que pour les disciplines, sous une autre forme : un nom d’affichage ne doit jamais être une clé. Le slug du nom ne sert qu’une fois, à la création ; ensuite, c’est l’identifiant, stable, qui retrouve l’espace.
Un nom de fichier sûr#
Le nom du fichier, lui, vient bien du client : c’est le nom d’origine, celui que le membre a choisi sur son disque. Il passe par une seule fonction, côté serveur :
export function buildUploadFileName(originalFileName: string): string { const dotIdx = originalFileName.lastIndexOf('.'); const hasExt = dotIdx > 0 && dotIdx < originalFileName.length - 1; const baseName = hasExt ? originalFileName.slice(0, dotIdx) : originalFileName; const ext = hasExt ? originalFileName.slice(dotIdx).toLowerCase() : ''; // Underscores et séparateurs → tirets AVANT slugify (sinon strict les // SUPPRIME et colle les tokens : baccalaureat_stephane → baccalaureatstephane). const safeBase = slugify(baseName.replace(/_+/g, ' '), { lower: true, strict: true }) || 'fichier'; return `${safeBase}${ext}`;}Salut final (2025).JPG devient salut-final-2025.jpg. La clé ne contient ni espace, ni parenthèse, ni accent : elle se lit dans une URL sans encodage hasardeux. Le nom d’origine n’est pas perdu : il est conservé dans , et c’est lui (ou un nom d’affichage choisi plus tard) que l’interface montre.
Cette fonction vivait autrefois dans le navigateur, avec un slugify maison. Il divergeait du paquet npm sur & et % : une discipline « Arts & Combat » produisait deux dossiers, l’un pour ses photos (chemin calculé par le serveur), l’autre pour ses PDF (chemin calculé par le client). Une seule fonction, côté serveur, c’est zéro écart possible.
Les contrôles avant de signer#
Nous savons maintenant où le fichier ira. Avant de signer, la procédure vérifie encore que cet envoi-là est permis.
Le droit de déposer#
export async function assertUploadDestinationAllowed(params: { prisma: PrismaClient; userId: string; isAdmin: boolean; destination: UploadDestination;}): Promise<void> { const { prisma, userId, isAdmin, destination } = params; if (destination.kind === "event" && destination.disciplineIds.length > 0 && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "Seul un administrateur peut rattacher des disciplines à un événement.", }); } // `group` : droit d'écriture sur l'espace. Toutes les autres destinations // (entités discipline/stage/event, common_repository, perso) sont ouvertes // à TOUT UTILISATEUR CONNECTÉ pour l'ÉCRITURE : un membre peut déposer, le // contenu part `pending`, et la LECTURE reste admin (assertCanReadPath). if (destination.kind === "group") { await assertCanWriteGroupSpace({ prisma, userId, groupId: destination.groupId }); }}Deux règles seulement, parce que la troisième — personne ne publie sans un administrateur — est portée par le statut pending :
- l’espace d’un groupe exige le droit d’écriture sur ce groupe (rôle
EDITOR, hiérarchie comprise ; un administrateur passe). C’est la seule destination où un fichier est lu par d’autres sans validation : les membres du groupe le voient dès son arrivée ; - rattacher des disciplines à un événement modifie la fiche publique de l’événement, sans validation. C’est réservé aux administrateurs, et vérifié dès la signature : refusé plus tard, à l’enregistrement, le fichier serait déjà parti au stockage pour rien.
Le format#
export function uploadTypeError(fileName: string, mimeType: string): string | null { const dot = fileName.lastIndexOf("."); const ext = dot > 0 ? fileName.slice(dot + 1).toLowerCase() : ""; const mimes = ALLOWED_UPLOAD_TYPES[ext]; if (!mimes) { return `Format non accepté${ext ? ` (.${ext})` : ""}. Formats acceptés : ${ALLOWED_UPLOAD_EXTENSIONS.join(", ")}.`; } const mime = mimeType.trim().toLowerCase().split(";")[0]; if (mime !== "application/octet-stream" && !mimes.includes(mime)) { return `Le contenu de « ${fileName} » ne correspond pas à son extension .${ext}.`; } return null;}Une liste de formats autorisés (images, vidéos, audio, documents bureautiques, PDF, texte, archives zip et rar), et non d’interdits : un format imprévu est refusé par défaut. Aucun format qu’un navigateur pourrait exécuter ou afficher comme une page — HTML, SVG, XML, scripts — n’y figure. L’extension du nom et le type annoncé doivent concorder : un « planning.pdf » annoncé text/html est refusé.
Une exception : le type application/octet-stream, « suite d’octets sans type connu ». Certains navigateurs l’emploient (ou ne donnent aucun type) pour des formats qu’ils ne reconnaissent pas, comme .rar. Il est accepté pour une extension autorisée : un fichier servi avec ce type est téléchargé, jamais interprété, ce qui ne laisse aucune prise à un contenu actif.
Pas d’écrasement silencieux#
Deux fichiers de même nom dans la même destination donnent la même clé. Envoyer le second remplacerait le premier dans le stockage. La procédure refuse donc par défaut (CONFLICT), et n’écrase que sur confirmation explicite (allowOverwrite).
Mais confirmer ne suffit pas. Écraser, c’est remplacer le contenu d’un fichier sans repasser par la validation : la ligne garde son statut. Dans une destination ouverte à tous, un membre pourrait sinon remplacer une photo publiée, posée sur la page d’accueil, par n’importe quelle image de son choix. D’où une seconde règle, lue dans la procédure ci-dessous : n’écrase qu’un administrateur, un éditeur dans l’espace de son groupe, ou l’auteur d’un fichier encore en attente.
Remplacer, c’est publier sans validation. Toute procédure qui écrase un contenu doit vérifier le droit sur ce qui est écrasé, pas seulement sur l’endroit où l’on écrit.
Pour poser la question une seule fois pour tout un lot, le formulaire appelle d’abord storage.checkR2Conflicts :
checkR2Conflicts: protectedProcedure .input(z.object({ destination: uploadDestinationSchema, fileNames: z.array(z.string().min(1).max(255)).max(100) })) .query(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination }); const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Deux fichiers du même lot peuvent donner la même clé (« Photo 1.JPG » et « photo-1.jpg ») : // envoyés en parallèle, le second écraserait le premier sans que personne ne l'ait demandé. // On les signale par leur position dans le lot (deux noms identiques ne se distinguent pas). const byPath = new Map<string, string>(); const duplicateIndexes: number[] = []; input.fileNames.forEach((name, index) => { const path = `${folder}/${buildUploadFileName(name)}`; if (byPath.has(path)) duplicateIndexes.push(index); else byPath.set(path, name); }); const found = await ctx.prisma.mediaAsset.findMany({ where: { fullPath: { in: [...byPath.keys()] } }, select: { fullPath: true } }); return { existing: found.map((f) => byPath.get(f.fullPath)).filter((name): name is string => Boolean(name)), duplicateIndexes, }; }),La procédure répond deux choses. Les noms déjà présents à cet endroit, pour demander s’il faut les remplacer. Et les fichiers du lot qui se percutent entre eux : « Photo 1.JPG » et « photo-1.jpg » donnent la même clé ; envoyés en parallèle, le second écraserait le premier sans que personne ne l’ait demandé, et aucun des deux n’existait encore en base pour le signaler. Ils sont repérés par leur position dans le lot, puisque deux noms identiques ne se distinguent pas autrement.
Le quota de l’espace personnel#
L’espace personnel n’accepte que des images, dans la limite d’un quota compté en base (countPersoImages). Un remplacement ne compte pas : il ne crée pas de fichier.
La procédure qui signe#
Voici createR2Upload en entier ; tout ce qui précède s’y lit dans l’ordre.
createR2Upload — vérifier, nommer, signer
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { z } from "zod";import { assertOutsideAvatars, avatarsPrefix, outsideBin } from "@backend/modules/storage/mediaPaths";import { TRPCError } from "@trpc/server";/** * Ce qui compte comme extension en fin de nom : 1 à 8 caractères * alphanumériques. Exclut les points internes d'un libellé (« (ep. BAZZE) »). */const EXTENSION_PATTERN = /\.[A-Za-z0-9]{1,8}$/;/** * La corbeille est une FEUILLE dans l'arbre du finder : on n'expose jamais son * sous-arbre physique `.trash/<uuid>/…`. On elague recursivement les enfants du * noeud dont le chemin est exactement `binPath`. (La vue plate lit trash.listBin, * le drill-down passe par `list` — aucun ne depend de getTree.) *//** * La corbeille (`<racine>/bin/…`) n'appartient qu'au routeur `trash` : ni renommage ni déplacement * n'y entre ou n'en sort par `storage`. Restaurer et vider ont leurs propres gardes (TrashEntry). */function assertOutsideBin(appRoot: string, paths: readonly string[]): void { const bin = `${appRoot}/bin`; if (paths.some((p) => p === bin || p.startsWith(`${bin}/`))) { throw new TRPCError({ code: "FORBIDDEN", message: "La corbeille se gère depuis sa propre vue (restaurer, supprimer).", }); }}function stripBinSubtree(node: StorageFolderNode, binPath: string): void { for (const child of node.children ?? []) { if (child.type !== "folder") continue; if (child.path === binPath) { child.children = []; child.hasChildren = false; } else { stripBinSubtree(child, binPath); } }}import { enrichFilesWithStatus, enrichTreeWithStatus,} from "@backend/modules/storage/services/enrichStatus.service";import { router, protectedProcedure } from "@backend/trpc";import { isAdmin } from "@backend/trpc/middleware";import { storageProviderSchema, storageMoveIntentSchema, createR2UploadAuthorizationSchema, type StorageFolderNode,} from "@contracts/storage";import { uploadDestinationSchema, type UploadDestination } from "@contracts/storage/upload-destination.schema";import { uploadTypeError } from "@contracts/storage/allowedUploadTypes";import { getAdapter } from "@backend/modules/storage/providerRegistry";import { VirtualStorage } from "@backend/modules/storage/virtualStorage";import { isProtectedEntityFolderPath } from "@backend/modules/storage/protectedEntityFolder";import { isRootFolder } from "@backend/modules/storage/services/ensureRootFolders.service";import { planMoveOperations, executeMoveOperations,} from "@backend/modules/storage/resolveMoveIntent.service";import { countPersoImages } from "@backend/modules/media/services/countPersoImages.service";import { PERSO_PHOTO_QUOTA } from "@backend/modules/media/services/persoPhotoQuota.constants";import { listCommonRepositoryFolders } from "@backend/modules/media/services/listCommonRepositoryFolders.service";import { listMyCommonRepositoryContainers } from "@backend/modules/media/services/listMyCommonRepositoryContainers.service";import { commonRepositoryContainerPath } from "@backend/modules/media/services/commonRepositoryContainerPath.service";import { resolvePendingUploadFolder } from '@backend/modules/storage/services/resolvePendingUploadFolder.service';import { assertCanReadPath } from "@backend/modules/memberGroups/assertCanReadPath.service";import { assertCanWritePaths } from "@backend/modules/memberGroups/assertCanWritePaths.service";import { resolveGroupBaseFolder } from "@backend/modules/media/services/resolveGroupBaseFolder.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { scopePersoSpaceInTree } from "@backend/modules/storage/scopePersoSpace.service";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { mergeGroupSpaceFolders, mergeGroupSpaceFoldersIntoTree,} from "@backend/modules/storage/mergeGroupSpaceFolders.service";import { ensureContentRootsInTree, missingContentRootFolders, requiredRootPaths,} from "@backend/modules/storage/ensureContentRoots.service";import { applyGroupSpaceNamesToFolders, applyGroupSpaceNamesToTree,} from "@backend/modules/storage/applyGroupSpaceNames.service";import { assertUploadDestinationAllowed } from '@backend/modules/storage/assertUploadDestinationAllowed.service';import { buildUploadFileName } from '@backend/modules/storage/services/buildUploadFileName.service';/** * storageRouter — finder (lecture, renommage, déplacement) et envois vers R2. *//* -------------------------------------------------------------------------- *//* Schema R2 Phase 2 — inline *//* -------------------------------------------------------------------------- *//** * Destination metier — discriminée pour gérer les deux cas : * - existing-discipline : on a categoryId + disciplineId direct * - new-discipline : on a categoryId + proposedDisciplineName (admin validera plus tard) * * Forme identique à ce que `DragNDropForm` construit côté frontend. *//** * Remplacer un fichier déjà connu, c'est publier un contenu SANS repasser par la validation : la * ligne garde son statut. Un dépôt ouvert à tout membre (discipline, stage, événement, dépôt commun) * ne doit donc pas permettre de remplacer un fichier publié, ou déposé par quelqu'un d'autre. * Autorisé : un administrateur ; un éditeur dans l'espace de son groupe (il peut déjà y mettre à la * corbeille ; son droit d'écriture est vérifié par assertUploadDestinationAllowed) ; l'auteur d'un * fichier encore en attente. Vérifié à la signature ET à l'enregistrement : l'enregistrement met à * jour la ligne existante, auteur compris. */function mayReplace( existing: { status: string; uploaderUserId: string }, user: { id: string; isAdmin: boolean }, destination: UploadDestination,): boolean { return ( user.isAdmin || destination.kind === "group" || (existing.status === "pending" && existing.uploaderUserId === user.id) );}const registerR2UploadInputSchema = z.object({ path: z.string().min(1), expectedBytes: z.number().int().positive(), expectedMimeType: z.string().min(1), // Phase 2 — nouveaux champs requis pour créer la row MediaAsset destination: uploadDestinationSchema, originalFileName: z.string().min(1).max(255),});/* -------------------------------------------------------------------------- *//* Router *//* -------------------------------------------------------------------------- */export const storageRouter = router({ /* ====================================================================== */ /* Lecture (inchangé) */ /* ====================================================================== */ /** * Compteurs « à traiter » de la bibliothèque, pour la cloche du header : * assets en attente de classement (MediaAsset.status "pending") et * entrées de corbeille (TrashEntry IN_BIN). Réservée aux administrateurs : ce sont des * compteurs de toute la bibliothèque, et la cloche qui les affiche n'existe que pour eux. */ getAttentionCounts: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const [pending, bin, commonRepositoryPending, persoCounts] = await Promise.all([ // Hors corbeille : un fichier jeté garde sa ligne (pour sa restauration), pas son tour. ctx.prisma.mediaAsset.count({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) } }), ctx.prisma.trashEntry.count({ where: { status: "IN_BIN" } }), ctx.prisma.mediaAsset.count({ where: { status: "pending", appRoot: ctx.appRoot, fullPath: { startsWith: `${ctx.appRoot}/common-repository/` }, }, }), countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }), ]); return { pending, bin, commonRepositoryPending, persoPending: persoCounts.pending, }; }), /** * Ventilation des contenus « en attente » par dossier parent. * * Renvoie le `kind` du dossier plutôt qu'un libellé : la formulation * (« le stockage général », « votre stockage personnel »…) appartient à * l'UI, pas au backend. Les dossiers sont triés par volume décroissant. * Réservée aux administrateurs : elle nomme les dossiers de toute la bibliothèque, * espaces personnels compris. */ getPendingBreakdown: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { const rows = await ctx.prisma.mediaAsset.findMany({ where: { appRoot: ctx.appRoot, status: "pending", ...outsideBin(ctx.appRoot) }, select: { fullPath: true }, }); const counts = new Map<string, number>(); for (const row of rows) { const segments = row.fullPath.split("/"); segments.pop(); // le nom du fichier const folder = segments.join("/"); if (!folder) continue; counts.set(folder, (counts.get(folder) ?? 0) + 1); } const commonRepositoryRoot = `${ctx.appRoot}/common-repository`; const persosRoot = `${ctx.appRoot}/personal-space`; const entries = Array.from(counts.entries()).map(([path, count]) => { let kind: "common_repository" | "perso" | "folder" = "folder"; if (path === commonRepositoryRoot || path.startsWith(`${commonRepositoryRoot}/`)) { kind = "common_repository"; } else if (path.startsWith(`${persosRoot}/`)) { // Personnel de l'utilisateur courant uniquement : le dossier porte // son id. Ceux des autres restent des dossiers ordinaires. kind = path.includes(ctx.sessionClient.user.id) ? "perso" : "folder"; } // Nom lisible : pour le dossier personnel d'un AUTRE utilisateur, le // dernier segment vaut « photos » et n'apprend rien — on prend le // segment qui identifie la personne. let name = path.split("/").pop() ?? path; if (kind === "folder" && path.startsWith(`${persosRoot}/`)) { name = path.slice(persosRoot.length + 1).split("/")[0] ?? name; } return { path, count, kind, name }; }); entries.sort((a, b) => b.count - a.count); return { total: rows.length, entries }; }), /** * Statut du quota d'images de l'espace perso de l'admin courant (lecture * seule). Le dossier perso est dérivé de `ctx.sessionClient.user.id`, jamais d'un input. */ getPersoPhotoQuota: protectedProcedure.query(async ({ ctx }) => { const counts = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const remaining = Math.max(0, PERSO_PHOTO_QUOTA - counts.total); return { quota: PERSO_PHOTO_QUOTA, pending: counts.pending, published: counts.published, total: counts.total, remaining, }; }), /** * Sous-dossiers existants sous `general/` (pending + published), pour peupler * le select « dossier existant » de l'uploader général. */ listCommonRepositoryFolders: protectedProcedure.query(async ({ ctx }) => { return listCommonRepositoryFolders({ prisma: ctx.prisma, appRoot: ctx.appRoot }); }), // Sujets des dépôts de l'utilisateur courant (page membre) — scopé user. listMyCommonRepositoryContainers: protectedProcedure.query(async ({ ctx }) => { return listMyCommonRepositoryContainers({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); }), // Pose le libellé humain d'un conteneur de dépôt (appelé par le front après // un dépôt réussi). Le path est dérivé de ctx.sessionClient.user.id → un membre ne peut // labelliser QUE ses propres conteneurs. setCommonRepositoryLabel: protectedProcedure .input(z.object({ subject: z.string().trim().min(1).max(120), label: z.string().trim().min(1).max(200) })) .mutation(async ({ ctx, input }) => { const path = await commonRepositoryContainerPath({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, subject: input.subject, }); await ctx.prisma.folderLabel.upsert({ where: { path }, update: { displayName: input.label }, create: { path, displayName: input.label }, }); return { ok: true }; }), /** * Hiérarchie des espaces collaboratifs : chemin d'espace RÉEL + parent, * pour l'imbrication visuelle du finder admin (les chemins physiques * restent inchangés). Réservé aux admins. */ groupSpaceHierarchy: protectedProcedure.query(async ({ ctx }) => { if (!ctx.sessionClient.user.isAdmin) return []; const groups = await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }); return Promise.all( groups.map(async (g) => ({ groupId: g.id, name: g.name, parentGroupId: g.parentGroupId, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: g.id, }), })), ); }), /** * Espaces des groupes collaboratifs accessibles à l'utilisateur courant * (racines du finder côté membre) : chemin + droit par groupe. * * Passerelle admin : un administrateur est d'emblée ÉDITEUR de TOUS les * espaces collaboratifs (au-dessus du système de groupes), sans * appartenance manuelle. */ myCollaborativeSpaces: protectedProcedure.query(async ({ ctx }) => { const isAdmin = ctx.sessionClient.user.isAdmin; const entries: { groupId: string; name: string; access: "VIEWER" | "EDITOR"; parentGroupId: string | null; }[] = isAdmin ? ( await ctx.prisma.memberGroup.findMany({ where: { isCollaborative: true }, select: { id: true, name: true, parentGroupId: true }, }) ).map((g) => ({ groupId: g.id, name: g.name, access: "EDITOR" as const, parentGroupId: g.parentGroupId })) : await collaborativeEntriesForMember(ctx.prisma, ctx.sessionClient.user.id); return Promise.all( entries.map(async (e) => ({ ...e, path: await resolveGroupBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, groupId: e.groupId, }), })), ); }), list: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), cursor: z.string().optional(), limit: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; const listed = await reader.list({ path: input.path, cursor: input.cursor, limit: input.limit, }); // Les avatars ne sont pas des médias de la bibliothèque : leur dossier n'est jamais listé. const result = { ...listed, folders: listed.folders.filter((f) => f.path !== avatarsPrefix(ctx.appRoot)), }; // Le statut vit en DB depuis l'aplatissement : on le rapatrie ici. await enrichFilesWithStatus(ctx.prisma, ctx.appRoot, result.files); // Espaces de groupe visibles même vides : R2 n'a pas de // vrais dossiers, un espace sans asset s'évaporerait du listing. if (input.path === `${ctx.appRoot}/collaborative-group-spaces`) { const merged = await mergeGroupSpaceFolders({ result, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); return { ...merged, folders: await applyGroupSpaceNamesToFolders( merged.folders, ctx.prisma, ), }; } // Racine du finder : racines principales visibles même vides (grille). if (input.path === ctx.appRoot) { const missing = await missingContentRootFolders( result.folders.map((f) => f.path), ctx.prisma, ctx.appRoot, ); return { ...result, folders: await applyGroupSpaceNamesToFolders( [...result.folders, ...missing], ctx.prisma, ), }; } return result; }), getTree: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), depth: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; // Perso : quand la grille demande le conteneur personal-space // (adapter.list = getTree depth1), on lit en réalité le sous-espace de // l'utilisateur courant → il ne voit QUE son contenu (privé + illusion). const persoContainer = `${ctx.appRoot}/personal-space`; let readPath = input.path; if (input.path === persoContainer) { try { readPath = await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); } catch { readPath = input.path; } } const result = await reader.getTree({ path: readPath, depth: input.depth, }); // Corbeille = feuille : jamais de `.trash` dans l'arbre du finder. stripBinSubtree(result.root, `${ctx.appRoot}/bin`); // Avatars : hors de la bibliothèque, leur dossier (enfant direct de la racine) n'apparaît pas. if (result.root.children) { result.root.children = result.root.children.filter((c) => c.path !== avatarsPrefix(ctx.appRoot)); } await enrichTreeWithStatus(ctx.prisma, ctx.appRoot, result.root); // Espaces de groupe ET perso visibles même vides, dans l'arbre du finder. result.root = await mergeGroupSpaceFoldersIntoTree({ root: result.root, prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Racines principales visibles même vides (courses/seminars/events/ // personal-space/collaborative-group-spaces/common-repository). // Uniquement à la RACINE : la grille fait adapter.list = getTree // depth1 sur CHAQUE dossier ; sans cette garde on injecterait les // racines de contenu dans tous les dossiers. if (input.path === ctx.appRoot) { result.root = await ensureContentRootsInTree( result.root, ctx.prisma, ctx.appRoot, ); } // Perso : dans l'arbre racine, aplatir personal-space sur le contenu du // sous-espace de l'utilisateur courant (masque les autres admins). if (input.path === ctx.appRoot) { result.root = scopePersoSpaceInTree( result.root, ctx.appRoot, ctx.sessionClient.user.id, ); } // Nom EXACT des dossiers d'espace de groupe (accents) — c'est getTree // que lit l'adapter du finder (et le picker). return { ...result, root: await applyGroupSpaceNamesToTree(result.root, ctx.prisma), }; }), // Libellés d'affichage des dossiers (découplés du chemin physique). folderLabels: protectedProcedure.query(({ ctx }) => ctx.prisma.folderLabel.findMany({ select: { path: true, displayName: true }, }), ), // Racines de contenu (label-only) : le front y route « Renommer » vers // l'édition du FolderLabel plutôt que le rename physique. contentRootPaths: protectedProcedure.query(({ ctx }) => requiredRootPaths(ctx.prisma, ctx.appRoot), ), setFolderLabel: protectedProcedure .input( z.object({ path: z.string().min(1), displayName: z.string().trim().max(255), }), ) .mutation(async ({ ctx, input }) => { // Un libellé change ce que TOUT LE MONDE voit : même droit qu'un renommage. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideAvatars(ctx.appRoot, [input.path]); const name = input.displayName.trim(); // Libellé vide = on retire l'override (retour au repli du listing). if (name === "") { await ctx.prisma.folderLabel.deleteMany({ where: { path: input.path } }); return { path: input.path, displayName: null as string | null }; } const row = await ctx.prisma.folderLabel.upsert({ where: { path: input.path }, create: { path: input.path, displayName: name }, update: { displayName: name }, }); return { path: row.path, displayName: row.displayName as string | null }; }), getNode: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getNode) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getNode().` ); } return reader.getNode(input.path); }), getMetadata: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), }) ) .query(async ({ ctx, input }) => { await assertCanReadPath({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, path: input.path, }); assertOutsideAvatars(ctx.appRoot, [input.path]); const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const backend = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const reader = backend; if (!reader.getMetadata) { throw new Error( `Provider "${input.provider ?? "virtual"}" does not support getMetadata().` ); } return reader.getMetadata(input.path); }), /** * Renomme un fichier ou un dossier — c'est-à-dire un `move` vers le même * parent sous un nouveau nom. * * `newBaseName` est le nom SANS extension : le client n'édite que la base * (l'extension affichée est reconstruite par `displayName`). L'extension de * la source est réappliquée telle quelle. */ rename: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), path: z.string().min(1), type: z.enum(["file", "folder"]).default("file"), newBaseName: z.string().min(1).max(255), }) ) .mutation(async ({ ctx, input }) => { // Écriture : un administrateur partout, un éditeur dans l'espace de son groupe. await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [input.path] }); assertOutsideBin(ctx.appRoot, [input.path]); assertOutsideAvatars(ctx.appRoot, [input.path]); const cleanName = input.newBaseName.trim(); if (!cleanName) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas être vide.", }); } if (cleanName.includes("/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « / ».", }); } // « . » et « .. » feraient un chemin que toutes les gardes refusent (`includes("..")`) : le // dossier renommé deviendrait illisible pour tout le monde. if (cleanName === "." || cleanName.includes("..")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le nom ne peut pas contenir « .. ».", }); } const segments = input.path.split("/"); const currentName = segments.pop() ?? ""; const parent = segments.join("/"); if (!parent) { throw new TRPCError({ code: "BAD_REQUEST", message: "Impossible de renommer un élément racine.", }); } // Dossier-entité : nom PHYSIQUE immuable. Sa garde de suppression // repose sur son chemin ; le renommer (déplacement du binaire) // casserait cette protection. Refusé (un libellé d'affichage passera // par un mécanisme séparé). if (input.type === "folder" && isProtectedEntityFolderPath(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être renommé.", }); } // Racine de contenu (catégorie / stages / events / espaces / dépôt) : // nom PHYSIQUE immuable aussi. Son « Renommer » édite le FolderLabel // côté client ; un rename physique casserait le lien catégorie↔dossier // et les gardes. Refusé ici (défense en profondeur). if (input.type === "folder") { const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if (roots.includes(input.path)) { throw new TRPCError({ code: "FORBIDDEN", message: "Cette racine ne se renomme pas physiquement (libellé éditable).", }); } } // ─── FICHIER : on n'édite QUE le nom d'affichage ─────────────────── // // Depuis la slugification (increment 1), la clé de stockage est un slug // stable et opaque. Renommer un fichier ne déplace donc plus rien chez // le provider et ne devine plus d'extension (fin du bug historique) : // c'est un simple UPDATE de `displayName`, par le chemin exact. Deux fichiers peuvent // partager un nom d'affichage : pas de contrôle de collision ici, la clé reste unique. if (input.type === "file") { const result = await ctx.prisma.mediaAsset.updateMany({ where: { appRoot: ctx.appRoot, fullPath: input.path }, data: { displayName: cleanName }, }); if (result.count === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Aucun média trouvé pour ce fichier.", }); } return { success: true, path: input.path }; } // Extension de la SOURCE, réappliquée telle quelle. // // ⚠️ Un point dans un nom n'est PAS forcément un séparateur // d'extension : « CNI recto PORQUET (ep. BAZZE) Yvonne » en contient // un. Couper au dernier point produirait une pseudo-extension // « . BAZZE) Yvonne » et mutilerait le fichier au renommage. On // n'accepte donc qu'un suffixe AYANT LA FORME d'une extension. // Un DOSSIER n'a pas d'extension : « photos.2024 » renommé en // « archives » doit donner « archives », pas « archives.2024 ». const extensionMatch = input.type === "folder" ? null : EXTENSION_PATTERN.exec(currentName); const extension = extensionMatch ? extensionMatch[0] : ""; const targetPath = `${parent}/${cleanName}${extension}`; // Renommage en son propre nom : rien à faire, mais pas une erreur. if (targetPath === input.path) { return { success: true, path: input.path }; } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); // On n'écrase jamais un élément existant : un objet (ou un dossier non vide) chez le stockage, // une ligne MediaAsset, un dossier enregistré (même contrôle que storage.move). const [collision, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(targetPath) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: targetPath }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: targetPath }, select: { id: true } }), ]); if (collision || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: "Un élément porte déjà ce nom dans ce dossier.", }); } // `move` est optionnel sur l'interface d'adapter : on garde avant // d'appeler, comme pour `getNode` plus haut. if (!adapter.move) { throw new TRPCError({ code: "NOT_IMPLEMENTED", message: "Ce provider ne supporte pas le déplacement.", }); } await adapter.move({ source: { type: input.type, path: input.path }, target: { path: targetPath }, }); return { success: true, path: targetPath }; }), move: protectedProcedure .input( z.object({ provider: storageProviderSchema.optional(), intent: storageMoveIntentSchema, }) ) .mutation(async ({ ctx, input }) => { // Écriture : la SOURCE et la CIBLE. Un éditeur ne sort rien de l'espace de son groupe, et // n'y fait rien entrer qui vienne d'ailleurs. { const source = input.intent.source; const sourcePaths = source.type === "selection" ? source.roots : [source.path]; const targetPaths = [input.intent.target.path]; await assertCanWritePaths({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, paths: [...sourcePaths, ...targetPaths], }); assertOutsideBin(ctx.appRoot, [...sourcePaths, ...targetPaths]); assertOutsideAvatars(ctx.appRoot, [...sourcePaths, ...targetPaths]); } // Dossier-entité : chemin immuable, pas de déplacement du binaire. // Même raison qu'au rename ; couvre aussi les sélections multiples. { const movedPaths = input.intent.source.type === "folder" ? [input.intent.source.path] : input.intent.source.type === "selection" ? input.intent.source.roots : []; // Les racines de contenu (cours, stages, événements, espaces…) et la corbeille non plus : // leur chemin est connu du code (destinations d'envoi, gardes, libellés). const roots = await requiredRootPaths(ctx.prisma, ctx.appRoot); if ( movedPaths.some( (pth) => isProtectedEntityFolderPath(pth) || roots.includes(pth) || isRootFolder(ctx.appRoot, pth), ) ) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce dossier système ne peut pas être déplacé.", }); } } const deps = { prisma: ctx.prisma, appRoot: ctx.appRoot }; const adapter = input.provider ? getAdapter(input.provider, deps) : new VirtualStorage(deps); const planned = await planMoveOperations({ adapter, appRoot: ctx.appRoot, intent: input.intent }); // Une opération sur place (déposer un élément dans son propre dossier) n'a rien à faire : // l'adapter renommerait un objet sur lui-même, le stockage refuserait, et tout le geste échouerait. const effectiveOperations = planned.filter( (operation) => operation.source.path !== operation.target.path, ); // Rien ne s'écrase en silence : une copie S3 remplace l'objet qui porte déjà la clé cible, et // la ligne MediaAsset déplacée heurterait la contrainte d'unicité APRÈS la copie — le fichier // d'arrivée serait perdu. On vérifie donc chaque cible avant la moindre écriture : un objet // (ou un dossier non vide) chez le stockage, une ligne MediaAsset, un dossier enregistré. const targets = new Set<string>(); for (const operation of effectiveOperations) { const target = operation.target.path; // Deux éléments de la même sélection qui arriveraient au même nom. if (targets.has(target)) { throw new TRPCError({ code: "CONFLICT", message: `Deux éléments de la sélection s'appellent « ${target.slice(target.lastIndexOf("/") + 1)} ».`, }); } targets.add(target); const [node, asset, folder] = await Promise.all([ adapter.getNode ? adapter.getNode(target) : Promise.resolve(null), ctx.prisma.mediaAsset.findUnique({ where: { fullPath: target }, select: { id: true } }), ctx.prisma.folder.findFirst({ where: { appRoot: ctx.appRoot, fullPath: target }, select: { id: true } }), ]); if (node || asset || folder) { throw new TRPCError({ code: "CONFLICT", message: `« ${target.slice(target.lastIndexOf("/") + 1)} » existe déjà dans le dossier de destination.`, }); } } // Exécution séquentielle : des opérations concurrentes sur des // préfixes voisins pourraient se marcher dessus. await executeMoveOperations(adapter, effectiveOperations); return { operations: effectiveOperations }; }), /* ====================================================================== */ /* Upload R2 — Phase 2 (enrichi avec destination + originalFileName) */ /* ====================================================================== */ /** * Noms de fichiers qui existent DÉJÀ dans la destination (pour demander avant d'écraser). */ checkR2Conflicts: protectedProcedure .input(z.object({ destination: uploadDestinationSchema, fileNames: z.array(z.string().min(1).max(255)).max(100) })) .query(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination }); const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); // Deux fichiers du même lot peuvent donner la même clé (« Photo 1.JPG » et « photo-1.jpg ») : // envoyés en parallèle, le second écraserait le premier sans que personne ne l'ait demandé. // On les signale par leur position dans le lot (deux noms identiques ne se distinguent pas). const byPath = new Map<string, string>(); const duplicateIndexes: number[] = []; input.fileNames.forEach((name, index) => { const path = `${folder}/${buildUploadFileName(name)}`; if (byPath.has(path)) duplicateIndexes.push(index); else byPath.set(path, name); }); const found = await ctx.prisma.mediaAsset.findMany({ where: { fullPath: { in: [...byPath.keys()] } }, select: { fullPath: true } }); return { existing: found.map((f) => byPath.get(f.fullPath)).filter((name): name is string => Boolean(name)), duplicateIndexes, }; }), createR2Upload: protectedProcedure .input(createR2UploadAuthorizationSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin se calcule ICI, une seule fois (jamais côté client). const folder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const fileName = buildUploadFileName(input.originalFileName); const path = `${folder}/${fileName}`; // Formats autorisés seulement (extension ET type déclaré) : jamais de HTML, SVG ou // script, qu'un navigateur pourrait exécuter s'il était affiché. Le type est signé dans // l'URL d'envoi, puis revérifié par registerR2Upload sur l'objet réellement stocké. const typeError = uploadTypeError(input.originalFileName, input.mimeType); if (typeError) { throw new TRPCError({ code: "BAD_REQUEST", message: typeError }); } if (input.destination.kind === "perso" && !input.mimeType.startsWith("image/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier perso n'accepte que des images." }); } // Pas d'écrasement silencieux : confirmation explicite requise. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: path }, select: { id: true, status: true, uploaderUserId: true }, }); if (existing && !input.allowOverwrite) { throw new TRPCError({ code: "CONFLICT", message: `Un fichier « ${fileName} » existe déjà à cet endroit.` }); } // Confirmer ne suffit pas : remplacer, c'est publier sans validation (mayReplace). if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: `« ${fileName} » existe déjà et ne peut être remplacé que par un administrateur. Renommez votre fichier.`, }); } if (input.destination.kind === "perso" && !existing) { const { total } = await countPersoImages({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id }); if (total + 1 > PERSO_PHOTO_QUOTA) { throw new TRPCError({ code: "BAD_REQUEST", message: `Quota d'images perso atteint (max ${PERSO_PHOTO_QUOTA}).` }); } } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); const auth = await adapter.createUploadAuthorization({ path, mimeType: input.mimeType, maxBytes: input.maxBytes, }); // Le client a besoin du chemin résolu : il le renverra tel quel à // `registerR2Upload`. Le chemin ne retraverse jamais une règle de calcul. return { ...auth, path }; }), /** * Phase 2 — l'input transporte maintenant `destination` + `originalFileName` * pour permettre à l'adapter R2 de créer la row MediaAsset après HeadObject. */ registerR2Upload: protectedProcedure .input(registerR2UploadInputSchema) .mutation(async ({ ctx, input }) => { await assertUploadDestinationAllowed({ prisma: ctx.prisma, userId: ctx.sessionClient.user.id, isAdmin: ctx.sessionClient.user.isAdmin, destination: input.destination, }); // Le chemin enregistré doit être celui que createR2Upload a autorisé pour cette destination. const expectedFolder = await resolvePendingUploadFolder({ prisma: ctx.prisma, destination: input.destination, appRoot: ctx.appRoot, userId: ctx.sessionClient.user.id, }); const rest = input.path.slice(expectedFolder.length + 1); if (!input.path.startsWith(`${expectedFolder}/`) || rest === "" || rest.includes("/") || rest.includes("..")) { throw new TRPCError({ code: "FORBIDDEN", message: "Chemin d'envoi hors de la destination autorisée." }); } // L'enregistrement met à jour une ligne existante (auteur compris) : même règle qu'à la // signature, sans quoi un membre pourrait « adopter » le fichier en attente d'un autre, puis le // remplacer en tant qu'auteur. const existing = await ctx.prisma.mediaAsset.findUnique({ where: { fullPath: input.path }, select: { status: true, uploaderUserId: true }, }); if (existing && !mayReplace(existing, ctx.sessionClient.user, input.destination)) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce fichier existe déjà et ne peut être remplacé que par un administrateur.", }); } const adapter = getAdapter("r2", { prisma: ctx.prisma, appRoot: ctx.appRoot, }); return adapter.registerUploadedAsset({ path: input.path, userId: ctx.sessionClient.user.id, expectedBytes: input.expectedBytes, expectedMimeType: input.expectedMimeType, destination: input.destination, originalFileName: input.originalFileName, }); }),});Sceller l’autorisation#
Il ne reste qu’à signer. L’adaptateur R2 revérifie la clé — une ceinture sous les bretelles : elle a été calculée par le serveur, mais une régression dans un résolveur ne doit pas pouvoir signer hors de la racine — puis fabrique l’URL :
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(), }; },assertUploadPathSafe refuse une clé vide, absolue, finissant par /, contenant .. ou //, ou hors de la racine AKFC/. Ce sont des erreurs internes (Error), et c’est voulu : aucune saisie ne peut les provoquer, puisque le chemin vient du serveur. Si l’une se déclenche, c’est un bug, qui doit apparaître comme tel dans les journaux.
Ce que la signature couvre, ce qu’elle ne couvre pas#
Une signature n’est pas une autorisation générale. Elle est définie par ce qu’elle inclut, et tout ce qui reste hors de sa portée doit être traité ailleurs.
Élément | Couvert ? | Conséquence |
|---|---|---|
La méthode (PUT) | ✅ oui | L’URL ne permet ni de lire, ni de supprimer, ni de lister. |
La clé | ✅ oui | Le fichier ne peut aller ailleurs que dans le dossier résolu par le serveur, sous le nom calculé par lui. |
Le Content-Type | ✅ oui | Un envoi avec un autre type est refusé par le stockage : le fichier stocké a forcément le type vérifié par uploadTypeError. |
L’expiration (5 minutes) | ✅ oui | Une URL interceptée ou retrouvée dans un historique ne vaut plus rien peu après. |
La taille | ❌ non | Une URL PUT présignée ne borne pas la taille : elle est vérifiée sur l’objet réel, à l’enregistrement. |
Le contenu | ❌ non | Le stockage accepte les octets qu’on lui envoie : un « .jpg » peut contenir autre chose qu’une image. |
Le nombre d’envois | ❌ non | Pendant cinq minutes, l’URL peut servir plusieurs fois, sur la même clé. |
Showing entries1to7entries
La lecture de ce tableau dessine le chapitre suivant. Ce que la signature couvre, c’est l’emplacement, l’opération et le type du dépôt. Ce qu’elle ne couvre pas, c’est ce qui a réellement été déposé. Le serveur devra donc le constater lui-même, après coup.
Pourquoi cinq minutes#
Sans expiration, une URL signée resterait valable éternellement. Quiconque la retrouverait — dans un journal mal anonymisé, dans l’historique d’un navigateur partagé — pourrait déposer des mois plus tard n’importe quel contenu sous cette clé, c’est-à-dire remplacer un fichier qui, entre-temps, aurait été validé et publié.
Cinq minutes suffisent largement : l’URL est utilisée dans la seconde qui suit sa création. Un fichier volumineux sur une connexion lente ne pose pas de problème, car l’expiration est vérifiée au début de la requête PUT, pas à sa fin.
L’expiration n’est pas un confort, c’est une borne. Sans elle, une autorisation donnée une fois vaudrait pour toujours.
Il reste une fenêtre, et il faut la connaître. La règle d’écrasement est vérifiée au moment de signer. Si, pendant ces cinq minutes, l’administrateur valide le fichier en attente qu’un membre s’apprête à remplacer, le PUT du membre remplacera quand même le binaire : le stockage ne connaît que la signature. La fenêtre est courte, et le scénario suppose les deux gestes à la même minute ; la fermer tout à fait demanderait de déposer sous une clé temporaire, puis de la renommer à l’enregistrement.
Le navigateur : trois temps, une fonction#
Côté navigateur, la séquence est écrite une seule fois, et tous les formulaires d’envoi l’utilisent (bibliothèque, espace personnel, dépôt commun, espaces de groupe) :
Trois détails comptent. Le même type sert à la demande, au Content-Type du PUT et à l’enregistrement : le stockage refuserait un PUT dont le type diffère de celui signé, et cette fonction rend l’écart impossible. Le chemin renvoyé par le serveur est retransmis tel quel. Et une erreur du stockage est traduite en message lisible, sans recopier le corps XML de sa réponse.
Le formulaire de la bibliothèque envoie ses fichiers en parallèle (Promise.all), et chaque envoi rend son propre résultat, succès ou erreur : un fichier refusé n’empêche pas les autres d’aboutir, et l’erreur s’affiche sur la vignette concernée. Celui de l’espace personnel les envoie un par un, parce que le quota se vérifie à chaque demande.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Que fournit le client ? | Une destination (union discriminée) et un nom d’origine. Jamais un chemin. |
Qui calcule le dossier ? | Le serveur, en relisant la base et la session (resolvePendingUploadFolder). Les segments de discipline et d’espace personnel sont stables malgré un renommage. |
Qui calcule le nom ? | Le serveur (buildUploadFileName) ; le nom d’origine est conservé à part. |
Qui peut déposer ? | Tout membre connecté, sauf l’espace d’un groupe (éditeur) ; rattacher des disciplines à un événement est réservé aux administrateurs. |
Et écraser ? | Sur confirmation, et seulement un administrateur, un éditeur dans son groupe, ou l’auteur d’un fichier en attente. |
Que couvre la signature ? | Méthode, clé, type, expiration. Ni la taille, ni le contenu. |
Showing entries1to6entries
À suivre#
Le navigateur a envoyé son fichier, et revient dire « c’est fait, voici ce que j’ai déposé ». C’est la phrase la plus dangereuse qu’un backend puisse croire sans vérifier.
Chapitre suivant : enregistrer ce qui est arrivé. Le trou entre « signé » et « en base », la relecture chez le stockage, et pourquoi chaque champ de la ligne MediaAsset vient du serveur ou du stockage, jamais du navigateur.