Commit 1ee089da authored by Kourser's avatar Kourser
Browse files

Suppression : effacer les fichiers, pas seulement les lignes

La cascade en base n'emporte que des lignes. Supprimer un exercice ou une
organisation laissait donc sur le disque, indéfiniment, les images téléversées
par les joueurs pendant l'exercice — de la donnée personnelle conservée sans
finalité, et sans aucun moyen de l'effacer. SECURITY.md promettait déjà cet
effacement ; il n'était vrai que de la ligne.

Les cinq chemins de suppression relèvent maintenant les clés AVANT de supprimer
— après, la cascade les a effacées et plus rien ne dit quels fichiers
appartenaient à l'exercice — puis effacent celles que plus aucune ligne ne
porte : exercice, purge de rétention, séance de découverte fermée ou échue,
organisation supprimée depuis la console d'instance.

Le comptage de références n'est pas une précaution de confort. Une même clé est
PARTAGÉE : le logo d'une organisation est recopié dans chaque exercice créé, et
la duplication recopie l'assetPath des documents. Effacer sur la seule foi des
clés de l'exercice supprimé crevait le logo de l'organisation — vérifié en
sabotant volontairement le filtre, la recette le rattrape.

Remplacer ou retirer un logo abandonnait son fichier de la même façon : c'est
le même défaut, traité au même endroit.

Pour les installations antérieures, qui portent les fichiers de tout ce qui a
été supprimé avant cette version, balayer-fichiers.ts les mesure puis les
efface sur --confirm.

Le harnais de recette sait désormais envoyer du multipart : les routes de
téléversement n'étaient pas testables.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent bfb4036e
Loading
Loading
Loading
Loading
+16 −5
Changes for SECURITY.md: 16 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -382,11 +382,22 @@ de chat. Les garde-fous :
  généré en `cuid`, qui est partiellement prévisible (compteur et empreinte machine).
- **Rien ne sort de l'exercice** : les images sont servies depuis le stockage local, jamais
  depuis un service tiers.
- **RGPD** : une image téléversée puis abandonnée avant l'envoi reste orpheline en base et sur
  le disque. Elle est emportée par la suppression de l'exercice et par la purge de rétention,
  mais aucune purge intermédiaire n'existe à ce jour. Rappeler aux joueurs que le contenu
  d'un exercice doit rester **fictif** : une capture d'écran réelle peut contenir des données
  personnelles.
- **RGPD** : la suppression d'un exercice ou d'une organisation efface les **fichiers**, et pas
  seulement les lignes. La cascade en base n'emporte jamais un fichier : elle laissait sur le
  disque les images téléversées par les joueurs, indéfiniment et sans moyen de les effacer.
  Les cinq chemins de suppression (exercice, purge de rétention, séance de découverte fermée
  ou échue, organisation supprimée depuis la console d'instance) relèvent désormais les clés
  **avant** la suppression, puis effacent celles que plus aucune ligne ne porte
  (`apps/api/src/common/stored-files.service.ts`).
- Le filtrage par référence n'est pas une précaution de confort : une même clé est **partagée**
  — le logo d'une organisation est recopié dans chaque exercice créé, la duplication recopie
  l'`assetPath` des documents. Effacer sur la seule foi des clés de l'exercice supprimé crevait
  le logo de l'organisation ; la recette couvre ce cas précisément.
- Une image téléversée puis abandonnée avant l'envoi reste orpheline. Les instances antérieures
  portent de la même façon les fichiers de tout ce qui a été supprimé avant cette version :
  `apps/api/src/scripts/balayer-fichiers.ts` les mesure, puis les efface sur `--confirm`.
- Rappeler aux joueurs que le contenu d'un exercice doit rester **fictif** : une capture d'écran
  réelle peut contenir des données personnelles.

## Fidélité des exercices clos

+2 −1
Changes for apps/api/package.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -13,7 +13,8 @@
    "db:migrate:deploy": "prisma migrate deploy",
    "db:seed": "ts-node prisma/seed.ts",
    "mfa:reset": "ts-node src/scripts/reset-mfa.ts",
    "instance:amorcer": "ts-node src/scripts/amorcer-instance.ts"
    "instance:amorcer": "ts-node src/scripts/amorcer-instance.ts",
    "fichiers:balayer": "ts-node src/scripts/balayer-fichiers.ts"
  },
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
+2 −0
Changes for apps/api/src/app.module.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import { AuditModule } from './common/audit.module';
import { TenantStatusModule } from './common/tenant-status.module';
import { SessionEpochModule } from './common/session-epoch.module';
import { AppUrlModule } from './common/app-url.module';
import { StoredFilesModule } from './common/stored-files.module';
import { InstanceModule } from './instance/instance.module';
import { BugsModule } from './bugs/bugs.module';
import { OrganisationModule } from './organisation/organisation.module';
@@ -47,6 +48,7 @@ function staticWebModules(): DynamicModule[] {
    TenantStatusModule,
    SessionEpochModule,
    AppUrlModule,
    StoredFilesModule,
    PlatformMailModule,
    HealthModule,
    AuthModule,
+15 −0
Changes for apps/api/src/common/stored-files.module.ts: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Global, Module } from '@nestjs/common';
import { StoredFilesService } from './stored-files.service';

