diff --git a/ansible/roles/portail/defaults/main.yml b/ansible/roles/portail/defaults/main.yml index b70ae4e..7cad83d 100644 --- a/ansible/roles/portail/defaults/main.yml +++ b/ansible/roles/portail/defaults/main.yml @@ -24,3 +24,5 @@ portail_port_visio: 8090 # routeur par nom (stream Nginx) l'envoie directement à coturn. portail_domaine_turn: turn.syllabuslab.fr portail_port_turn_tls: 5349 +# Bloc C : dossier de l'élève en WebDAV (rclone, local pour l'instant). +portail_port_webdav: 8765 diff --git a/ansible/roles/portail/handlers/main.yml b/ansible/roles/portail/handlers/main.yml index c3648fe..b6179aa 100644 --- a/ansible/roles/portail/handlers/main.yml +++ b/ansible/roles/portail/handlers/main.yml @@ -14,3 +14,9 @@ name: portail-horizon state: restarted enabled: true + +- name: redémarrer rclone-webdav + ansible.builtin.systemd: + name: rclone-webdav + state: restarted + enabled: true diff --git a/ansible/roles/portail/tasks/09-webdav.yml b/ansible/roles/portail/tasks/09-webdav.yml new file mode 100644 index 0000000..332edf2 --- /dev/null +++ b/ansible/roles/portail/tasks/09-webdav.yml @@ -0,0 +1,35 @@ +--- +# Bloc C (CLAUDE.md) : dossier de l'élève monté comme lecteur réseau WebDAV. +# rclone sert un dossier local en WebDAV (bascule vers un backend S3 +# possible plus tard sans changer l'architecture : rclone sait servir les +# deux). Nginx fait l'authentification (déléguée au portail, mêmes +# identifiants que la connexion) et restreint chaque élève à son seul +# sous-dossier avant de relayer vers rclone — voir 05-nginx.yml et +# App\Http\Controllers\WebdavAuthController. +- name: installer rclone + ansible.builtin.apt: + name: rclone + state: present + +- name: créer le dossier racine du WebDAV des élèves + ansible.builtin.file: + path: "{{ portail_dossier_app }}/storage/app/webdav" + state: directory + owner: www-data + group: www-data + mode: "0750" + +- name: déployer le service systemd de rclone (WebDAV) + ansible.builtin.template: + src: rclone-webdav.service.j2 + dest: /etc/systemd/system/rclone-webdav.service + mode: "0644" + notify: + - recharger systemd + - redémarrer rclone-webdav + +- name: activer et démarrer rclone (WebDAV) + ansible.builtin.systemd: + name: rclone-webdav + state: started + enabled: true diff --git a/ansible/roles/portail/tasks/main.yml b/ansible/roles/portail/tasks/main.yml index 529f932..84d942f 100644 --- a/ansible/roles/portail/tasks/main.yml +++ b/ansible/roles/portail/tasks/main.yml @@ -14,6 +14,9 @@ - name: banque de quiz officielle (contenu éditorial de paillasse-contenus/) ansible.builtin.import_tasks: 08-banque-quiz.yml +- name: dossier de l'élève en WebDAV (bloc C) + ansible.builtin.import_tasks: 09-webdav.yml + - name: Nginx ansible.builtin.import_tasks: 05-nginx.yml diff --git a/ansible/roles/portail/templates/nginx-portail.conf.j2 b/ansible/roles/portail/templates/nginx-portail.conf.j2 index 411087a..39ca36a 100644 --- a/ansible/roles/portail/templates/nginx-portail.conf.j2 +++ b/ansible/roles/portail/templates/nginx-portail.conf.j2 @@ -39,6 +39,47 @@ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } + # Bloc C : dossier de l'élève en WebDAV (rclone en local, voir + # 09-webdav.yml). "internal" : jamais appelable depuis l'extérieur, + # seulement par la sous-requête auth_request ci-dessous. + # $request_uri d'une sous-requête auth_request reflète la requête + # d'origine ("/webdav/…", pas "/interne/webdav-auth") : constaté le + # 2026-10-14 (404 systématique côté Laravel, qui routait donc sur le + # mauvais chemin). D'où REQUEST_URI écrit en dur ci-dessous — l'URI de + # cette location est fixe, il n'y a rien d'autre qu'elle ne pourrait + # valoir. + location = /interne/webdav-auth { + internal; + fastcgi_pass unix:/run/php/php{{ portail_php_version }}-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME {{ portail_dossier_app }}/public/index.php; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param REQUEST_URI /interne/webdav-auth; + fastcgi_param REQUEST_METHOD GET; + } + + # "rewrite" s'exécute dans une phase antérieure à auth_request_set (le + # rewrite verrait donc toujours $eleve_dossier vide, et chaque élève + # atterrirait à la racine commune du dossier — constaté le 2026-10-14, + # un élève pouvait lire les fichiers d'un autre). proxy_pass avec le nom + # capturé par la regex de la location, lui, est évalué après coup. + location ~ ^/webdav/(?.*)$ { + auth_request /interne/webdav-auth; + auth_request_set $eleve_dossier $upstream_http_x_eleve_dossier; + error_page 401 = @webdav_non_authentifie; + client_max_body_size 100m; + proxy_pass http://127.0.0.1:{{ portail_port_webdav }}/$eleve_dossier/$chemin_webdav; + proxy_http_version 1.1; + } + + # Sans cela, un explorateur de fichiers (Windows/macOS) ne propose + # jamais de ressaisir les identifiants après un échec. + location @webdav_non_authentifie { + add_header WWW-Authenticate 'Basic realm="Syllabus WebDAV"' always; + return 401; + } + location ~ /\.(?!well-known) { deny all; } diff --git a/ansible/roles/portail/templates/rclone-webdav.service.j2 b/ansible/roles/portail/templates/rclone-webdav.service.j2 new file mode 100644 index 0000000..50d4fea --- /dev/null +++ b/ansible/roles/portail/templates/rclone-webdav.service.j2 @@ -0,0 +1,27 @@ +# Géré par Ansible (rôle portail) — ne pas modifier à la main. +# Bloc C (CLAUDE.md) : dossier de l'élève monté comme lecteur réseau. +# N'écoute qu'en local (127.0.0.1) : Nginx authentifie (délégué au portail) +# et restreint chaque élève à son sous-dossier avant de relayer ici — voir +# ansible/roles/portail/templates/nginx-portail.conf.j2 et +# App\Http\Controllers\WebdavAuthController. +[Unit] +Description=Serveur WebDAV (rclone) pour le dossier des élèves — Syllabus +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +# --dir-cache-time : sans ça, les dossiers créés par Laravel (un par élève, +# en dehors de rclone) restent invisibles jusqu'à 5 minutes (la valeur par +# défaut) — constaté le 2026-10-14, un nouveau dossier élève répondait 404 +# tant que rclone ne l'avait pas découvert lui-même. +ExecStart=/usr/bin/rclone serve webdav {{ portail_dossier_app }}/storage/app/webdav --addr 127.0.0.1:{{ portail_port_webdav }} --no-modtime --vfs-cache-mode off --dir-cache-time 1s +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths={{ portail_dossier_app }}/storage/app/webdav + +[Install] +WantedBy=multi-user.target diff --git a/docs/adr/0008-webdav-eleve-auth-request.md b/docs/adr/0008-webdav-eleve-auth-request.md new file mode 100644 index 0000000..1bbcfa1 --- /dev/null +++ b/docs/adr/0008-webdav-eleve-auth-request.md @@ -0,0 +1,82 @@ +# ADR 0008 — WebDAV de l'élève : authentification déléguée (Nginx auth_request) + rclone + +- **Date** : 2026-10-14 +- **Statut** : accepté + +## Contexte + +Bloc C du CLAUDE.md : « dossier de l'élève montable comme lecteur réseau. » +Besoin : chaque élève a un dossier personnel, accessible en WebDAV (Windows/ +macOS/Linux savent tous monter un lecteur réseau WebDAV nativement), avec les +mêmes identifiants que la connexion, et **jamais** accès au dossier d'un +autre élève. + +## Décisions + +**`rclone serve webdav`, pas un module WebDAV Nginx/Apache.** rclone sait +servir aussi bien un dossier local qu'un bucket S3 avec la même commande — +bascule possible plus tard vers Hetzner Object Storage sans rien changer à +l'architecture d'authentification. Empreinte mémoire très faible (~20 Mo). + +**Authentification déléguée au portail via `auth_request` (Nginx), pas une +authentification propre à rclone.** rclone n'a pas de notion de comptes +multiples ; Nginx vérifie les identifiants auprès du portail (même logique +que la connexion, `Auth::guard('eleve')->once(...)`) via une sous-requête +interne (`WebdavAuthController`), puis limite la suite de la requête au seul +sous-dossier de l'élève authentifié. + +**Un seul process rclone pour tous les élèves, pas un par élève.** Chaque +sous-dossier (`storage/app/webdav//`) est injecté dans le chemin +transmis à rclone via `proxy_pass http://127.0.0.1:.../$eleve_dossier/$chemin` +— $eleve_dossier vient de l'en-tête `X-Eleve-Dossier` posé par +`WebdavAuthController` après vérification des identifiants. + +## Deux bugs trouvés en vérifiant en réel (pas en local, Nginx n'a pas de test unitaire) + +**1. `rewrite` ne voit jamais la variable posée par `auth_request_set`.** +Nginx exécute les directives `rewrite` dans la phase *rewrite*, qui a lieu +**avant** la phase *access* où tourne `auth_request`/`auth_request_set` — +même écrites après dans le fichier, textuellement. Une première version +utilisait `rewrite ^/webdav/(.*)$ /$eleve_dossier/$1 break;` : `$eleve_dossier` +y était toujours vide, donc tous les fichiers de tous les élèves atterrissaient +à la racine commune du dossier WebDAV — **un élève pouvait lire le fichier +d'un autre simplement en devinant son nom.** Jamais vu en local (pas de test +automatisé qui exerce une vraie pile Nginx+rclone) : découvert en testant +avec deux comptes élèves jetables en vrai sur le serveur. +Corrigé : une `location` avec une regex à groupe nommé +(`location ~ ^/webdav/(?.*)$`) et `proxy_pass` avec la +variable directement dans l'URL — `proxy_pass` construit sa requête dans une +phase plus tardive, où `$eleve_dossier` est déjà posée. + +**2. Le cache de répertoires de rclone (`--dir-cache-time`, 5 min par +défaut) ne voit pas les dossiers créés par Laravel.** Chaque dossier élève +est créé par PHP (`Storage::disk('webdav')->makeDirectory(...)`), en dehors +de rclone — qui le découvre seulement à la prochaine actualisation de son +cache de liste. Un élève dont le dossier venait d'être créé pouvait recevoir +une 404 pendant plusieurs minutes. Corrigé par `--dir-cache-time 1s` dans le +service systemd (`infra`/`ansible/roles/portail/templates/rclone-webdav.service.j2`). +`--vfs-cache-mode off` (déjà en place, pour ne jamais garder de contenu de +fichier en cache) ne couvre pas ce cache de répertoires : deux réglages +distincts. + +**3. `SCRIPT_NAME`/`REQUEST_URI` ne sont pas fiables dans une sous-requête +`auth_request` quand on construit son propre appel FastCGI.** `$request_uri` +dans la sous-requête reflétait la requête d'origine (`/webdav/…`), pas l'URI +de la sous-requête elle-même (`/interne/webdav-auth`) : Laravel routait donc +sur le mauvais chemin et répondait 404 à chaque appel. Comme cette location +a une URI toujours identique (`= /interne/webdav-auth`), la solution a été +d'écrire `SCRIPT_FILENAME`, `SCRIPT_NAME` et `REQUEST_URI` en dur dans les +paramètres FastCGI plutôt que de les déduire de variables Nginx. + +## Conséquences + +- Testé en réel avec trois comptes élèves jetables : upload, lecture, + PROPFIND, isolation stricte entre élèves (un 404 propre, jamais le + contenu d'un autre), dossier d'un nouvel élève utilisable immédiatement. + Comptes et fichiers de test supprimés ensuite. +- Cette classe de bug (interaction entre phases Nginx, cache rclone) n'est + détectable qu'en testant la vraie pile déployée — aucun test Pest ne + l'aurait vue, puisque les tests appellent le contrôleur Laravel + directement, sans passer par Nginx ni rclone. +- Reste en dette : pas de quota de stockage par élève, dossier local + seulement (pas encore sur Hetzner Object Storage). diff --git a/docs/dette.md b/docs/dette.md index 24612e7..a64ce8e 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -39,3 +39,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait. | **Serveur réel : 31 Go de RAM et 230 Go RAID 1, pas 64 Go / 2×500 Go comme écrit dans le CLAUDE.md (section 3)** | Constaté en vérifiant l'état réel avant la démo Ceph (`free -h`, `lsblk`). Change la vraie marge de capacité (hot pool + OpenStack + portail + tout chantier futur) : à corriger dans le CLAUDE.md, ou à traiter avec une vraie mise à niveau matérielle si le pilote (semaine 8) montre que ça manque. | Avant le pilote si la capacité manque | 2026-10-14 | | Ceph : démo mono-serveur seulement (1 OSD sur fichier épars, pas de réplication, pas branché à Cinder/Glance) | `ceph_pool_default_size=1` et un seul OSD acceptés uniquement parce que c'est une démo de compréhension, sans donnée réelle. Le vrai cluster (plusieurs OSD, réplication, intégré à Cinder) attend le 2ᵉ serveur du CLAUDE.md. Voir [ADR 0007](adr/0007-ceph-demo-mono-serveur.md). | 2ᵉ serveur (bloc C) | 2026-10-14 | | Ceph : bootstrap fait à la main, pas encore rejouable par Ansible | Comme Kolla à l'étape 2 : acceptable pour une démo, à automatiser si ce cluster doit durer. Le fichier systemd du loop device est suivi dans `infra/ceph/` mais déployé à la main. | Si la démo devient durable | 2026-10-14 | +| WebDAV de l'élève : stockage local, pas S3 ; pas de quota par élève | Dossier local (`storage/app/webdav/`), partage le disque serré avec OpenStack et le portail — pas de limite de taille par élève. rclone sait servir un backend S3 sans changement d'architecture (voir [ADR 0008](adr/0008-webdav-eleve-auth-request.md)) : à basculer en même temps que le bucket S3 des devoirs (`docs/runbooks/stockage-s3.md`), et à ajouter un quota si l'usage réel le demande. | Quand le bucket S3 existe / si l'usage le demande | 2026-10-14 | diff --git a/docs/journal.md b/docs/journal.md index 21b2a58..5f07c20 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1199,3 +1199,55 @@ Deux leçons distinctes : d'abord, ne jamais faire confiance à la documentation **Suite** Prochain chantier du bloc C, annoncé par Adam : WebDAV, puis ElasticSearch. + +## 2026-10-14 — WebDAV de l'élève (bloc C) + +Deuxième chantier du bloc C : dossier personnel de l'élève, monté comme +lecteur réseau. Deux bugs réels trouvés et corrigés en vérifiant en vrai sur +le serveur, détaillés dans [ADR 0008](adr/0008-webdav-eleve-auth-request.md). + +**Fait** + +- `rclone serve webdav` (20 Mo de RAM) sert le dossier local de chaque élève (`storage/app/webdav//`), accessible en `https://syllabuslab.fr/webdav/` avec les identifiants de connexion habituels. +- L'authentification est déléguée au portail via `auth_request` de Nginx (`WebdavAuthController`, sous-requête interne, jamais appelable depuis l'extérieur) — pas de nouveau système de comptes. +- Page « Mes fichiers » (menu du compte, élève seulement) : explique comment monter le lecteur sous Windows/macOS/Linux. +- Dossier WebDAV de l'élève supprimé automatiquement lors de la purge RGPD d'une classe archivée (comme pour le stockage S3 des devoirs). + +**Bug grave trouvé en testant en réel (pas en local)** + +Première version : un `rewrite` injectait le sous-dossier de l'élève +(posé par `auth_request_set` après vérification des identifiants) dans le +chemin transmis à rclone. Testé avec deux comptes élèves jetables : le +fichier déposé par le premier atterrissait à la racine du dossier WebDAV +commun, et **le second élève pouvait le lire en devinant juste son nom** — +un élève aurait pu accéder aux fichiers d'un autre. Cause : `rewrite` +s'exécute dans une phase Nginx antérieure à celle où `auth_request_set` pose +sa variable, qui était donc toujours vide au moment du `rewrite`, même +écrite après dans le fichier. Corrigé avec une `location` à regex nommée et +`proxy_pass` construisant l'URL directement (phase plus tardive, la +variable y est déjà posée) — revérifié avec trois comptes élèves jetables, +isolation stricte confirmée (404 propre, jamais le contenu d'un autre), +comptes supprimés ensuite. + +Deuxième bug, plus bénin : le cache de répertoires de rclone (distinct du +cache de contenu de fichier, déjà désactivé) ne voyait pas un dossier élève +tout juste créé par Laravel — 404 pendant que le cache n'avait pas encore +rafraîchi sa liste. Corrigé en réduisant ce cache à 1 seconde (volume +d'élèves trop faible pour que ça coûte quoi que ce soit). + +**Comprendre** + +Aucun test Pest n'aurait pu voir ni l'un ni l'autre bug : les tests +appellent `WebdavAuthController` directement, sans jamais passer par Nginx +ni par rclone — la pile complète (Nginx → auth_request → PHP-FPM → rclone → +disque) n'existe qu'en production. Pour ce genre de chantier (plusieurs +briques d'infra qui communiquent entre elles), la vérification en réel avec +de vraies requêtes HTTP sur deux comptes différents n'est pas une étape de +confort après coup : c'est la seule façon de voir ce bug-là. La leçon +« rewrite s'exécute avant auth_request_set » est aussi le genre de piège +d'ordre des phases Nginx qui ne saute pas aux yeux en relisant le fichier +de haut en bas — une bonne question à savoir expliquer en entretien SRE. + +**Suite** + +Prochain chantier du bloc C : ElasticSearch. diff --git a/docs/runbooks/webdav.md b/docs/runbooks/webdav.md new file mode 100644 index 0000000..5fc23a3 --- /dev/null +++ b/docs/runbooks/webdav.md @@ -0,0 +1,67 @@ +# Runbook — WebDAV de l'élève + +Dossier personnel de l'élève, monté comme lecteur réseau (bloc C du +CLAUDE.md). Détail des choix et des bugs trouvés en vérifiant en réel : +[ADR 0008](../adr/0008-webdav-eleve-auth-request.md). + +## Où sont les choses + +| Élément | Emplacement | +|---|---| +| Fichiers des élèves | `/var/www/portail/storage/app/webdav//` (jamais versionné, jamais synchronisé par un déploiement) | +| Service WebDAV | `rclone-webdav.service` (systemd), écoute en local seulement sur `127.0.0.1:8765` | +| Authentification | `App\Http\Controllers\WebdavAuthController`, sous-requête Nginx `/interne/webdav-auth` (jamais appelable de l'extérieur) | +| Config Nginx | bloc `/webdav/` dans `ansible/roles/portail/templates/nginx-portail.conf.j2` | + +Adresse pour l'élève : `https://syllabuslab.fr/webdav/` (voir « Mes fichiers » +dans le menu du compte). Identifiants : les mêmes que la connexion. + +## Vérifier que ça fonctionne + +``` +systemctl is-active rclone-webdav +curl -s -o /dev/null -w "%{http_code}\n" https://syllabuslab.fr/webdav/ # 401 attendu sans identifiants +``` + +Avec un compte élève jetable (créé et supprimé ensuite via tinker, jamais un +compte réel) : + +``` +curl -u IDENTIFIANT:CODE -X PROPFIND -H "Depth: 1" https://syllabuslab.fr/webdav/ +curl -u IDENTIFIANT:CODE -T fichier.txt https://syllabuslab.fr/webdav/fichier.txt +``` + +## Pourquoi RAM et disque sont serrés + +Comme pour Ceph (voir `docs/runbooks/ceph.md`) : ce serveur a réellement +31 Go de RAM et 230 Go en RAID 1, pas 64 Go / 2×500 Go comme écrit dans le +CLAUDE.md. rclone utilise ~20 Mo, négligeable — mais chaque fichier déposé +par un élève prend de l'espace disque réel, partagé avec OpenStack et le +portail. Pas de quota par élève pour l'instant (voir `docs/dette.md`). + +## Nettoyer entièrement (si besoin) + +``` +ssh paillasse +sudo systemctl disable --now rclone-webdav.service +sudo rm /etc/systemd/system/rclone-webdav.service +sudo rm -rf /var/www/portail/storage/app/webdav +sudo systemctl daemon-reload +``` +Puis retirer le bloc `/webdav/` et `/interne/webdav-auth` du vhost (relancer +`ansible-playbook portail.yml` après avoir enlevé `09-webdav.yml` du +`main.yml` du rôle). + +## Écueils rencontrés (pour ne pas les redécouvrir) + +- **Jamais `rewrite` pour injecter une variable posée par `auth_request_set`** + dans le chemin proxié — `rewrite` s'exécute avant que la variable existe. + Utiliser une `location` à regex nommée + `proxy_pass` avec la variable + dans l'URL. +- **`--vfs-cache-mode off` ne désactive pas le cache de répertoires de + rclone** (`--dir-cache-time`, 5 min par défaut) : un dossier créé par + Laravel reste invisible de rclone jusqu'à l'expiration du cache, sauf à + mettre `--dir-cache-time` très bas (1 s ici, le volume est faible). +- **`SCRIPT_NAME`/`REQUEST_URI` ne sont pas fiables** dans une sous-requête + `auth_request` qui fait son propre appel FastCGI : les écrire en dur + quand l'URI de la location est fixe. diff --git a/portail/app/Console/Commands/PurgerClassesArchivees.php b/portail/app/Console/Commands/PurgerClassesArchivees.php index b1ffccd..ff7fc49 100644 --- a/portail/app/Console/Commands/PurgerClassesArchivees.php +++ b/portail/app/Console/Commands/PurgerClassesArchivees.php @@ -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', [ diff --git a/portail/app/Http/Controllers/MesFichiersController.php b/portail/app/Http/Controllers/MesFichiersController.php new file mode 100644 index 0000000..b30cd16 --- /dev/null +++ b/portail/app/Http/Controllers/MesFichiersController.php @@ -0,0 +1,25 @@ +user(); + + return view('mes-fichiers', ['eleve' => $eleve, 'urlWebdav' => rtrim(config('app.url'), '/').'/webdav/']); + } +} diff --git a/portail/app/Http/Controllers/WebdavAuthController.php b/portail/app/Http/Controllers/WebdavAuthController.php new file mode 100644 index 0000000..3d3065b --- /dev/null +++ b/portail/app/Http/Controllers/WebdavAuthController.php @@ -0,0 +1,58 @@ +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); + } +} diff --git a/portail/config/filesystems.php b/portail/config/filesystems.php index 37d8fca..fc316aa 100644 --- a/portail/config/filesystems.php +++ b/portail/config/filesystems.php @@ -47,6 +47,18 @@ return [ 'report' => false, ], + // Bloc C, WebDAV (2026-10-14) : dossier personnel de l'élève, servi + // en WebDAV par rclone (voir ansible/roles/portail/templates/ + // rclone-webdav.service.j2), un sous-dossier par élève (son id). + // Local pour l'instant ; bascule vers S3 possible plus tard sans + // changer l'architecture (rclone sait servir les deux). + 'webdav' => [ + 'driver' => 'local', + 'root' => storage_path('app/webdav'), + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/portail/resources/views/layouts/app.blade.php b/portail/resources/views/layouts/app.blade.php index eef9db5..ca9de22 100644 --- a/portail/resources/views/layouts/app.blade.php +++ b/portail/resources/views/layouts/app.blade.php @@ -31,6 +31,12 @@ Paramètres + @if (! $estProf) + + + Mes fichiers + + @endif Aide et assistance diff --git a/portail/resources/views/mes-fichiers.blade.php b/portail/resources/views/mes-fichiers.blade.php new file mode 100644 index 0000000..03da9d9 --- /dev/null +++ b/portail/resources/views/mes-fichiers.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.app') + +@section('titre', 'Mes fichiers') + +@section('contenu') +
+

