ajoute les pages légales, la purge RGPD et sert polices et scripts depuis le portail

Pages /legal et /confidentialite réécrites d'après le système réel, purge
des classes archivées depuis 90 jours (prouvée par Pest), archivage côté
prof. Polices, xterm.js et CodeMirror ne sont plus chargés depuis Google
ni jsDelivr. Pages d'aperçu signalées, sans vrais établissements ni faux
chiffres. Journaux Laravel gardés 14 jours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-09 21:07:25 +02:00
parent 0a5df89201
commit e4a6aaebb1
62 changed files with 822 additions and 238 deletions

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Classe;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis
* 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines
* et corrections. Le compte du prof est conservé. Le journal ne garde que
* des identifiants et des nombres, jamais de nom d'élève.
*
* Les sauvegardes chiffrées contenant encore ces données expirent d'elles-
* mêmes au plus 4 semaines plus tard (rotation, docs/runbooks/sauvegardes.md).
*/
class PurgerClassesArchivees extends Command
{
protected $signature = 'classes:purger-archivees';
protected $description = 'Supprime les classes archivées depuis 90 jours et les données de leurs élèves';
public function handle(): void
{
$classes = Classe::whereNotNull('archivee_a')
->where('archivee_a', '<=', now()->subDays(Classe::DELAI_PURGE_JOURS))
->withCount('eleves')
->get();
$purgees = 0;
foreach ($classes as $classe) {
// Ne peut arriver qu'en cas d'anomalie (un lab expire en 45 min) :
// on ne supprime pas une session dont la stack Heat vivrait encore.
if ($classe->aDesLabsEnCours()) {
Log::warning('Purge RGPD reportée : lab en cours', ['classe_id' => $classe->id]);
continue;
}
DB::transaction(fn () => $classe->delete());
Log::info('Purge RGPD : classe supprimée', [
'classe_id' => $classe->id,
'eleves' => $classe->eleves_count,
'archivee_a' => $classe->archivee_a->toDateString(),
]);
$purgees++;
}
$this->info("{$purgees} classe(s) purgée(s).");
}
}

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\Seance;
use App\Services\GenerateurCompteEleve;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -124,6 +125,33 @@ class ClasseController extends Controller
];
}
/**
* Fin d'année : plus de séance possible, et suppression définitive de la
* classe et des données de ses élèves 90 jours plus tard.
*/
public function archiver(Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
if ($classe->aDesLabsEnCours()) {
return back()->withErrors(['archivage' => 'Des élèves ont encore un lab en cours : termine la séance avant d’archiver.']);
}
$classe->forceFill(['archivee_a' => now()])->save();
$classe->seances()->whereIn('statut', [Seance::PLANIFIEE, Seance::SALLE_ATTENTE, Seance::EN_COURS])
->update(['statut' => Seance::TERMINEE, 'terminee_a' => now()]);
return redirect()->route('mes-classes.show', $classe);
}
public function desarchiver(Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$classe->forceFill(['archivee_a' => null])->save();
return redirect()->route('mes-classes.show', $classe);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);

View file

@ -24,6 +24,10 @@ class SeanceController extends Controller
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
if ($classe->estArchivee()) {
return back()->withErrors(['prevue_a' => 'Cette classe est archivée : désarchive-la pour lancer une séance.']);
}
$donnees = $request->validate([
'lab_id' => ['required', 'exists:labs,id'],
'prevue_a' => ['nullable', 'date_format:Y-m-d\TH:i'],
@ -130,6 +134,7 @@ class SeanceController extends Controller
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
abort_if($seance->classe->estArchivee(), 403);
LabSession::firstOrCreate(
['seance_id' => $seance->id, 'eleve_id' => $eleve->id],

View file

@ -41,7 +41,8 @@ class PolitiqueSecuriteContenu
$communes = [
'default-src' => "'self'",
'img-src' => "'self' data:",
'font-src' => "'self' https://fonts.gstatic.com",
// Polices servies par le portail (Vite), depuis l'étape 14.
'font-src' => "'self'",
'object-src' => "'none'",
'base-uri' => "'self'",
'form-action' => "'self'",
@ -54,13 +55,17 @@ class PolitiqueSecuriteContenu
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'",
// Attributs style= présents dans le gabarit commun : sans
// risque d'exécution de code, tolérés.
'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com",
'style-src' => "'self' 'unsafe-inline'",
'connect-src' => "'self' wss://{$request->getHost()}",
]
: [
'script-src' => "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com",
'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
'connect-src' => "'self'",
// jsDelivr : Pyodide et sql.js, téléchargés par /ide à la
// demande (WebAssembly, d'où 'wasm-unsafe-eval', qui n'autorise
// pas eval() en JavaScript). Tailwind : CDN d'exécution des
// pages du design. Voir docs/dette.md.
'script-src' => "'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdn.tailwindcss.com",
'style-src' => "'self' 'unsafe-inline'",
'connect-src' => "'self' https://cdn.jsdelivr.net",
'frame-src' => 'https://www.youtube-nocookie.com',
];

View file

@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class Classe extends Model
@ -58,4 +59,22 @@ class Classe extends Model
{
return $this->archivee_a !== null;
}
/**
* RGPD (CLAUDE.md) : une classe archivée et toutes les données de ses
* élèves sont supprimées 90 jours après l'archivage (classes:purger-archivees).
*/
public const DELAI_PURGE_JOURS = 90;
public function supprimeeLe(): ?Carbon
{
return $this->archivee_a?->copy()->addDays(self::DELAI_PURGE_JOURS);
}
public function aDesLabsEnCours(): bool
{
return LabSession::whereIn('seance_id', $this->seances()->select('id'))
->whereIn('etat', [LabSession::ADMIS, LabSession::ACTIF])
->exists();
}
}