/**
 * Global : les cinq chemins de suppression (exercice, retention, bac a sable,
 * purge des seances, console d'instance) doivent tous l'appeler. L'importer un
 * a un invitait a en oublier un — et un oubli ici ne se voit pas, il laisse
 * simplement des fichiers derriere lui.
 */
@Global()
@Module({
  providers: [StoredFilesService],
  exports: [StoredFilesService],
})
export class StoredFilesModule {}
+175 −0
Changes for apps/api/src/common/stored-files.service.ts: 175 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { readdir } from 'node:fs/promises';
import { Injectable, Logger } from '@nestjs/common';
import type { PrismaClient } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { deleteUpload, UPLOADS_DIR } from './uploads';
import { orphanFiles, unreferenced, uploadKeys } from './stored-files';

/**
 * Effacement des fichiers televerses devenus inutiles.
 *
 * La cascade en base ne suffit pas : elle emporte les LIGNES, jamais les
 * fichiers. Une organisation supprimee laissait donc sur le disque les images
 * televersees par ses joueurs pendant l'exercice — de la donnee personnelle
 * conservee sans finalite, et sans moyen de l'effacer.
 *
 * Le geste se fait en deux temps, et l'ordre compte :
 *   1. RELEVER les cles avant la suppression — apres, la cascade les a effacees
 *      et plus rien ne dit quels fichiers appartenaient a l'exercice ;
 *   2. n'effacer, APRES la suppression, que les cles que plus aucune ligne ne
 *      porte (cf. `stored-files.ts` : une meme cle est partagee).
 */
@Injectable()
export class StoredFilesService {
  private readonly logger = new Logger(StoredFilesService.name);

  constructor(private readonly prisma: PrismaService) {}

  /**
   * Cles portees par un ou plusieurs exercices : images, pieces jointes, logo propre.
   *
   * A appeler AVANT la suppression. Une liste vide en entree ne declenche aucune
   * requete : la purge de retention passe souvent sans rien a purger.
   */
  async keysOfExercises(exerciseIds: readonly string[]): Promise<string[]> {
    if (exerciseIds.length === 0) return [];
    const where = { exerciseId: { in: [...exerciseIds] } };
    const [images, documents, exercises] = await Promise.all([
      this.prisma.exerciseImage.findMany({ where, select: { path: true } }),
      this.prisma.document.findMany({ where, select: { assetPath: true } }),
      this.prisma.exercise.findMany({
        where: { id: { in: [...exerciseIds] } },
        select: { socialAccountLogoPath: true },
      }),
    ]);
    return uploadKeys([
      ...images.map((i) => i.path),
      ...documents.map((d) => d.assetPath),
      ...exercises.map((e) => e.socialAccountLogoPath),
    ]);
  }