Mes fichiers

+

+ Un dossier personnel, à toi seul, que tu peux ouvrir comme un lecteur réseau depuis ton ordinateur — pas besoin de passer par le site pour y déposer ou récupérer un fichier. +

+ +
+

Adresse à utiliser

+

{{ $urlWebdav }}

+

+ Identifiant et code secret : les mêmes que pour te connecter ({{ $eleve->identifiant }}). +

+
+ +
+

Comment monter le lecteur

+
+
+
Windows
+
Explorateur de fichiers → « Ce PC » → « Ajouter un lecteur réseau » → coller l'adresse ci-dessus.
+
+
+
macOS
+
Finder → menu « Aller » → « Se connecter au serveur… » → coller l'adresse ci-dessus.
+
+
+
Linux
+
Gestionnaire de fichiers → « Se connecter à un serveur » → type WebDAV (davs) → coller l'adresse ci-dessus.
+
+
+
+
+@endsection diff --git a/portail/routes/web.php b/portail/routes/web.php index cfc2623..b7010b0 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -20,6 +20,7 @@ use App\Http\Controllers\InscriptionController; use App\Http\Controllers\MaClasseController; use App\Http\Controllers\MesCoursController; use App\Http\Controllers\MesDevoirsController; +use App\Http\Controllers\MesFichiersController; use App\Http\Controllers\MesNotesController; use App\Http\Controllers\ObservationController; use App\Http\Controllers\ParametresController; @@ -36,6 +37,7 @@ use App\Http\Controllers\SessionQuizController; use App\Http\Controllers\SignalementController; use App\Http\Controllers\TokenApiController; use App\Http\Controllers\VisioController; +use App\Http\Controllers\WebdavAuthController; use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Support\Facades\Route; @@ -157,8 +159,17 @@ Route::middleware(['auth:eleve', 'session.unique:eleve', 'maj.activite'])->group // Chantier « vrai cockpit », étape 3 : Devoirs maison. Route::get('/devoirs/{devoir}', [DevoirEleveController::class, 'show'])->name('devoirs.eleve.show'); Route::post('/devoirs/{devoir}/rendre', [DevoirEleveController::class, 'rendre'])->name('devoirs.eleve.rendre'); + + // Bloc C : dossier personnel en WebDAV. + Route::get('/mes-fichiers', [MesFichiersController::class, 'show'])->name('mes-fichiers'); }); +// Sous-requête interne de Nginx seulement (location "internal", voir +// ansible/roles/portail/templates/nginx-portail.conf.j2) : jamais appelable +// depuis l'extérieur. Pas de middleware de session : chaque appel s'identifie +// par en-tête HTTP Basic (mêmes identifiants que la connexion élève). +Route::get('/interne/webdav-auth', [WebdavAuthController::class, 'verifier'])->name('interne.webdav-auth'); + // --- Espace prof (protégé, session unique) --- Route::middleware(['auth:web', 'session.unique:web'])->group(function () { Route::get('/mes-classes', [ClasseController::class, 'index'])->name('mes-classes.index'); diff --git a/portail/tests/Feature/PagesPubliquesTest.php b/portail/tests/Feature/PagesPubliquesTest.php index dc2c516..92e1800 100644 --- a/portail/tests/Feature/PagesPubliquesTest.php +++ b/portail/tests/Feature/PagesPubliquesTest.php @@ -12,7 +12,7 @@ test('chaque page publique s’affiche sans erreur', function () { && $r->getName() !== null && ! str_contains($r->uri(), '{') && ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth'))) - ->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || $r->uri() === 'up') + ->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || str_starts_with($r->uri(), 'interne/') || $r->uri() === 'up') // Redirections voulues (ex. /statut vers le site de statut ; pages de // suivi d'une demande pour rejoindre une classe, sans demande en cours). ->reject(fn ($r) => $r->getActionName() === '\\Illuminate\\Routing\\RedirectController' @@ -31,7 +31,8 @@ test('chaque page publique s’affiche sans erreur', function () { test('aucune page publique ne charge de police depuis un service externe', function () { $pages = collect(Route::getRoutes()) ->filter(fn ($r) => in_array('GET', $r->methods(), true) && $r->getName() !== null && ! str_contains($r->uri(), '{')) - ->reject(fn ($r) => collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth'))); + ->reject(fn ($r) => collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth'))) + ->reject(fn ($r) => str_starts_with($r->uri(), 'interne/')); foreach ($pages as $route) { $html = $this->get('/'.ltrim($route->uri(), '/'))->getContent(); @@ -56,7 +57,8 @@ test('les scripts en ligne des pages publiques sont du JavaScript valide', funct $pages = collect(Route::getRoutes()) ->filter(fn ($r) => in_array('GET', $r->methods(), true) && $r->getName() !== null && ! str_contains($r->uri(), '{')) ->reject(fn ($r) => collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth'))) - ->reject(fn ($r) => $r->getActionName() === '\Illuminate\Routing\RedirectController'); + ->reject(fn ($r) => $r->getActionName() === '\Illuminate\Routing\RedirectController') + ->reject(fn ($r) => str_starts_with($r->uri(), 'interne/')); $erreurs = []; foreach ($pages as $route) { diff --git a/portail/tests/Feature/WebdavTest.php b/portail/tests/Feature/WebdavTest.php new file mode 100644 index 0000000..c2d12cb --- /dev/null +++ b/portail/tests/Feature/WebdavTest.php @@ -0,0 +1,69 @@ +get(route('interne.webdav-auth'))->assertStatus(401); +}); + +test('la sous-requête Nginx refuse un code secret incorrect', function () { + $eleve = Eleve::factory()->create(); + + $this->withBasicAuth($eleve->identifiant, 'mauvais-code') + ->get(route('interne.webdav-auth')) + ->assertStatus(401); +}); + +test('la sous-requête Nginx accepte les identifiants de connexion et pose le dossier de l’élève', function () { + $eleve = Eleve::factory()->create(['code_secret' => bcrypt('monCode123')]); + + $this->withBasicAuth($eleve->identifiant, 'monCode123') + ->get(route('interne.webdav-auth')) + ->assertStatus(200) + ->assertHeader('X-Eleve-Dossier', (string) $eleve->id); + + Storage::disk('webdav')->assertExists((string) $eleve->id); +}); + +test('5 échecs de suite bloquent temporairement même avec le bon code juste après', function () { + $eleve = Eleve::factory()->create(['code_secret' => bcrypt('monCode123')]); + + for ($i = 0; $i < 5; $i++) { + $this->withBasicAuth($eleve->identifiant, 'mauvais') + ->get(route('interne.webdav-auth')) + ->assertStatus(401); + } + + $this->withBasicAuth($eleve->identifiant, 'monCode123') + ->get(route('interne.webdav-auth')) + ->assertStatus(401); + + RateLimiter::clear('webdav:'.$eleve->identifiant.'|127.0.0.1'); +}); + +test('un élève connecté voit l’adresse de son dossier WebDAV', function () { + $eleve = Eleve::factory()->create(); + + eleve($this, $eleve)->get(route('mes-fichiers')) + ->assertOk() + ->assertSee('/webdav/') + ->assertSee($eleve->identifiant); +}); + +test('purger une classe archivée supprime aussi le dossier WebDAV de ses élèves', function () { + $classe = Classe::factory()->create(); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + Storage::disk('webdav')->put($eleve->id.'/photo.jpg', 'contenu'); + + $classe->forceFill(['archivee_a' => now()->subDays(91)])->save(); + $this->artisan('classes:purger-archivees'); + + Storage::disk('webdav')->assertMissing($eleve->id.'/photo.jpg'); +});