Le droit à l’image
L’autorisation d’utiliser l’image d’un membre, usage par usage : un formulaire signé déposé par un administrateur, rangé hors de la bibliothèque et servi à ses seuls ayants droit, un statut qui en découle, un historique qui fait preuve, et une preuve gardée cinq ans après le départ.
Précédemment. Une purge tient chaque nuit les durées annoncées ; les comptes ne sont jamais supprimés sans décision ; chaque membre exporte ses données.
Le club prend des photos et des vidéos pendant les cours et les stages. Un membre reconnaissable sur une image a son mot à dire sur sa diffusion, et pas d’un bloc : il peut accepter la galerie des membres et refuser les réseaux sociaux. Son accord doit être écrit, signé (par un parent pour un mineur), et le club doit pouvoir le prouver (article 7.1 du RGPD). Le site ne remplace pas la signature : il conserve le document signé, en tire un statut, et le rend consultable au membre.
Les parcours du chapitre#
Trois parcours traversent ce chapitre : le dépôt d’un formulaire signé, la consultation par le membre, et ce que devient la preuve après son départ.
Acteur : Administrateur
- 1
Depuis la fiche du membre, il demande une autorisation d'envoi pour le formulaire scanné
imageConsent.createUploadadmin - 2
Le formulaire est vérifié et enregistré avec ses usages ; s'il porte les droits les plus récents, le statut du membre en découle, dans la même transaction
imageConsent.registeradmin- ImageConsentForm créée : fileName, keepAfterLeave, memberName, mimeType, signedAt, size, storageKey, uploadedById, usages, userId
- User modifiée : imageConsent, imageConsentAt
Lit : ImageConsentForm, User
E-mails : sendImageConsentNotice
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).
imageConsent.registerpackages/backend/src/modules/imageConsent/router.ts:29registerImageConsentFormpackages/backend/src/modules/imageConsent/imageConsentForm.service.ts:64prisma.imageConsentForm.createpackages/backend/src/modules/imageConsent/imageConsentForm.service.ts:106ImageConsentForm crééeprisma.user.updatepackages/backend/src/modules/imageConsent/imageConsentForm.service.ts:123User modifiée
Des usages choisis un par un 📸#
Le formulaire papier propose une ligne Oui / Non par usage. Le site reprend exactement ces lignes, dans le même ordre, depuis une description partagée par le backend et le front :
export const IMAGE_USAGE_VALUES = ["MEMBER_SPACE", "PUBLIC_SITE", "SOCIAL_NETWORKS", "PRINT", "PRESS"] as const;export type ImageUsageValue = (typeof IMAGE_USAGE_VALUES)[number];export const IMAGE_USAGE_LABEL: Record<ImageUsageValue, string> = { MEMBER_SPACE: "Espace membres", PUBLIC_SITE: "Site public", SOCIAL_NETWORKS: "Réseaux sociaux du club", PRINT: "Supports imprimés", PRESS: "Presse locale",};Le tableau as const donne à la fois la liste (pour les cases à cocher, dans l’ordre du formulaire) et le type (ImageUsageValue, l’union de ses valeurs) ; le dictionnaire des libellés est vérifié par le compilateur : un usage ajouté sans libellé ne compile pas.
Le formulaire vierge est un PDF du site. Le membre le trouve dans son profil, avec une explication : l’autorisation est facultative, chaque usage se choisit, et le formulaire se remet à un responsable du club. Il demande aussi ce que deviennent les images après le départ du membre : le club peut continuer à utiliser celles déjà prises, ou les retire sous un an. Le choix est enregistré tel quel (), avec une troisième valeur, null, pour un formulaire d’une version qui ne posait pas la question.
Un formulaire signé, déposé par un administrateur#
Seul un administrateur dépose un formulaire, depuis la fiche du membre : le fichier (PDF ou photo), les usages cochés « Oui », le choix après le départ et la date de signature. Une photo prise avec un téléphone en HEIC est convertie en JPEG dans le navigateur avant l’envoi.
L’envoi suit le circuit signé du stockage : le serveur délivre une URL valable cinq minutes, le navigateur envoie le fichier directement, puis le serveur enregistre. Mais la clé n’est pas dans la bibliothèque :
export const IMAGE_CONSENT_ROOT = "_private/image-consent";function formFolder(userId: string): string { return `${IMAGE_CONSENT_ROOT}/${userId}`;}_private/image-consent/<identifiant>/ est hors de la racine de l’application (AKFC/…) : aucun finder ne le liste, aucune route de la bibliothèque ne le sert. Comme le logo du site, et pour une raison plus forte : ce document porte une signature et parfois l’identité d’un parent.
Un statut qui découle du formulaire#
registerImageConsentForm — vérifier, enregistrer, déduire
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 { IMAGE_CONSENT_FORM_MAX_BYTES, IMAGE_CONSENT_FORM_MIME_EXT, consentFromUsages, type ImageUsageValue,} from "@contracts/legal/imageConsent";import { sendImageConsentNotice } from "@backend/email/templates/imageConsentNotice";import { latestFormUsages } from "@backend/modules/imageConsent/formOrder";/** * Formulaires de droit à l'image SIGNÉS — déposés par un admin sur la fiche d'un membre. * * Fichier dans R2 sous `_private/image-consent/<userId>/`, HORS de la racine de l'appli : il * n'apparaît pas dans la bibliothèque et n'est servi que par /api/media/image-consent/[id] * (le membre concerné et les admins). Envoi direct navigateur → R2 (URL signée), comme l'avatar. * * Le dépôt fixe User.imageConsent d'après les usages cochés, SI ses droits sont les plus récents : * un formulaire signé avant les droits en vigueur est archivé (preuve) sans changer le statut * (formOrder.ts). User.imageConsentAt est la date des droits en vigueur. Les anciens restent (preuve). À la suppression du compte, les formulaires sont détachés et * gardés 5 ans, puis effacés par la purge nocturne. */export const IMAGE_CONSENT_ROOT = "_private/image-consent";function formFolder(userId: string): string { return `${IMAGE_CONSENT_ROOT}/${userId}`;}async function removeObject(key: string): Promise<void> { try { await getR2Client().send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: key })); } catch { // déjà absent — rien à faire }}/** URL signée (PUT, 5 min) pour envoyer le formulaire d'un membre, et la clé à renvoyer à `register`. */export async function createImageConsentFormUpload(params: { userId: string; mimeType: string; bytes: number }) { const ext = IMAGE_CONSENT_FORM_MIME_EXT[params.mimeType]; if (!ext) throw new TRPCError({ code: "BAD_REQUEST", message: "Format non accepté : PDF, JPEG, PNG ou WebP." }); if (params.bytes <= 0 || params.bytes > IMAGE_CONSENT_FORM_MAX_BYTES) { throw new TRPCError({ code: "BAD_REQUEST", message: "Fichier trop volumineux (15 Mo au plus)." }); } const path = `${formFolder(params.userId)}/${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${ext}`; const uploadUrl = await getSignedUrl( getR2PresignClient(), new PutObjectCommand({ Bucket: getR2Bucket(), Key: path, ContentType: params.mimeType }), { expiresIn: 5 * 60 }, ); return { uploadUrl, path };}/** * Après l'envoi : vérifie le fichier, crée la fiche, et met à jour le statut du membre si ce * formulaire porte des droits plus récents que ceux en vigueur (`applied`). */export async function registerImageConsentForm(params: { prisma: PrismaClient; userId: string; uploadedById: string; path: string; fileName: string; usages: ImageUsageValue[]; keepAfterLeave: boolean | null; signedAt: Date | null;}) { const { prisma, userId, path } = params; const ext = path.split(".").pop() ?? ""; const mimeType = Object.keys(IMAGE_CONSENT_FORM_MIME_EXT).find((m) => IMAGE_CONSENT_FORM_MIME_EXT[m] === ext); if (!path.startsWith(`${formFolder(userId)}/`) || path.includes("..") || !mimeType) { throw new TRPCError({ code: "BAD_REQUEST", message: "Chemin hors de l'espace du membre." }); } const head = await getR2Client() .send(new HeadObjectCommand({ Bucket: getR2Bucket(), Key: path })) .catch(() => null); if (!head) throw new TRPCError({ code: "BAD_REQUEST", message: "Fichier introuvable : l'envoi n'a pas abouti." }); const size = head.ContentLength ?? 0; if (size <= 0 || size > IMAGE_CONSENT_FORM_MAX_BYTES || head.ContentType !== mimeType) { await removeObject(path); throw new TRPCError({ code: "BAD_REQUEST", message: "Fichier refusé." }); } const usages = [...new Set(params.usages)]; const member = await prisma.user.findUnique({ where: { id: userId }, select: { firstName: true, lastName: true, pseudo: true, email: true, imageConsentAt: true }, }); if (!member) { await removeObject(path); throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable." }); } const memberName = [member.firstName, member.lastName].filter(Boolean).join(" ").trim() || member.pseudo || member.email; const previousUsages = await latestFormUsages(prisma, userId); // Les droits ne changent que pour des droits plus récents : sans date de signature, ceux du jour. const effectiveAt = params.signedAt ?? new Date(); const applied = !member.imageConsentAt || effectiveAt.getTime() >= member.imageConsentAt.getTime(); const [form] = await prisma.$transaction([ prisma.imageConsentForm.create({ data: { userId, memberName, storageKey: path, mimeType, fileName: params.fileName.trim().slice(0, 200) || `autorisation.${ext}`, size, usages, keepAfterLeave: params.keepAfterLeave, signedAt: params.signedAt, uploadedById: params.uploadedById, }, select: { id: true }, }), ...(applied ? [ prisma.user.update({ where: { id: userId }, data: { imageConsent: consentFromUsages(usages), imageConsentAt: effectiveAt }, select: { id: true }, }), ] : []), ]); if (!applied) return { id: form.id, applied }; await sendImageConsentNotice({ userId, memberName, via: "par formulaire papier déposé par un administrateur", usages, previousUsages, }).catch((err) => console.error("[droit à l'image] avis au club non envoyé :", err instanceof Error ? err.message : err)); return { id: form.id, applied };}/** Supprime un formulaire (fichier + fiche). Le statut du membre n'est pas modifié. */export async function deleteImageConsentForm(prisma: PrismaClient, id: string): Promise<void> { const form = await prisma.imageConsentForm.findUnique({ where: { id }, select: { storageKey: true } }); if (!form) return; // Le fichier d'abord : s'il ne part pas, la fiche reste et l'administrateur peut réessayer. // L'inverse laisserait un document signé dans le stockage sans plus aucune ligne pour le désigner. try { await getR2Client().send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: form.storageKey })); } catch (err) { console.error(`[droit à l'image] formulaire ${id} : fichier non supprimé :`, err instanceof Error ? err.message : err); throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Le fichier n'a pas pu être supprimé. Réessayez dans un instant." }); } await prisma.imageConsentForm.delete({ where: { id } });}/** Durée de conservation d'un formulaire après la suppression du compte (preuve du consentement). */export const DETACHED_FORM_RETENTION_YEARS = 5;/** Purge nocturne : formulaires détachés depuis plus de 5 ans (fiche et fichier). */export async function purgeDetachedImageConsentForms(prisma: PrismaClient, now = new Date()): Promise<number> { const cutoff = new Date(now); cutoff.setFullYear(cutoff.getFullYear() - DETACHED_FORM_RETENTION_YEARS); const old = await prisma.imageConsentForm.findMany({ where: { userId: null, detachedAt: { lt: cutoff } }, select: { id: true, storageKey: true }, }); let purged = 0; for (const f of old) { // Le fichier d'abord, la fiche ensuite, et la fiche SEULEMENT si le fichier est parti : un échec // laisse la fiche, et la purge suivante réessaiera. Dans l'autre ordre (ou en ignorant l'échec), // un document signé resterait dans le stockage sans plus aucune ligne pour le désigner. // (Supprimer une clé déjà absente réussit : un fichier déjà parti n'empêche rien.) try { await getR2Client().send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: f.storageKey })); } catch (err) { console.error(`[droit à l'image] formulaire ${f.id} non purgé (fichier) :`, err instanceof Error ? err.message : err); continue; } await prisma.imageConsentForm.delete({ where: { id: f.id } }); purged += 1; } return purged;}/** Champs exposés d'un formulaire (jamais la clé R2). */export const imageConsentFormSelect = { id: true, mimeType: true, fileName: true, size: true, usages: true, keepAfterLeave: true, signedAt: true, createdAt: true, source: true, signerName: true, signerQuality: true,} as const;Chaque membre a un statut, : Non recueillie, Accordée ou Refusée. Il n’est pas saisi à côté du formulaire, il en est déduit :
export function consentFromUsages(usages: readonly ImageUsageValue[]): Exclude<ImageConsentValue, "UNKNOWN"> { return usages.length > 0 ? "GRANTED" : "REFUSED";}Accordée dès qu’un usage est autorisé, refusée sinon. Les anciens formulaires ne sont pas remplacés : un membre qui change d’avis remet un nouveau formulaire, qui s’ajoute à l’historique. Les précédents restent comme preuve de ce qui était autorisé à leur date.
La règle du club tient en une phrase : les droits ne changent que si des droits plus récents sont enregistrés. « Plus récent » se lit à la date des droits — la signature d’un formulaire papier, la confirmation d’un formulaire en ligne, la saisie d’un administrateur — et jamais à la date du dépôt :
Ce qui arrive | Le statut |
|---|---|
Un formulaire signé après les droits en vigueur est déposé | Change, et prend la date de signature. |
Un formulaire signé AVANT les droits en vigueur est déposé (retrouvé dans un classeur, par exemple) | Ne change pas : le formulaire est archivé comme preuve ; l’administrateur en est averti. |
Un formulaire est supprimé (déposé sur la mauvaise fiche…) | Ne change pas. Si le statut était faux, l’administrateur le corrige sur la fiche, où il peut le fixer à la main. |
Le membre confirme en ligne (chapitre 4) | Change : une confirmation est toujours datée du jour, donc la plus récente. |
Showing entries1to4entries
Les listes de formulaires suivent le même ordre (byEffectiveDesc) : sur la fiche comme dans le profil, le premier est celui dont les droits sont les plus récents.
/** Date à laquelle un formulaire a donné ses droits : sa signature, à défaut son dépôt. */export function formEffectiveAt(f: Dated): Date { return f.signedAt ?? f.createdAt;}/** Du plus récent au plus ancien par date des droits ; à date égale, le dernier déposé d'abord. */export function byEffectiveDesc(a: Dated, b: Dated): number { return formEffectiveAt(b).getTime() - formEffectiveAt(a).getTime() || b.createdAt.getTime() - a.createdAt.getTime();}Une règle qui dépend du temps se décide sur la date de ce qu’elle décrit, pas sur la date à laquelle le site l’apprend. Un formulaire de mars déposé en octobre décrit le mois de mars.
Où | Ce qu’on y voit |
|---|---|
Table des utilisateurs (admin) | Une colonne « Photos » : le statut de chaque membre, en couleur. |
Fiche du membre (admin) | Le statut, modifiable ; les formulaires déposés, avec aperçu, et le dépôt d’un nouveau. |
Profil du membre | Son statut, les usages autorisés par le dernier formulaire, ses formulaires (aperçu), le formulaire vierge, et la confirmation en ligne s’il est majeur (chapitre 4). |
Showing entries1to3entries
Le statut informe, il ne bloque rien. La visibilité d’une photo se décide par galerie (publique, ou réservée aux membres connectés), et le site ne sait pas qui figure sur une image. C’est à l’administrateur qui publie de consulter la colonne « Photos » avant de rendre une galerie publique.
Servi aux seuls ayants droit#
Un formulaire signé n’a ni URL publique ni URL signée transmissible. Une route du site le lit dans le stockage et le transmet elle-même, au membre concerné et aux administrateurs :
Tout autre membre reçoit 404, comme si le formulaire n’existait pas. La réponse porte Cache-Control: private, no-store et nosniff ; les types acceptés au dépôt (PDF, JPEG, PNG, WebP) s’affichent sans risque. Les procédures qui listent les formulaires ne renvoient jamais la clé de stockage :
/** Champs exposés d'un formulaire (jamais la clé R2). */export const imageConsentFormSelect = { id: true, mimeType: true, fileName: true, size: true, usages: true, keepAfterLeave: true, signedAt: true, createdAt: true, source: true, signerName: true, signerQuality: true,} as const;Après le départ, une preuve gardée cinq ans#
Supprimer le compte d’un membre ne supprime pas ses formulaires. Ils servent à prouver qu’une image publiée l’a été avec son accord, et cette preuve peut être demandée après son départ. Juste avant la suppression du compte, les formulaires sont détachés et datés :
// Formulaires de droit à l'image signés : gardés 5 ans comme preuve (purge nocturne). await tx.imageConsentForm.updateMany({ where: { userId: input.id }, data: { detachedAt: new Date() } });La relation vers le membre passe alors à null (onDelete: SetNull) ; permet encore de l’identifier. La purge de la nuit supprime la fiche et le fichier cinq ans après le détachement :
export async function purgeDetachedImageConsentForms(prisma: PrismaClient, now = new Date()): Promise<number> { const cutoff = new Date(now); cutoff.setFullYear(cutoff.getFullYear() - DETACHED_FORM_RETENTION_YEARS); const old = await prisma.imageConsentForm.findMany({ where: { userId: null, detachedAt: { lt: cutoff } }, select: { id: true, storageKey: true }, }); let purged = 0; for (const f of old) { // Le fichier d'abord, la fiche ensuite, et la fiche SEULEMENT si le fichier est parti : un échec // laisse la fiche, et la purge suivante réessaiera. Dans l'autre ordre (ou en ignorant l'échec), // un document signé resterait dans le stockage sans plus aucune ligne pour le désigner. // (Supprimer une clé déjà absente réussit : un fichier déjà parti n'empêche rien.) try { await getR2Client().send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: f.storageKey })); } catch (err) { console.error(`[droit à l'image] formulaire ${f.id} non purgé (fichier) :`, err instanceof Error ? err.message : err); continue; } await prisma.imageConsentForm.delete({ where: { id: f.id } }); purged += 1; } return purged;}Le fichier d’abord, la fiche ensuite, et la fiche seulement si le fichier est parti : un échec laisse la fiche, et la purge suivante réessaiera. La première version ignorait l’échec (removeObject l’avale) et supprimait la fiche quand même : un document signé pouvait rester dans le stockage sans plus aucune ligne pour le désigner — et donc pour le purger un jour.
Le club ne prouve rien avec un statut coché : il prouve avec le document signé. Le site garde donc le document, en tire le statut, et conserve l’historique, plutôt que de stocker seulement une case « accordée ».
Récapitulatif#
Question | Réponse du projet |
|---|---|
Comment le membre donne-t-il son accord ? | Par un formulaire signé, usage par usage, remis au club ; un administrateur le dépose sur sa fiche (ou, majeur, il confirme en ligne : chapitre 4). |
Où est rangé le fichier ? | Sous _private/image-consent/, hors de l’application ; servi par une route au membre concerné et aux administrateurs, 404 pour les autres. |
D’où vient le statut ? | Du formulaire déposé, dans la même transaction, si ses droits sont les plus récents : accordée dès qu’un usage est autorisé, refusée sinon. |
Un vieux formulaire déposé change-t-il le statut ? | Non : les droits ne changent que pour des droits plus récents (date de signature, pas de dépôt) ; il est archivé comme preuve. Une suppression ne change rien non plus. |
Le statut empêche-t-il de publier une photo ? | Non : il informe l’administrateur, qui décide de la visibilité de chaque galerie. |
Que deviennent les formulaires au départ du membre ? | Détachés du compte supprimé, gardés cinq ans comme preuve, puis purgés (fichier d’abord). |
Showing entries1to6entries
À suivre#
Le formulaire papier reste la voie de tous. Un membre majeur peut aussi donner son accord lui-même, depuis son profil : le site doit alors produire ce que produisait la signature — un document, une date, une version du texte, et la certitude que c’est bien lui.
Chapitre suivant : le consentement en ligne. La majorité vérifiée, le mot de passe qui vaut signature, un récapitulatif PDF figé, la version du texte, et ce qui reste à faire pour les mineurs.