  /**
   * Cles portees par une organisation entiere, logo compris.
   *
   * Sert aux suppressions de tenant — console d'instance, bac a sable, purge des
   * seances echues — ou tout est en cascade sur l'organisation.
   */
  async keysOfTenant(tenantId: string): Promise<string[]> {
    const [images, documents, exercises, tenant] = await Promise.all([
      this.prisma.exerciseImage.findMany({ where: { tenantId }, select: { path: true } }),
      this.prisma.document.findMany({ where: { tenantId }, select: { assetPath: true } }),
      this.prisma.exercise.findMany({
        where: { tenantId },
        select: { socialAccountLogoPath: true },
      }),
      this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { logoPath: true } }),
    ]);
    return uploadKeys([
      ...images.map((i) => i.path),
      ...documents.map((d) => d.assetPath),
      ...exercises.map((e) => e.socialAccountLogoPath),
      tenant?.logoPath,
    ]);
  }

  /**
   * Efface les fichiers de `keys` que plus aucune ligne ne reference.
   *
   * A appeler APRES la suppression en base. Ne leve jamais : un fichier resistant
   * ne doit pas faire echouer, ni pire annuler, une suppression deja acquise en
   * base. Renvoie le nombre de fichiers reellement retires.
   */
  async purgeUnreferenced(keys: readonly string[]): Promise<number> {
    if (keys.length === 0) return 0;
    try {
      const aEffacer = unreferenced([...keys], await this.stillReferenced(keys));
      let efface = 0;
      for (const key of aEffacer) if (await deleteUpload(key)) efface += 1;
      return efface;
    } catch (err) {
      this.logger.error(`Effacement des fichiers televerses impossible : ${String(err)}`);
      return 0;
    }
  }

  /** Parmi `keys`, celles qu'une ligne porte encore. */
  private async stillReferenced(keys: readonly string[]): Promise<string[]> {
    const dans = { in: [...keys] };
    const [images, documents, exercises, tenants] = await Promise.all([
      this.prisma.exerciseImage.findMany({ where: { path: dans }, select: { path: true } }),
      this.prisma.document.findMany({ where: { assetPath: dans }, select: { assetPath: true } }),
      this.prisma.exercise.findMany({
        where: { socialAccountLogoPath: dans },
        select: { socialAccountLogoPath: true },
      }),
      this.prisma.tenant.findMany({ where: { logoPath: dans }, select: { logoPath: true } }),
    ]);
    return uploadKeys([
      ...images.map((i) => i.path),
      ...documents.map((d) => d.assetPath),
      ...exercises.map((e) => e.socialAccountLogoPath),
      ...tenants.map((t) => t.logoPath),
    ]);
  }

  /**
   * Balayage : fichiers presents sur le disque qu'aucune ligne ne reference.
   *
   * Pour les installations existantes, dont le repertoire porte les fichiers
   * accumules avant que la suppression n'efface quoi que ce soit. `dryRun`
   * permet de mesurer avant d'effacer.
   *
   * Parcours a UN niveau, comme `saveUpload` ecrit : a plat, sous un nom d'UUID.
   */
  async sweepOrphans(dryRun = false): Promise<{ scanned: number; orphans: number; deleted: number }> {
    let entries: string[];
    try {
      entries = (await readdir(UPLOADS_DIR, { withFileTypes: true }))
        .filter((e) => e.isFile())
        .map((e) => e.name);
    } catch {
      // Repertoire absent : personne n'a encore rien televerse.
      return { scanned: 0, orphans: 0, deleted: 0 };
    }

    const orphelins = orphanFiles(entries, await referencedUploadKeys(this.prisma));
    if (dryRun) return { scanned: entries.length, orphans: orphelins.length, deleted: 0 };

    let deleted = 0;
    for (const nom of orphelins) if (await deleteUpload(`uploads/${nom}`)) deleted += 1;
    if (deleted > 0) this.logger.log(`${deleted} fichier(s) televerse(s) orphelin(s) efface(s)`);
    return { scanned: entries.length, orphans: orphelins.length, deleted };
  }

}

/**
 * Toutes les cles televersees encore portees par une ligne, tous tenants confondus.
 *
 * Hors de la classe, et prenant son client en argument : la commande de balayage
 * (`src/scripts/balayer-fichiers.ts`) l'appelle avec un `PrismaClient` nu, sans
 * monter l'application — la monter demarrerait les minuteries de purge, ce qu'une
 * commande ponctuelle n'a pas a faire. Une seule definition des QUATRE colonnes
 * qui portent une cle : en ajouter une cinquieme ailleurs se verrait ici.
 */
export async function referencedUploadKeys(prisma: PrismaClient): Promise<string[]> {
  const [images, documents, exercises, tenants] = await Promise.all([
    prisma.exerciseImage.findMany({ select: { path: true } }),
    prisma.document.findMany({
      where: { assetPath: { not: null } },
      select: { assetPath: true },
    }),
    prisma.exercise.findMany({
      where: { socialAccountLogoPath: { not: null } },
      select: { socialAccountLogoPath: true },
    }),
    prisma.tenant.findMany({ where: { logoPath: { not: null } }, select: { logoPath: true } }),
  ]);
  return uploadKeys([
    ...images.map((i) => i.path),
    ...documents.map((d) => d.assetPath),
    ...exercises.map((e) => e.socialAccountLogoPath),
    ...tenants.map((t) => t.logoPath),
  ]);
}
Loading