L’avatar
Le portrait d’un membre : un envoi direct au stockage que le serveur autorise et vérifie, un seul fichier par membre, une route publique qui ne sert que des avatars, une version pour rafraîchir toutes les vues, un avatar qui représente sans être posé ailleurs, et un dossier que la bibliothèque ne voit pas.
Précédemment. Un compte est ouvert par le club, avec un mot de passe qui ne parvient qu’au membre ; le profil n’est modifiable que par son titulaire, et un compte supprimé ne laisse rien derrière lui.
Les parcours du chapitre#
Un parcours traverse ce chapitre : le membre qui change son avatar, jusqu’au visiteur qui le voit à côté de son nom.
Acteur : Membre connecté
- 1
Le navigateur demande une autorisation d'envoi, dans le dossier du membre
avatar.createUploadmembre - 2
Le serveur vérifie le fichier arrivé, l'enregistre comme avatar et efface l'ancien
avatar.registermembre- User modifiée : avatar
Lit : 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).
avatar.registerpackages/backend/src/modules/avatar/router.ts:24registerAvatarpackages/backend/src/modules/avatar/avatar.service.ts:83prisma.user.updatepackages/backend/src/modules/avatar/avatar.service.ts:97User modifiée
- 3
Un visiteur voit le portrait à côté d'un nom
GET /api/media/public/avatar/[userId]Lit : 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).
GET /api/media/public/avatar/[userId]apps/web/src/app/api/media/public/avatar/[userId]/route.ts:26prisma.user.findUniqueapps/web/src/app/api/media/public/avatar/[userId]/route.ts:37User lue
Pas un média comme les autres 🖼️#
Un avatar est une image, envoyée au même stockage que les fichiers de la bibliothèque. Ce n’est pourtant pas un média de la bibliothèque, et presque tout ce qui le concerne en découle :
col_0 | Fichier de la bibliothèque | Avatar |
|---|---|---|
Ligne en base | MediaAsset (statut, nom d’origine, auteur…) | Aucune : User.avatar garde sa clé |
Rangement | Dans le Finder, déplaçable, renommable | Un dossier par membre, invisible du Finder |
Publication | Validé par un administrateur, puis placé dans un contenu publié | Visible dès qu’il est enregistré, là où le membre est représenté |
Nombre | Autant que nécessaire | Un seul : le précédent est effacé |
Qui le modifie | Les administrateurs, les éditeurs d’un espace | Son titulaire seul |
Showing entries1to5entries
L’avatar ne passe pas non plus par le formulaire du profil : il a son propre routeur, entièrement scellé sur l’utilisateur connecté. Aucune procédure ne reçoit d’identifiant de membre : le dossier est dérivé de la session, côté serveur.
Un envoi direct, autorisé par le serveur#
L’envoi suit le circuit des fichiers de la partie « Stockage et médias » : le serveur signe une autorisation, le navigateur envoie l’image directement au stockage, puis fait enregistrer le résultat. Le fichier ne transite pas par le serveur de l’application.
createAvatarUpload — signer une destination
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import crypto from "crypto";import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";import { getR2Bucket, getR2Client, getR2PresignClient } from "@backend/modules/storage/adapters/r2/client";import { deleteDerivatives } from "@backend/modules/media/services/mediaDerivatives.service";import { avatarsPrefix } from "@backend/modules/storage/mediaPaths";/** * avatar.service.ts — pipeline avatar, ISOLÉ du reste. * * Un avatar n'est PAS un MediaAsset : pas de catégorie, pas de cycle * pending/published, pas de finder. `User.avatar` garde la clé R2 du fichier : * `${appRoot}/avatars/<userId>/<horodatage>-<aléa>.<ext>`, servi par * `/api/media/public/avatar/<userId>` (vignette AVIF). * * Clé UNIQUE par envoi, et l'ancien fichier est SUPPRIMÉ à chaque * remplacement : un seul avatar à la fois. Le dossier est dérivé du userId * côté serveur : un user ne peut agir que sur son propre espace. *//** Dossier-entité des avatars d'un user. */export function avatarFolder(appRoot: string, userId: string): string { return `${avatarsPrefix(appRoot)}/${userId}`;}/** * Supprime l'avatar courant : fichier + champ User.avatar. */export async function deleteAvatar(params: { prisma: PrismaClient; userId: string;}): Promise<void> { const { prisma, userId } = params; const user = await prisma.user.findUnique({ where: { id: userId }, select: { avatar: true }, }); const avatar = user?.avatar ?? null; if (avatar) await removeAvatarFile(avatar); await prisma.user.update({ where: { id: userId }, data: { avatar: null }, });}export const AVATAR_MAX_BYTES = 10 * 1024 * 1024;const AVATAR_EXT: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" };/** Vrai si la valeur de User.avatar est une clé d'avatar valide (image JPEG, PNG ou WebP). */export function isAvatarKey(value: string): boolean { return /\.(jpe?g|png|webp)$/i.test(value);}export async function createAvatarUpload(params: { appRoot: string; userId: string; mimeType: string; bytes: number }) { const ext = AVATAR_EXT[params.mimeType]; if (!ext) throw new TRPCError({ code: "BAD_REQUEST", message: "Format d'avatar non accepté (JPEG, PNG ou WebP)." }); if (params.bytes <= 0 || params.bytes > AVATAR_MAX_BYTES) throw new TRPCError({ code: "BAD_REQUEST", message: "Avatar trop volumineux (10 Mo au plus)." }); const path = `${avatarFolder(params.appRoot, params.userId)}/${Date.now()}-${crypto.randomBytes(3).toString("hex")}.${ext}`; const uploadUrl = await getSignedUrl( getR2PresignClient(), new PutObjectCommand({ Bucket: getR2Bucket(), Key: path, ContentType: params.mimeType }), { expiresIn: 5 * 60 }, ); return { uploadUrl, path };}/** Supprime un fichier d'avatar et ses vignettes (meilleur effort, jamais bloquant). */export async function removeAvatarFile(value: string): Promise<void> { try { await getR2Client().send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: value })); await deleteDerivatives(value); } catch { // déjà absent — rien à faire }}export async function registerAvatar(params: { prisma: PrismaClient; appRoot: string; userId: string; path: string }): Promise<{ avatar: string }> { const { prisma, appRoot, userId, path } = params; if (!path.startsWith(`${avatarFolder(appRoot, userId)}/`) || path.includes("..") || !isAvatarKey(path)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Chemin hors de l'espace avatar de l'utilisateur." }); } const head = await getR2Client() .send(new HeadObjectCommand({ Bucket: getR2Bucket(), Key: path })) .catch(() => null); if (!head) throw new TRPCError({ code: "BAD_REQUEST", message: "Avatar introuvable : l'envoi n'a pas abouti." }); if (!(head.ContentType ?? "").startsWith("image/") || (head.ContentLength ?? 0) > AVATAR_MAX_BYTES) { await removeAvatarFile(path); throw new TRPCError({ code: "BAD_REQUEST", message: "Fichier d'avatar refusé." }); } const prev = await prisma.user.findUnique({ where: { id: userId }, select: { avatar: true } }); await prisma.user.update({ where: { id: userId }, data: { avatar: path } }); if (prev?.avatar && prev.avatar !== path) await removeAvatarFile(prev.avatar); return { avatar: path };}Côté navigateur, l’image recadrée suit les trois temps :
Le recadrage se fait avant, dans le navigateur, et ne déclenche rien : le membre voit le résultat et le confirme. Seul le fichier recadré est envoyé.
Ce que le serveur vérifie#
L’enregistrement ne croit pas le navigateur sur parole :
registerAvatar — croire le stockage, pas le navigateur
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import crypto from "crypto";import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";import { getSignedUrl } from "@aws-sdk/s3-request-presigner";import { getR2Bucket, getR2Client, getR2PresignClient } from "@backend/modules/storage/adapters/r2/client";import { deleteDerivatives } from "@backend/modules/media/services/mediaDerivatives.service";import { avatarsPrefix } from "@backend/modules/storage/mediaPaths";/** * avatar.service.ts — pipeline avatar, ISOLÉ du reste. * * Un avatar n'est PAS un MediaAsset : pas de catégorie, pas de cycle * pending/published, pas de finder. `User.avatar` garde la clé R2 du fichier : * `${appRoot}/avatars/<userId>/<horodatage>-<aléa>.<ext>`, servi par * `/api/media/public/avatar/<userId>` (vignette AVIF). * * Clé UNIQUE par envoi, et l'ancien fichier est SUPPRIMÉ à chaque * remplacement : un seul avatar à la fois. Le dossier est dérivé du userId * côté serveur : un user ne peut agir que sur son propre espace. *//** Dossier-entité des avatars d'un user. */export function avatarFolder(appRoot: string, userId: string): string { return `${avatarsPrefix(appRoot)}/${userId}`;}/** * Supprime l'avatar courant : fichier + champ User.avatar. */export async function deleteAvatar(params: { prisma: PrismaClient; userId: string;}): Promise<void> { const { prisma, userId } = params; const user = await prisma.user.findUnique({ where: { id: userId }, select: { avatar: true }, }); const avatar = user?.avatar ?? null; if (avatar) await removeAvatarFile(avatar); await prisma.user.update({ where: { id: userId }, data: { avatar: null }, });}export const AVATAR_MAX_BYTES = 10 * 1024 * 1024;const AVATAR_EXT: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" };/** Vrai si la valeur de User.avatar est une clé d'avatar valide (image JPEG, PNG ou WebP). */export function isAvatarKey(value: string): boolean { return /\.(jpe?g|png|webp)$/i.test(value);}export async function createAvatarUpload(params: { appRoot: string; userId: string; mimeType: string; bytes: number }) { const ext = AVATAR_EXT[params.mimeType]; if (!ext) throw new TRPCError({ code: "BAD_REQUEST", message: "Format d'avatar non accepté (JPEG, PNG ou WebP)." }); if (params.bytes <= 0 || params.bytes > AVATAR_MAX_BYTES) throw new TRPCError({ code: "BAD_REQUEST", message: "Avatar trop volumineux (10 Mo au plus)." }); const path = `${avatarFolder(params.appRoot, params.userId)}/${Date.now()}-${crypto.randomBytes(3).toString("hex")}.${ext}`; const uploadUrl = await getSignedUrl( getR2PresignClient(), new PutObjectCommand({ Bucket: getR2Bucket(), Key: path, ContentType: params.mimeType }), { expiresIn: 5 * 60 }, ); return { uploadUrl, path };}/** Supprime un fichier d'avatar et ses vignettes (meilleur effort, jamais bloquant). */export async function removeAvatarFile(value: string): Promise<void> { try { await getR2Client().send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: value })); await deleteDerivatives(value); } catch { // déjà absent — rien à faire }}export async function registerAvatar(params: { prisma: PrismaClient; appRoot: string; userId: string; path: string }): Promise<{ avatar: string }> { const { prisma, appRoot, userId, path } = params; if (!path.startsWith(`${avatarFolder(appRoot, userId)}/`) || path.includes("..") || !isAvatarKey(path)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Chemin hors de l'espace avatar de l'utilisateur." }); } const head = await getR2Client() .send(new HeadObjectCommand({ Bucket: getR2Bucket(), Key: path })) .catch(() => null); if (!head) throw new TRPCError({ code: "BAD_REQUEST", message: "Avatar introuvable : l'envoi n'a pas abouti." }); if (!(head.ContentType ?? "").startsWith("image/") || (head.ContentLength ?? 0) > AVATAR_MAX_BYTES) { await removeAvatarFile(path); throw new TRPCError({ code: "BAD_REQUEST", message: "Fichier d'avatar refusé." }); } const prev = await prisma.user.findUnique({ where: { id: userId }, select: { avatar: true } }); await prisma.user.update({ where: { id: userId }, data: { avatar: path } }); if (prev?.avatar && prev.avatar !== path) await removeAvatarFile(prev.avatar); return { avatar: path };}Chaque refus est une erreur BAD_REQUEST avec son message, que le membre lit tel quel (« Format d’avatar non accepté (JPEG, PNG ou WebP). », « Avatar trop volumineux (10 Mo au plus). »). Ces erreurs étaient levées comme de simples Error, que tRPC transforme en erreur interne au message masqué : le membre voyait « une erreur est survenue » pour une photo de 12 Mo.
Une route qui ne sert que des avatars#
Un avatar s’affiche à des visiteurs anonymes (la page des instructeurs, un commentaire). La route privée des fichiers exige une session, sans condition, parce qu’elle sert aussi des documents personnels ; la rouvrir pour les avatars l’aurait rouverte pour tout. Les avatars ont donc leur route :
GET /api/media/public/avatar/[userId]
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : apps/web/src/app/api/media/public/avatar/[userId]/route.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.L’URL est la même pour toujours : /api/media/public/avatar/<id>. Quand le membre change d’avatar, le navigateur garde l’ancienne image en cache. Un petit store partagé tient donc une version par membre, ajoutée à l’URL (?v=…) :
Après un enregistrement, bumpAvatar(userId) change la version : le header, le profil, les commentaires affichés rechargent l’image ensemble. La route ignore ce paramètre ; il ne sert qu’à changer l’URL aux yeux du cache.
Représenter n’est pas poser#
Un avatar sert à représenter son titulaire : à côté de son nom, quand il commente, publie, réagit, ou dans la liste des instructeurs. Là, il est visible d’autrui, et c’est sa raison d’être.
Le poser dans un contenu (un bloc média-texte ou texte enrobant qui montre « l’avatar de tel membre ») est autre chose : c’est publier le visage de quelqu’un sur une page qu’il n’a pas écrite. La règle du club est sans exception, administrateurs compris :
export function assertOwnAvatars( userId: string, ...contents: readonly (PageContentV1 | null | undefined)[]): void { const foreign = contents.some( (content) => content != null && avatarUserIdsFromContent(content).some((id) => id !== userId), ); if (foreign) { throw new TRPCError({ code: "FORBIDDEN", message: "Un contenu ne peut montrer que votre propre avatar. Remplacez l'avatar d'un autre membre par une photo de la bibliothèque.", }); }Chaque procédure qui enregistre un contenu l’appelle sur les contenus reçus ; le sélecteur d’avatar ne propose que soi (partie « Contenu éditorial », un contenu écrit par un membre).
Hors de la bibliothèque#
Les avatars vivent sous AKFC/avatars/<identifiant>/. Le Finder liste le stockage tel qu’il est : ce dossier y apparaissait, avec les fichiers de chaque membre. Un administrateur pouvait les déplacer, les renommer, les jeter. garde la clé exacte du fichier : après un déplacement, le compte aurait pointé sur rien, et l’avatar aurait disparu sans que personne comprenne pourquoi.
export function avatarsPrefix(appRoot: string): string { return `${appRoot}/avatars`;}export function isAvatarPath(appRoot: string, path: string): boolean { const prefix = avatarsPrefix(appRoot); return path === prefix || path.startsWith(`${prefix}/`);}/** Refuse toute opération de la bibliothèque sur le dossier des avatars. Même réponse qu'un chemin inconnu. */export function assertOutsideAvatars(appRoot: string, paths: readonly string[]): void { if (paths.some((p) => isAvatarPath(appRoot, p))) { throw new TRPCError({ code: "NOT_FOUND", message: "Dossier introuvable." }); }Le dossier n’est plus listé (ni par storage.list, ni par storage.getTree), et toute opération de la bibliothèque qui le vise — lecture, renommage, déplacement, libellé, corbeille — répond « introuvable », comme pour un dossier qui n’existe pas. Les avatars ne passent plus que par leur circuit : le profil de leur titulaire, et leur route.
Un objet qui a un propriétaire et un circuit à lui ne doit pas être aussi manipulable par un outil générique. Deux chemins d’écriture vers la même donnée, c’est deux façons de la rendre incohérente.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Un avatar est-il un média de la bibliothèque ? | Non : ni MediaAsset, ni statut ; User.avatar garde sa clé, et la bibliothèque ne le voit pas. |
Qui choisit où il est rangé ? | Le serveur : le dossier du membre connecté, une clé nouvelle à chaque envoi. |
Que vérifie l’enregistrement ? | Le dossier du membre, la présence du fichier, son type et sa taille réels, lus chez le stockage ; un fichier refusé est supprimé. |
Combien d’avatars par membre ? | Un : le précédent est effacé après la mise à jour du compte, et à la suppression du compte. |
Comment un visiteur le voit-il ? | Par /api/media/public/avatar/<id>, qui ne sert que des avatars, en vignette de 300 pixels. |
Comment toutes les vues se rafraîchissent-elles ? | Une version par membre, ajoutée à l’URL, changée après chaque envoi. |
Peut-on poser l’avatar d’un autre ? | Non, personne : un contenu ne montre que l’avatar de son auteur. |
Showing entries1to7entries
À suivre#
Le membre a son compte, son profil et son visage. Le club peut maintenant lui transmettre des documents : un compte rendu d’assemblée générale pour tous, une attestation pour lui seul, un planning pour un groupe.
Chapitre suivant : les documents des membres. Publier un fichier sans le copier, choisir qui le reçoit, suivre ce que chacun a lu, et ne servir le fichier qu’à ceux qui peuvent l’ouvrir.