ajoute le dossier WebDAV de l'élève (bloc C)

Dossier personnel de l'élève monté comme lecteur réseau : rclone
sert le dossier local, l'authentification est déléguée au portail
via auth_request Nginx (mêmes identifiants que la connexion), sans
nouveau système de comptes. Nettoyé par la purge RGPD comme les
autres données d'élève.

Corrige au passage un vrai bug d'isolation trouvé en testant avec
deux comptes jetables : rewrite injectait la variable posée par
auth_request_set, mais rewrite s'exécute dans une phase Nginx
antérieure à cette variable, donc toujours vide - un élève pouvait
lire le fichier d'un autre en devinant son nom. Corrigé par une
location à regex nommée + proxy_pass. Détails : ADR 0008.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-14 13:04:40 +02:00
parent cdf79d2823
commit e549f03e1b
19 changed files with 553 additions and 8 deletions

View file

@ -9,15 +9,17 @@ use App\Services\Stockage\StockageTravauxEleves;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* 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,
* corrections, quiz et devoirs maison (chantier « vrai cockpit »), ainsi que
* les annonces, observations, le chat élève ↔ prof (séparation vitrine/
* application, 2026-10-13) et les événements d'examen (2026-10-14). Le
* compte du prof est conservé. Le journal ne garde que des identifiants et
* des nombres, jamais de nom d'élève.
* application, 2026-10-13), les événements d'examen (2026-10-14) et le
* dossier WebDAV de chaque élève (bloc C, 2026-10-14). 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).
@ -33,6 +35,7 @@ class PurgerClassesArchivees extends Command
$classes = Classe::whereNotNull('archivee_a')
->where('archivee_a', '<=', now()->subDays(Classe::DELAI_PURGE_JOURS))
->withCount('eleves')
->with('eleves:id,classe_id')
->get();
$purgees = 0;
@ -46,13 +49,18 @@ class PurgerClassesArchivees extends Command
continue;
}
// Les fichiers d'Object Storage ne sont pas liés par une clé
// étrangère : à supprimer nous-mêmes avant la cascade en base.
// Les fichiers d'Object Storage et les dossiers WebDAV ne sont
// pas liés par une clé étrangère : à supprimer nous-mêmes avant
// la cascade en base.
RenduDevoir::whereHas('devoir', fn ($q) => $q->where('classe_id', $classe->id))
->whereNotNull('fichier_chemin')
->pluck('fichier_chemin')
->each(fn (string $chemin) => StockageTravauxEleves::supprimer($chemin));
foreach ($classe->eleves as $eleve) {
Storage::disk('webdav')->deleteDirectory((string) $eleve->id);
}
DB::transaction(fn () => $classe->delete());
CacheTableauDeBordProf::vider($classe->user_id);
Log::info('Purge RGPD : classe supprimée', [

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* « Mes fichiers » (bloc C, CLAUDE.md) : le dossier personnel de l'élève,
* montable comme lecteur réseau en WebDAV (voir WebdavAuthController et
* ansible/roles/portail/templates/nginx-portail.conf.j2). Pas de liste de
* fichiers ici : la page ne fait qu'expliquer comment monter le lecteur,
* la navigation dans les fichiers se fait dans l'explorateur du système.
*/
class MesFichiersController extends Controller
{
public function show(): View
{
/** @var Eleve $eleve */
$eleve = Auth::guard('eleve')->user();
return view('mes-fichiers', ['eleve' => $eleve, 'urlWebdav' => rtrim(config('app.url'), '/').'/webdav/']);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
/**
* Sous-requête interne de Nginx (auth_request, bloc C, WebDAV des élèves) :
* mêmes identifiants que la connexion, jamais de session, jamais appelable
* directement depuis l'extérieur (location « internal » de Nginx, voir
* ansible/roles/portail/templates/nginx-portail.conf.j2). Répond 200 avec
* l'id de l'élève en en-tête (le sous-dossier auquel Nginx le limite), ou
* 401 sinon.
*/
class WebdavAuthController extends Controller
{
public function verifier(Request $request): Response
{
$identifiant = $request->getUser();
$code = $request->getPassword();
if ($identifiant === null || $code === null) {
return $this->refuser();
}
$cle = 'webdav:'.$identifiant.'|'.$request->ip();
if (RateLimiter::tooManyAttempts($cle, 5)) {
return $this->refuser();
}
$succes = Auth::guard('eleve')->once(['identifiant' => $identifiant, 'password' => $code]);
if (! $succes) {
RateLimiter::hit($cle, 300);
return $this->refuser();
}
RateLimiter::clear($cle);
/** @var Eleve $eleve */
$eleve = Auth::guard('eleve')->user();
Storage::disk('webdav')->makeDirectory((string) $eleve->id);
return response('', 200)->withHeaders(['X-Eleve-Dossier' => (string) $eleve->id]);
}
private function refuser(): Response
{
return response('', 401);
}
}