From e549f03e1badbe1f7ee952ad5471d2fdf60454e4 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Wed, 14 Oct 2026 13:04:40 +0200 Subject: [PATCH] =?UTF-8?q?ajoute=20le=20dossier=20WebDAV=20de=20l'=C3=A9l?= =?UTF-8?q?=C3=A8ve=20(bloc=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ansible/roles/portail/defaults/main.yml | 2 + ansible/roles/portail/handlers/main.yml | 6 ++ ansible/roles/portail/tasks/09-webdav.yml | 35 ++++++++ ansible/roles/portail/tasks/main.yml | 3 + .../portail/templates/nginx-portail.conf.j2 | 41 ++++++++++ .../templates/rclone-webdav.service.j2 | 27 ++++++ docs/adr/0008-webdav-eleve-auth-request.md | 82 +++++++++++++++++++ docs/dette.md | 1 + docs/journal.md | 52 ++++++++++++ docs/runbooks/webdav.md | 67 +++++++++++++++ .../Commands/PurgerClassesArchivees.php | 18 ++-- .../Controllers/MesFichiersController.php | 25 ++++++ .../Http/Controllers/WebdavAuthController.php | 58 +++++++++++++ portail/config/filesystems.php | 12 +++ portail/resources/views/layouts/app.blade.php | 6 ++ .../resources/views/mes-fichiers.blade.php | 38 +++++++++ portail/routes/web.php | 11 +++ portail/tests/Feature/PagesPubliquesTest.php | 8 +- portail/tests/Feature/WebdavTest.php | 69 ++++++++++++++++ 19 files changed, 553 insertions(+), 8 deletions(-) create mode 100644 ansible/roles/portail/tasks/09-webdav.yml create mode 100644 ansible/roles/portail/templates/rclone-webdav.service.j2 create mode 100644 docs/adr/0008-webdav-eleve-auth-request.md create mode 100644 docs/runbooks/webdav.md create mode 100644 portail/app/Http/Controllers/MesFichiersController.php create mode 100644 portail/app/Http/Controllers/WebdavAuthController.php create mode 100644 portail/resources/views/mes-fichiers.blade.php create mode 100644 portail/tests/Feature/WebdavTest.php 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'); +});