Base de travail V7.8 reprise du répertoire de travail
Commit technique : reproduit à l'identique l'état non commité de la copie de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits) afin que le travail d'ergonomie qui suit soit isolable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Le Dockerfile fait COPY . /var/www/html : tout ce qui est listé ici reste
|
||||||
|
# hors de l'image, donc hors de la racine web et hors des couches publiées.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Historique et documentation de version : inutiles dans l'image.
|
||||||
|
README*.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Journaux, sauvegardes et artefacts locaux
|
||||||
|
*.log
|
||||||
|
*.sql.gz
|
||||||
|
*.dump
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
@@ -4,12 +4,17 @@ APP_PORT=8080
|
|||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
|
|
||||||
# MariaDB
|
# MariaDB
|
||||||
|
# Remplacer impérativement les deux mots de passe avant tout déploiement.
|
||||||
DB_NAME=pta
|
DB_NAME=pta
|
||||||
DB_USER=pta
|
DB_USER=pta
|
||||||
DB_PASSWORD=pta
|
DB_PASSWORD=a_remplacer
|
||||||
DB_ROOT_PASSWORD=pta
|
DB_ROOT_PASSWORD=a_remplacer_aussi
|
||||||
|
|
||||||
|
# Débogage : 1 renvoie le détail des exceptions au navigateur. Laisser à 0.
|
||||||
|
PTA_DEBUG=0
|
||||||
|
|
||||||
# phpMyAdmin
|
# phpMyAdmin
|
||||||
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
|
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
|
||||||
|
# phpMyAdmin demande désormais un identifiant MariaDB à chaque connexion.
|
||||||
PMA_BIND=127.0.0.1
|
PMA_BIND=127.0.0.1
|
||||||
PMA_PORT=8081
|
PMA_PORT=8081
|
||||||
|
|||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Configuration locale : contient les mots de passe MariaDB.
|
||||||
|
# Seul .env.example doit être versionné.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dumps et sauvegardes de base
|
||||||
|
*.sql.gz
|
||||||
|
*.dump
|
||||||
|
database/backup/
|
||||||
|
database/*_backup*.sql
|
||||||
|
|
||||||
|
# Journaux et fichiers temporaires
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Dépendances éventuelles
|
||||||
|
/vendor/
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
# Artefacts d'éditeur et de système
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
188
CLAUDE.md
Normal file
188
CLAUDE.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
PTA (Planning de Travail Annualisé) — application PHP 8.3 multipage (MVC maison) pour la
|
||||||
|
planification annualisée des agents d'accueils de loisirs périscolaires/extrascolaires.
|
||||||
|
|
||||||
|
**Aucune dépendance** : pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
|
||||||
|
suite de tests. Uniquement PHP + PDO/MySQL + JavaScript vanilla. Ne pas introduire de dépendance
|
||||||
|
sans raison forte : le PDF (`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont
|
||||||
|
volontairement réécrits à la main.
|
||||||
|
|
||||||
|
**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages
|
||||||
|
utilisateur, commentaires. Conserver cette langue pour tout nouveau code.
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrage complet (nginx :8080, phpMyAdmin :8081, MariaDB, php-fpm)
|
||||||
|
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# IMPORTANT : le code source est COPIÉ dans l'image au build (Dockerfile), il n'est pas monté.
|
||||||
|
# Toute modification PHP/JS/CSS exige un rebuild des DEUX services :
|
||||||
|
# (nginx sert lui-même les assets statiques depuis sa propre copie de /var/www/html)
|
||||||
|
docker compose up -d --build php nginx
|
||||||
|
|
||||||
|
docker compose logs -f php nginx db
|
||||||
|
docker compose ps
|
||||||
|
docker compose down # arrêt en conservant les données
|
||||||
|
docker compose down -v # DÉTRUIT le volume MariaDB
|
||||||
|
|
||||||
|
# Accès SQL direct
|
||||||
|
docker compose exec db mariadb -upta -p pta
|
||||||
|
|
||||||
|
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
|
||||||
|
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
|
||||||
|
```
|
||||||
|
|
||||||
|
Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué **qu'à la création initiale du volume**
|
||||||
|
MariaDB (`docker-entrypoint-initdb.d`). Sur une base existante, toute évolution de schéma doit être
|
||||||
|
appliquée manuellement en SQL — il n'y a pas d'outil de migration.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Deux types de points d'entrée, tous à la racine ou dans `api/`
|
||||||
|
|
||||||
|
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
|
||||||
|
|
||||||
|
**Pages HTML** — `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, `coverage.php`,
|
||||||
|
`structures.php`, `pending.php`, `vacations.php`, `administration.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
require __DIR__ . '/page_bootstrap.php';
|
||||||
|
runPage('planning'); // -> PageController::planning()
|
||||||
|
```
|
||||||
|
|
||||||
|
`page_bootstrap.php` attrape les `Throwable` et affiche une page d'erreur HTML.
|
||||||
|
|
||||||
|
**Endpoints JSON/PDF** — `api/*.php` plus `save_planning.php`, `validate_planning.php`,
|
||||||
|
`reopen_planning.php` (ces trois-là sont restés à la racine pour raisons historiques) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
|
||||||
|
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
(new PlanningController($pdo))->show();
|
||||||
|
```
|
||||||
|
|
||||||
|
`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable,
|
||||||
|
car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** (ils émettent des
|
||||||
|
octets binaires via `PdfResponse::inline()`).
|
||||||
|
|
||||||
|
Pour ajouter un endpoint : créer le fichier mince dans `api/`, ajouter la méthode au contrôleur.
|
||||||
|
Pour ajouter une page : fichier racine + méthode dans `PageController` + vue `app/Views/pages/` +
|
||||||
|
partiel `app/Views/partials/` + module JS + **inscription de la clé dans `Access::allowedPages()`
|
||||||
|
et dans le tableau `$navigation` de `app/Views/layouts/header.php`**.
|
||||||
|
|
||||||
|
### Chaîne d'un appel
|
||||||
|
|
||||||
|
```
|
||||||
|
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
|
||||||
|
page.php → PageController → Models → Views/pages/x.php
|
||||||
|
├── layouts/header.php (nav filtrée par rôle)
|
||||||
|
├── partials/x.php (markup)
|
||||||
|
└── layouts/footer.php (injecte window.PTA_CONFIG + scripts)
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables
|
||||||
|
d'environnement `PTA_DB_*`) et appelle `SchemaModel::assertCurrent()` **à chaque requête**. Ajouter
|
||||||
|
une table ou une colonne obligatoire impose de mettre à jour les listes de `SchemaModel`, sinon
|
||||||
|
l'application entière refuse de démarrer.
|
||||||
|
|
||||||
|
### Rôles et périmètres (`app/Core/Access.php`)
|
||||||
|
|
||||||
|
Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
|
||||||
|
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider).
|
||||||
|
|
||||||
|
`role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement
|
||||||
|
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
|
||||||
|
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
|
||||||
|
mécanique de droits.
|
||||||
|
|
||||||
|
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
|
||||||
|
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
|
||||||
|
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
|
||||||
|
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
|
||||||
|
échouer l'application si la migration manque — c'est volontairement fail-closed.
|
||||||
|
|
||||||
|
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
|
||||||
|
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
|
||||||
|
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
|
||||||
|
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
|
||||||
|
garde ni sans `Csrf::assertPayload($payload)`.**
|
||||||
|
|
||||||
|
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
|
||||||
|
`csrf_token: window.PTA_CONFIG.csrfToken`.
|
||||||
|
|
||||||
|
### Contraintes de sécurité à respecter
|
||||||
|
|
||||||
|
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
|
||||||
|
Tout `<script>` inline ajouté à une vue doit porter `nonce="<?= $cspNonce ?>"`, et aucun
|
||||||
|
`onclick=` / `style=` inline ne fonctionnera.
|
||||||
|
- **`PTA.utils.escapeHtml` échappe aussi les guillemets** et est donc valable en contexte texte
|
||||||
|
comme en contexte attribut. Ne pas la réimplémenter localement : c'est une variante incomplète
|
||||||
|
qui avait introduit un XSS stocké.
|
||||||
|
- **Ordre des blocs nginx** : nginx retient la première regex correspondante. Tout nouveau refus
|
||||||
|
doit être placé **avant** `location ~ \.php$`, sinon il est sans effet.
|
||||||
|
- `PTA_DEBUG` reste à `0`. Les détails d'exception passent par `Security::reportThrowable()`, qui
|
||||||
|
journalise côté serveur et ne renvoie rien au client.
|
||||||
|
- `session_start()` ne doit jamais être appelé directement : passer par `App\Core\Session::start()`.
|
||||||
|
|
||||||
|
### JavaScript
|
||||||
|
|
||||||
|
Pas de bundler ni de module ES. Chaque fichier est une IIFE qui lit le global `window.PTA` publié
|
||||||
|
par `core.js` (`config`, `elements`, `state`, `shared`, `utils`, `modules`) et s'enregistre :
|
||||||
|
|
||||||
|
```js
|
||||||
|
PTA.modules.agentOverview = { init, load };
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageController` décide quels scripts et quels modules charger (tableaux `scripts` / `modules`),
|
||||||
|
`footer.php` les injecte, puis `page-bootstrap.js` appelle `init()` sur chaque module déclaré et
|
||||||
|
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
|
||||||
|
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
|
||||||
|
|
||||||
|
Les assets sont versionnés à la main : `assets/style.css?v=7.7` et `assets/js/*.js?v=7.7` dans
|
||||||
|
`layouts/header.php` / `layouts/footer.php`. **Bumper ce numéro après toute modification de JS/CSS**,
|
||||||
|
sinon les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||||
|
|
||||||
|
### Modèle métier
|
||||||
|
|
||||||
|
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
|
||||||
|
contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
|
||||||
|
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
|
||||||
|
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
|
||||||
|
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
|
||||||
|
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
|
||||||
|
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
|
||||||
|
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
|
||||||
|
elles sont importées depuis l'API publique data.education.gouv.fr via
|
||||||
|
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
|
||||||
|
|
||||||
|
### Piège : logique de validation dupliquée
|
||||||
|
|
||||||
|
`database/pta_prod_schema_mariadb.sql` définit des vues (`v_suivi_quota_annuel`,
|
||||||
|
`v_plannings_en_attente_validation`, …) et des procédures (`sp_controler_planning`,
|
||||||
|
`sp_valider_planning`) — **aucune n'est appelée par le code PHP**. Les contrôles réellement
|
||||||
|
appliqués vivent dans `PlanningController::validate()`, `AnnualOverviewController::validateYear()`
|
||||||
|
et `PtaModel`. Modifier une règle métier impose de traiter le PHP ; le SQL correspondant est du
|
||||||
|
patrimoine à ne pas prendre pour la source de vérité.
|
||||||
|
|
||||||
|
Les contrôles renvoyés au client suivent partout la forme `{niveau: ERREUR|ALERTE|INFO, code,
|
||||||
|
message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation explicite
|
||||||
|
(409 + rappel avec `force_quota: true`).
|
||||||
|
|
||||||
|
## Points d'attention
|
||||||
|
|
||||||
|
- nginx interdit l'accès direct à `/app/`, `/database/`, `/docker/`, aux fichiers d'amorçage et aux
|
||||||
|
extensions de configuration (`docker/nginx/default.conf`) — ces règles doivent suivre toute
|
||||||
|
réorganisation de l'arborescence.
|
||||||
|
- phpMyAdmin n'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. Ne jamais redéfinir
|
||||||
|
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
|
||||||
|
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
|
||||||
|
déploiement réel.
|
||||||
|
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
|
||||||
|
utiles pour comprendre une décision, mais seul le code fait foi.
|
||||||
@@ -64,6 +64,12 @@ Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis
|
|||||||
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
|
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
|
||||||
|
|
||||||
|
|
||||||
|
## V7.8 — Sécurité et droits en base
|
||||||
|
|
||||||
|
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
|
||||||
|
vues SQL sont décrits dans `README_V7_8.md`. La migration
|
||||||
|
`database/migration_v7_8_securite_acces.sql` est obligatoire.
|
||||||
|
|
||||||
## V7.4 — Rôles et droits d’accès
|
## V7.4 — Rôles et droits d’accès
|
||||||
|
|
||||||
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.
|
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ Application :
|
|||||||
http://IP_DU_SERVEUR:8080
|
http://IP_DU_SERVEUR:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut.
|
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
|
||||||
|
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
|
||||||
|
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
|
||||||
|
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
|
||||||
|
atteint le port.
|
||||||
|
|
||||||
Sur le serveur lui-même :
|
Sur le serveur lui-même :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
134
README_V7_8.md
Normal file
134
README_V7_8.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# PTA V7.8 — Correctifs de sécurité et descente des droits en base
|
||||||
|
|
||||||
|
Cette version ne change aucune règle métier. Elle corrige les failles relevées
|
||||||
|
lors de l'audit et déplace l'intégralité des règles d'accès dans la base.
|
||||||
|
|
||||||
|
## Failles corrigées
|
||||||
|
|
||||||
|
### 1. phpMyAdmin ouvert sans mot de passe (critique)
|
||||||
|
|
||||||
|
`docker-compose.yml` fournissait `PMA_USER` et `PMA_PASSWORD` au conteneur
|
||||||
|
phpMyAdmin. Ces deux variables font basculer phpMyAdmin en `auth_type=config` :
|
||||||
|
toute personne atteignant le port arrivait sur une session **déjà
|
||||||
|
authentifiée**, avec accès complet en lecture et en écriture à la base. Le
|
||||||
|
`.env` publiait de surcroît ce port sur `0.0.0.0`, donc sur tout le réseau.
|
||||||
|
|
||||||
|
Les deux variables ont été retirées et le port est revenu sur `127.0.0.1`.
|
||||||
|
phpMyAdmin réclame désormais un identifiant MariaDB à chaque connexion. Pour y
|
||||||
|
accéder depuis un poste distant : `ssh -L 8081:127.0.0.1:8081 utilisateur@serveur`.
|
||||||
|
|
||||||
|
### 2. XSS stocké via les attributs HTML
|
||||||
|
|
||||||
|
`PTA.utils.escapeHtml` s'appuyait sur `textContent` / `innerHTML`, qui
|
||||||
|
n'échappent ni `"` ni `'`. La fonction était pourtant utilisée dans une
|
||||||
|
vingtaine de contextes d'attribut (`value="…"`, `title="…"`, `aria-label="…"`).
|
||||||
|
Un libellé contenant un guillemet permettait de sortir de l'attribut et
|
||||||
|
d'injecter du script.
|
||||||
|
|
||||||
|
Les vecteurs réalistes étaient les libellés de vacances scolaires — importés
|
||||||
|
depuis une API externe — ainsi que les noms de lieux, les noms d'agents, les
|
||||||
|
libellés de diplôme et les commentaires PTA.
|
||||||
|
|
||||||
|
`escapeHtml` échappe désormais les cinq caractères et reste valable en contexte
|
||||||
|
texte comme en contexte attribut. L'implémentation dupliquée `escapeAttr` de
|
||||||
|
`annual-overview.js` a été supprimée au profit de la fonction commune.
|
||||||
|
|
||||||
|
### 3. Règles de refus nginx sans effet
|
||||||
|
|
||||||
|
Les blocs interdisant `config.php`, `db.php` et `page_bootstrap.php` étaient
|
||||||
|
placés **après** `location ~ \.php$`. nginx retenant la première expression
|
||||||
|
régulière correspondante, ces refus n'ont jamais été appliqués : les trois
|
||||||
|
fichiers d'amorçage étaient exécutables depuis le navigateur.
|
||||||
|
|
||||||
|
L'ordre des blocs a été corrigé et la couverture élargie (`/docker/`, fichiers
|
||||||
|
`.env`, `.sql`, `.ini`, `.log`, `.md`).
|
||||||
|
|
||||||
|
### 4. Divulgation d'informations techniques
|
||||||
|
|
||||||
|
`PTA_DEBUG` valait `1` par défaut : le message des exceptions — requêtes SQL,
|
||||||
|
noms de tables, chemins serveur — était renvoyé au navigateur. Les pages
|
||||||
|
d'erreur de `page_bootstrap.php` et de `role.php` affichaient de la même façon
|
||||||
|
`getMessage()`, et `db.php` y ajoutait l'hôte, l'utilisateur et le nom de la
|
||||||
|
base issus du message PDO.
|
||||||
|
|
||||||
|
Le mode debug est désormais désactivé par défaut. Tout détail technique est
|
||||||
|
journalisé côté serveur via `App\Core\Security::reportThrowable()` et n'atteint
|
||||||
|
le client que si `PTA_DEBUG` est explicitement activé.
|
||||||
|
|
||||||
|
### 5. Absence d'en-têtes de sécurité
|
||||||
|
|
||||||
|
Les réponses HTML portent maintenant une `Content-Security-Policy` **à nonce** :
|
||||||
|
`script-src 'self' 'nonce-…'`, sans `unsafe-inline`. PTA ne contient aucun
|
||||||
|
gestionnaire d'événement inline ni style inline, la politique peut donc rester
|
||||||
|
stricte. S'y ajoutent `X-Content-Type-Options`, `X-Frame-Options`,
|
||||||
|
`Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` et, en
|
||||||
|
HTTPS, `Strict-Transport-Security`.
|
||||||
|
|
||||||
|
Tout nouveau `<script>` inline doit porter `nonce="<?= $cspNonce ?>"`, sinon le
|
||||||
|
navigateur refusera de l'exécuter.
|
||||||
|
|
||||||
|
### 6. Session et CSRF
|
||||||
|
|
||||||
|
`session_start()` était appelé à trois endroits sans paramètres de cookie.
|
||||||
|
`App\Core\Session::start()` centralise le démarrage et impose `HttpOnly`,
|
||||||
|
`SameSite=Lax`, `use_strict_mode` et `secure` en HTTPS — y compris hors du
|
||||||
|
conteneur Docker, où le `php.ini` fourni ne s'applique pas.
|
||||||
|
|
||||||
|
Le changement de rôle (`role.php?action=reset`), qui modifiait l'état de la
|
||||||
|
session via un simple GET, exige désormais le jeton CSRF. Le jeton est
|
||||||
|
régénéré à chaque changement de rôle.
|
||||||
|
|
||||||
|
### 7. Secrets versionnables
|
||||||
|
|
||||||
|
Le dépôt n'avait ni `.gitignore` ni `.dockerignore`. Le `.env` contenant les
|
||||||
|
mots de passe MariaDB était prêt à être commité, et `COPY . /var/www/html`
|
||||||
|
copiait `.env` et `.git` dans l'image publiée. Les deux fichiers ont été créés.
|
||||||
|
|
||||||
|
## Descente des droits en base
|
||||||
|
|
||||||
|
`app/Core/Access.php` ne contient plus aucune règle : la matrice des droits et
|
||||||
|
les périmètres de visibilité sont lus dans des vues SQL.
|
||||||
|
|
||||||
|
| Vue | Rôle |
|
||||||
|
| ------------------------------ | ----------------------------------------------------------- |
|
||||||
|
| `v_acces_role_page` | pages autorisées par rôle |
|
||||||
|
| `v_acces_role_permission` | permissions fonctionnelles par rôle |
|
||||||
|
| `v_acces_identite_agent` | identités d'agents actifs sélectionnables |
|
||||||
|
| `v_acces_identite_structure` | lieux d'affectation actifs sélectionnables |
|
||||||
|
| `v_acces_agent_par_structure` | quels agents un lieu peut consulter (rattachement ou planif.) |
|
||||||
|
| `v_acces_perimetre_creneau` | lieu et planning d'un créneau, avant modification |
|
||||||
|
| `v_acces_perimetre_planning` | lieu et agent d'un planning, avant validation |
|
||||||
|
|
||||||
|
Les deux premières s'appuient sur les tables `acces_role_page` et
|
||||||
|
`acces_role_permission` : **modifier un droit se fait désormais en SQL, sans
|
||||||
|
redéploiement**. Une permission absente de la base vaut « refusé ».
|
||||||
|
|
||||||
|
`SchemaModel::assertCurrent()` vérifie la présence des sept vues au démarrage.
|
||||||
|
Si la migration n'a pas été jouée, l'application refuse de démarrer plutôt que
|
||||||
|
de tourner sans contrôle de périmètre.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Sauvegarder la base.
|
||||||
|
2. Exécuter une seule fois :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SOURCE database/migration_v7_8_securite_acces.sql;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Reconstruire les images, phpMyAdmin compris :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build php nginx
|
||||||
|
docker compose up -d --force-recreate phpmyadmin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reste à traiter
|
||||||
|
|
||||||
|
Le sélecteur de `role.php` n'est toujours **pas une authentification** :
|
||||||
|
n'importe quel visiteur peut choisir « Service Enfance » et obtenir le contrôle
|
||||||
|
complet de l'application. Tant que ce point n'est pas traité, aucune des
|
||||||
|
corrections ci-dessus ne protège une instance exposée sur Internet.
|
||||||
|
|
||||||
|
Le mot de passe MariaDB doit également être changé : le `.env` reprend encore la
|
||||||
|
valeur d'exemple.
|
||||||
@@ -9,6 +9,7 @@ use App\Core\Csrf;
|
|||||||
use App\Core\JsonResponse;
|
use App\Core\JsonResponse;
|
||||||
use App\Core\PdfResponse;
|
use App\Core\PdfResponse;
|
||||||
use App\Core\Request;
|
use App\Core\Request;
|
||||||
|
use App\Core\Security;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\QuotaModel;
|
use App\Models\QuotaModel;
|
||||||
use App\Models\StructureModel;
|
use App\Models\StructureModel;
|
||||||
@@ -410,7 +411,7 @@ final class AgentController
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
JsonResponse::send(500, [
|
JsonResponse::send(500, [
|
||||||
'error' => 'Impossible de créer l’agent.',
|
'error' => 'Impossible de créer l’agent.',
|
||||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Access;
|
use App\Core\Access;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\Csrf;
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Security;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\MotifModel;
|
use App\Models\MotifModel;
|
||||||
use App\Models\StructureModel;
|
use App\Models\StructureModel;
|
||||||
@@ -177,10 +178,12 @@ final class PageController extends Controller
|
|||||||
|
|
||||||
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
|
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
|
||||||
{
|
{
|
||||||
|
Security::sendHtmlHeaders();
|
||||||
$profile = $this->access->profile();
|
$profile = $this->access->profile();
|
||||||
$this->render('pages/' . $view, array_merge([
|
$this->render('pages/' . $view, array_merge([
|
||||||
'pageTitle' => $title,
|
'pageTitle' => $title,
|
||||||
'activePage' => $activePage,
|
'activePage' => $activePage,
|
||||||
|
'cspNonce' => Security::nonce(),
|
||||||
'csrfToken' => Csrf::ensureToken(),
|
'csrfToken' => Csrf::ensureToken(),
|
||||||
'scripts' => [],
|
'scripts' => [],
|
||||||
'modules' => [],
|
'modules' => [],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
|||||||
use App\Core\Access;
|
use App\Core\Access;
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\Csrf;
|
use App\Core\Csrf;
|
||||||
|
use App\Core\Security;
|
||||||
use App\Models\AgentModel;
|
use App\Models\AgentModel;
|
||||||
use App\Models\StructureModel;
|
use App\Models\StructureModel;
|
||||||
use PDO;
|
use PDO;
|
||||||
@@ -21,8 +22,13 @@ final class RoleController extends Controller
|
|||||||
{
|
{
|
||||||
$access = new Access($this->pdo);
|
$access = new Access($this->pdo);
|
||||||
|
|
||||||
|
// Le changement de rôle modifie l'état de la session : il est protégé
|
||||||
|
// par le jeton CSRF au même titre que les écritures de l'API.
|
||||||
if (($_GET['action'] ?? '') === 'reset') {
|
if (($_GET['action'] ?? '') === 'reset') {
|
||||||
|
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||||
|
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||||
$access->clear();
|
$access->clear();
|
||||||
|
}
|
||||||
header('Location: role.php', true, 302);
|
header('Location: role.php', true, 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -47,8 +53,10 @@ final class RoleController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Security::sendHtmlHeaders();
|
||||||
$this->render('pages/role', [
|
$this->render('pages/role', [
|
||||||
'pageTitle' => 'Choisir votre accès',
|
'pageTitle' => 'Choisir votre accès',
|
||||||
|
'cspNonce' => Security::nonce(),
|
||||||
'csrfToken' => Csrf::ensureToken(),
|
'csrfToken' => Csrf::ensureToken(),
|
||||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ namespace App\Core;
|
|||||||
|
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôle des rôles et des périmètres.
|
||||||
|
*
|
||||||
|
* Aucune règle de droit n'est écrite ici : les pages autorisées, les
|
||||||
|
* permissions fonctionnelles et les périmètres de visibilité sont lus dans les
|
||||||
|
* vues SQL créées par database/migration_v7_8_securite_acces.sql. Cette classe
|
||||||
|
* ne fait que poser la question à la base et appliquer la réponse.
|
||||||
|
*/
|
||||||
final class Access
|
final class Access
|
||||||
{
|
{
|
||||||
public const ROLE_AGENT = 'AGENT';
|
public const ROLE_AGENT = 'AGENT';
|
||||||
@@ -14,11 +22,15 @@ final class Access
|
|||||||
|
|
||||||
private const SESSION_KEY = 'pta_access';
|
private const SESSION_KEY = 'pta_access';
|
||||||
|
|
||||||
|
/** @var array<string, array<string, bool>> */
|
||||||
|
private array $permissionCache = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<int, string>> */
|
||||||
|
private array $pageCache = [];
|
||||||
|
|
||||||
public function __construct(private PDO $pdo)
|
public function __construct(private PDO $pdo)
|
||||||
{
|
{
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
Session::start();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isConfigured(): bool
|
public function isConfigured(): bool
|
||||||
@@ -85,13 +97,16 @@ final class Access
|
|||||||
$selection['structure_id'] = $structureId;
|
$selection['structure_id'] = $structureId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION[self::SESSION_KEY] = $selection;
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
|
$_SESSION[self::SESSION_KEY] = $selection;
|
||||||
|
// Le jeton CSRF suit le cycle de vie de la session : un changement de
|
||||||
|
// rôle invalide le jeton précédent.
|
||||||
|
unset($_SESSION['csrf_token']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
unset($_SESSION[self::SESSION_KEY]);
|
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,39 +143,61 @@ final class Access
|
|||||||
return $profile;
|
return $profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions fonctionnelles lues dans v_acces_role_permission.
|
||||||
|
* Une permission absente de la base vaut « refusé ».
|
||||||
|
*
|
||||||
|
* @return array<string, bool>
|
||||||
|
*/
|
||||||
public function permissions(): array
|
public function permissions(): array
|
||||||
{
|
{
|
||||||
$role = $this->role();
|
$role = $this->role();
|
||||||
$service = $role === self::ROLE_SERVICE;
|
if ($role === null) {
|
||||||
$responsable = $role === self::ROLE_RESPONSABLE;
|
return [];
|
||||||
|
|
||||||
return [
|
|
||||||
'read_only' => $role === self::ROLE_AGENT,
|
|
||||||
'can_edit_draft' => $service || $responsable,
|
|
||||||
'can_edit_cross_structure' => $service,
|
|
||||||
'can_validate' => $service,
|
|
||||||
'can_reopen' => $service || $responsable,
|
|
||||||
'can_use_templates' => $service,
|
|
||||||
'can_copy_full_week' => $service,
|
|
||||||
'can_view_pending' => $service,
|
|
||||||
'can_manage_coverage' => $service,
|
|
||||||
'can_manage_agents' => $service,
|
|
||||||
'can_manage_structures' => $service,
|
|
||||||
'can_manage_vacations' => $service,
|
|
||||||
'can_manage_teams' => $service,
|
|
||||||
'can_manage_pta' => $service,
|
|
||||||
'can_forecast_next_year' => $service,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isset($this->permissionCache[$role])) {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
|
||||||
|
);
|
||||||
|
$stmt->execute(['role' => $role]);
|
||||||
|
|
||||||
|
$permissions = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
|
||||||
|
}
|
||||||
|
$this->permissionCache[$role] = $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->permissionCache[$role];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPermission(string $permission): bool
|
||||||
|
{
|
||||||
|
return $this->permissions()[$permission] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages autorisées lues dans v_acces_role_page.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
public function allowedPages(): array
|
public function allowedPages(): array
|
||||||
{
|
{
|
||||||
return match ($this->role()) {
|
$role = $this->role();
|
||||||
self::ROLE_AGENT => ['agents', 'structures'],
|
if ($role === null) {
|
||||||
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
|
return [];
|
||||||
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
|
}
|
||||||
default => [],
|
|
||||||
};
|
if (!isset($this->pageCache[$role])) {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT page FROM v_acces_role_page WHERE role = :role ORDER BY ordre_affichage, page'
|
||||||
|
);
|
||||||
|
$stmt->execute(['role' => $role]);
|
||||||
|
$this->pageCache[$role] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pageCache[$role];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canAccessPage(string $page): bool
|
public function canAccessPage(string $page): bool
|
||||||
@@ -248,10 +285,7 @@ final class Access
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT p.id_structure
|
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
|
||||||
FROM creneau_horaire c
|
|
||||||
INNER JOIN planning p ON p.id_planning = c.id_planning
|
|
||||||
WHERE c.id_creneau = :id LIMIT 1'
|
|
||||||
);
|
);
|
||||||
$stmt->execute(['id' => $entryId]);
|
$stmt->execute(['id' => $entryId]);
|
||||||
$structureId = $stmt->fetchColumn();
|
$structureId = $stmt->fetchColumn();
|
||||||
@@ -267,7 +301,9 @@ final class Access
|
|||||||
if ($this->role() === self::ROLE_SERVICE) {
|
if ($this->role() === self::ROLE_SERVICE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT id_structure FROM v_acces_perimetre_planning WHERE id_planning = :id LIMIT 1'
|
||||||
|
);
|
||||||
$stmt->execute(['id' => $planningId]);
|
$stmt->execute(['id' => $planningId]);
|
||||||
$structureId = $stmt->fetchColumn();
|
$structureId = $stmt->fetchColumn();
|
||||||
if ($structureId === false) {
|
if ($structureId === false) {
|
||||||
@@ -293,14 +329,14 @@ final class Access
|
|||||||
|
|
||||||
private function activeAgentExists(int $agentId): bool
|
private function activeAgentExists(int $agentId): bool
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
|
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
|
||||||
$stmt->execute(['id' => $agentId]);
|
$stmt->execute(['id' => $agentId]);
|
||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeStructureExists(int $structureId): bool
|
private function activeStructureExists(int $structureId): bool
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
|
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
|
||||||
$stmt->execute(['id' => $structureId]);
|
$stmt->execute(['id' => $structureId]);
|
||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
@@ -312,11 +348,9 @@ final class Access
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
|
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
|
||||||
s.nom AS structure_nom
|
FROM v_acces_identite_agent
|
||||||
FROM agent a
|
WHERE id_agent = :id LIMIT 1'
|
||||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
|
||||||
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
|
|
||||||
);
|
);
|
||||||
$stmt->execute(['id' => $agentId]);
|
$stmt->execute(['id' => $agentId]);
|
||||||
return $stmt->fetch() ?: null;
|
return $stmt->fetch() ?: null;
|
||||||
@@ -330,8 +364,8 @@ final class Access
|
|||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT id_structure, code, nom
|
'SELECT id_structure, code, nom
|
||||||
FROM structure
|
FROM v_acces_identite_structure
|
||||||
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
|
WHERE id_structure = :id LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute(['id' => $structureId]);
|
$stmt->execute(['id' => $structureId]);
|
||||||
return $stmt->fetch() ?: null;
|
return $stmt->fetch() ?: null;
|
||||||
@@ -344,25 +378,11 @@ final class Access
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT 1
|
'SELECT 1 FROM v_acces_agent_par_structure
|
||||||
FROM agent a
|
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
|
||||||
WHERE a.id_agent = :agent_id
|
|
||||||
AND a.actif = TRUE
|
|
||||||
AND (
|
|
||||||
a.id_structure = :structure_id_default
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM planning p
|
|
||||||
WHERE p.id_agent = a.id_agent
|
|
||||||
AND p.id_structure = :structure_id_planning
|
|
||||||
)
|
|
||||||
)
|
|
||||||
LIMIT 1'
|
LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
|
||||||
'agent_id' => $agentId,
|
|
||||||
'structure_id_default' => $structureId,
|
|
||||||
'structure_id_planning' => $structureId,
|
|
||||||
]);
|
|
||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ final class Csrf
|
|||||||
{
|
{
|
||||||
public static function ensureToken(): string
|
public static function ensureToken(): string
|
||||||
{
|
{
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
Session::start();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|||||||
@@ -21,18 +21,14 @@ final class JsonResponse
|
|||||||
});
|
});
|
||||||
|
|
||||||
set_exception_handler(static function (\Throwable $exception): void {
|
set_exception_handler(static function (\Throwable $exception): void {
|
||||||
error_log(sprintf(
|
// Security::reportThrowable journalise systématiquement le détail
|
||||||
'[PTA API] %s: %s in %s:%d',
|
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
|
||||||
get_class($exception),
|
// explicitement activé. Par défaut, rien ne fuit.
|
||||||
$exception->getMessage(),
|
|
||||||
$exception->getFile(),
|
|
||||||
$exception->getLine()
|
|
||||||
));
|
|
||||||
|
|
||||||
$payload = ['error' => 'Une erreur serveur est survenue.'];
|
$payload = ['error' => 'Une erreur serveur est survenue.'];
|
||||||
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
|
if (Security::debugEnabled()) {
|
||||||
$payload['details'] = $exception->getMessage();
|
$payload['details'] = $exception->getMessage();
|
||||||
}
|
}
|
||||||
|
Security::reportThrowable($exception, 'Erreur API.');
|
||||||
|
|
||||||
self::send(500, $payload);
|
self::send(500, $payload);
|
||||||
});
|
});
|
||||||
@@ -42,6 +38,8 @@ final class JsonResponse
|
|||||||
{
|
{
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
|
|||||||
89
app/Core/Security.php
Normal file
89
app/Core/Security.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En-têtes de sécurité des réponses HTML.
|
||||||
|
*
|
||||||
|
* La CSP est nominative : aucun script inline n'est autorisé sans le nonce
|
||||||
|
* généré à chaque requête. PTA ne contient aucun gestionnaire d'événement
|
||||||
|
* inline (onclick=, onchange=…) ni style inline, la politique peut donc rester
|
||||||
|
* stricte sans dégrader l'interface.
|
||||||
|
*/
|
||||||
|
final class Security
|
||||||
|
{
|
||||||
|
private static ?string $nonce = null;
|
||||||
|
|
||||||
|
public static function nonce(): string
|
||||||
|
{
|
||||||
|
if (self::$nonce === null) {
|
||||||
|
self::$nonce = base64_encode(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sendHtmlHeaders(): void
|
||||||
|
{
|
||||||
|
if (headers_sent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nonce = self::nonce();
|
||||||
|
$policy = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"style-src 'self'",
|
||||||
|
"font-src 'self'",
|
||||||
|
// Aucune requête sortante depuis le navigateur : les appels à
|
||||||
|
// data.education.gouv.fr sont faits côté serveur.
|
||||||
|
"connect-src 'self'",
|
||||||
|
sprintf("script-src 'self' 'nonce-%s'", $nonce),
|
||||||
|
];
|
||||||
|
|
||||||
|
header('Content-Security-Policy: ' . implode('; ', $policy));
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('Referrer-Policy: no-referrer');
|
||||||
|
header('Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()');
|
||||||
|
header('Cross-Origin-Opener-Policy: same-origin');
|
||||||
|
|
||||||
|
if (Session::isHttps()) {
|
||||||
|
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai uniquement si le mode debug a été activé explicitement.
|
||||||
|
* Par défaut, aucun détail technique ne sort du serveur.
|
||||||
|
*/
|
||||||
|
public static function debugEnabled(): bool
|
||||||
|
{
|
||||||
|
return in_array(strtolower((string) (getenv('PTA_DEBUG') ?: '0')), ['1', 'true', 'on', 'yes'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Journalise l'exception côté serveur et renvoie le message à afficher.
|
||||||
|
* Le détail technique n'est rendu que si PTA_DEBUG est explicitement actif.
|
||||||
|
*/
|
||||||
|
public static function reportThrowable(\Throwable $exception, string $publicMessage): string
|
||||||
|
{
|
||||||
|
error_log(sprintf(
|
||||||
|
'[PTA] %s: %s in %s:%d',
|
||||||
|
get_class($exception),
|
||||||
|
$exception->getMessage(),
|
||||||
|
$exception->getFile(),
|
||||||
|
$exception->getLine()
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::debugEnabled()
|
||||||
|
? $publicMessage . ' Détail : ' . $exception->getMessage()
|
||||||
|
: $publicMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Core/Session.php
Normal file
59
app/Core/Session.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarrage unique et durci de la session PHP.
|
||||||
|
*
|
||||||
|
* Les mêmes garanties sont posées ici et dans docker/php/php.ini : le php.ini
|
||||||
|
* ne s'applique qu'au conteneur fourni, alors que ce code protège aussi les
|
||||||
|
* déploiements sur un serveur PHP existant dont la configuration est inconnue.
|
||||||
|
*/
|
||||||
|
final class Session
|
||||||
|
{
|
||||||
|
private static bool $started = false;
|
||||||
|
|
||||||
|
public static function start(): void
|
||||||
|
{
|
||||||
|
if (self::$started || session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
self::$started = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
ini_set('session.use_only_cookies', '1');
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => self::isHttps(),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
self::$started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si la requête courante arrive en HTTPS, directement ou via un proxy
|
||||||
|
* de confiance. Sans cette détection, le cookie de session serait marqué
|
||||||
|
* « secure » sur une installation HTTP et la session ne fonctionnerait plus.
|
||||||
|
*/
|
||||||
|
public static function isHttps(): bool
|
||||||
|
{
|
||||||
|
if (($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,5 +78,43 @@ final class SchemaModel extends BaseModel
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertAccessViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les vues d'accès portent l'intégralité des règles de droits. Si elles
|
||||||
|
* manquent, l'application doit refuser de démarrer plutôt que de tourner
|
||||||
|
* sans contrôle de périmètre.
|
||||||
|
*/
|
||||||
|
private function assertAccessViews(): void
|
||||||
|
{
|
||||||
|
$requiredViews = [
|
||||||
|
'v_acces_role_page',
|
||||||
|
'v_acces_role_permission',
|
||||||
|
'v_acces_identite_agent',
|
||||||
|
'v_acces_identite_structure',
|
||||||
|
'v_acces_agent_par_structure',
|
||||||
|
'v_acces_perimetre_creneau',
|
||||||
|
'v_acces_perimetre_planning',
|
||||||
|
];
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT TABLE_NAME
|
||||||
|
FROM information_schema.VIEWS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmt->execute($requiredViews);
|
||||||
|
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
|
||||||
|
|
||||||
|
if ($missing) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Contrôle d’accès indisponible : vue(s) manquante(s) : '
|
||||||
|
. implode(', ', $missing)
|
||||||
|
. '. Exécutez database/migration_v7_8_securite_acces.sql.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
/** @var array $pageConfig */
|
/** @var array $pageConfig */
|
||||||
/** @var array $accessProfile */
|
/** @var array $accessProfile */
|
||||||
/** @var array $permissions */
|
/** @var array $permissions */
|
||||||
|
/** @var string $cspNonce */
|
||||||
?>
|
?>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||||
window.PTA_CONFIG = {
|
window.PTA_CONFIG = {
|
||||||
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
||||||
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||||
@@ -27,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
|
|||||||
'params' => $pageConfig,
|
'params' => $pageConfig,
|
||||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/core.js?v=7.7"></script>
|
<script src="assets/js/core.js?v=7.8"></script>
|
||||||
<?php foreach ($scripts as $script): ?>
|
<?php foreach ($scripts as $script): ?>
|
||||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
|
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.8"></script>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
|
<script src="assets/js/page-bootstrap.js?v=7.8"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
/** @var string $activePage */
|
/** @var string $activePage */
|
||||||
/** @var array $allowedPages */
|
/** @var array $allowedPages */
|
||||||
/** @var array $accessProfile */
|
/** @var array $accessProfile */
|
||||||
|
/** @var string $csrfToken */
|
||||||
|
|
||||||
$navigation = [
|
$navigation = [
|
||||||
'planning' => ['planning.php', 'Saisie'],
|
'planning' => ['planning.php', 'Saisie'],
|
||||||
@@ -23,7 +24,7 @@ $navigation = [
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||||
<link rel="stylesheet" href="assets/style.css?v=7.7">
|
<link rel="stylesheet" href="assets/style.css?v=7.8">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
@@ -44,7 +45,7 @@ $navigation = [
|
|||||||
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
|
<a class="button button-ghost switch-role-link" href="role.php?action=reset&csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/** @var array $structures */
|
/** @var array $structures */
|
||||||
/** @var string $error */
|
/** @var string $error */
|
||||||
/** @var array|null $currentProfile */
|
/** @var array|null $currentProfile */
|
||||||
|
/** @var string $cspNonce */
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Choisir votre accès — PTA</title>
|
<title>Choisir votre accès — PTA</title>
|
||||||
<link rel="stylesheet" href="assets/style.css?v=7.4">
|
<link rel="stylesheet" href="assets/style.css?v=7.8">
|
||||||
</head>
|
</head>
|
||||||
<body class="role-page-body">
|
<body class="role-page-body">
|
||||||
<main class="role-page-shell">
|
<main class="role-page-shell">
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
|
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||||
(() => {
|
(() => {
|
||||||
const agentContext = document.querySelector('#agent-context');
|
const agentContext = document.querySelector('#agent-context');
|
||||||
const responsableContext = document.querySelector('#responsable-context');
|
const responsableContext = document.querySelector('#responsable-context');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>Vacances scolaires</h2>
|
<h2>Vacances scolaires</h2>
|
||||||
<p class="section-subtitle">Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
<p class="section-subtitle">Consultez toutes les vacances d'une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
|
|||||||
<div class="selection-grid vacation-filters">
|
<div class="selection-grid vacation-filters">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Académie</span>
|
<span>Académie</span>
|
||||||
<input id="vacation-academy" type="text" maxlength="100" value="Orléans-Tours" placeholder="Ex. Orléans-Tours" list="vacation-academies" autocomplete="off">
|
<input id="vacation-academy" type="text" maxlength="100" value="Dijon" placeholder="Ex. Dijon" list="vacation-academies" autocomplete="off">
|
||||||
<datalist id="vacation-academies">
|
<datalist id="vacation-academies">
|
||||||
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
|
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
|
||||||
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
|
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
|
||||||
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
|
|||||||
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
||||||
<div class="section-heading compact-heading">
|
<div class="section-heading compact-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3>Dates proposées par l’API</h3>
|
<h3>Dates proposées par l'API</h3>
|
||||||
<p class="section-subtitle">Toutes les périodes de l’année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
<p class="section-subtitle">Toutes les périodes de l'année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="vacation-select-all">
|
<label class="vacation-select-all">
|
||||||
<input id="vacation-select-all" type="checkbox">
|
<input id="vacation-select-all" type="checkbox">
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Core\Session;
|
||||||
use App\Models\SchemaModel;
|
use App\Models\SchemaModel;
|
||||||
|
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
Session::start();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo = require dirname(__DIR__) . '/db.php';
|
$pdo = require dirname(__DIR__) . '/db.php';
|
||||||
(new SchemaModel($pdo))->assertCurrent();
|
(new SchemaModel($pdo))->assertCurrent();
|
||||||
|
|||||||
@@ -5,12 +5,9 @@
|
|||||||
if (!PTA) return;
|
if (!PTA) return;
|
||||||
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
|
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
const escapeAttr = (value) => String(value ?? '')
|
// escapeHtml échappe désormais aussi les guillemets : une seule
|
||||||
.replaceAll('&', '&')
|
// implémentation partagée, valable en contexte texte comme attribut.
|
||||||
.replaceAll('\"', '"')
|
const escapeAttr = escapeHtml;
|
||||||
.replaceAll("'", ''')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>');
|
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|
||||||
function show(text, type = 'info') {
|
function show(text, type = 'info') {
|
||||||
|
|||||||
@@ -44,10 +44,21 @@
|
|||||||
|
|
||||||
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
|
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échappe une valeur pour l'insertion dans du HTML, texte ET attribut.
|
||||||
|
*
|
||||||
|
* La sérialisation via textContent/innerHTML n'échappe ni " ni ' : elle est
|
||||||
|
* sûre entre deux balises, mais permet de sortir d'un attribut value="…" ou
|
||||||
|
* title="…". PTA insère des libellés de vacances, des noms de lieux et des
|
||||||
|
* commentaires dans des attributs : les guillemets doivent être échappés.
|
||||||
|
*/
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
const div = document.createElement('div');
|
return String(value ?? '')
|
||||||
div.textContent = String(value ?? '');
|
.replaceAll('&', '&')
|
||||||
return div.innerHTML;
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text, type = 'info') {
|
function showMessage(text, type = 'info') {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
|
|
||||||
templateSelect.disabled = false;
|
templateSelect.disabled = false;
|
||||||
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
||||||
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
|
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
|
||||||
)).join('');
|
)).join('');
|
||||||
renderSelectedTemplateDetails();
|
renderSelectedTemplateDetails();
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
}
|
}
|
||||||
periodTemplateSelect.disabled = false;
|
periodTemplateSelect.disabled = false;
|
||||||
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
|
||||||
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
|
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
|
||||||
)).join('');
|
)).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
201
database/migration_v7_8_securite_acces.sql
Normal file
201
database/migration_v7_8_securite_acces.sql
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PTA V7.8 — Sécurisation : les règles d'accès descendent en base
|
||||||
|
-- ============================================================================
|
||||||
|
-- À exécuter UNE SEULE FOIS sur une base PTA V7.7 existante :
|
||||||
|
-- SOURCE database/migration_v7_8_securite_acces.sql;
|
||||||
|
--
|
||||||
|
-- Objectif : plus aucune règle de périmètre ni de droit n'est reconstruite en
|
||||||
|
-- PHP. Le contrôleur pose une question à la base, la base répond oui ou non.
|
||||||
|
-- Toute évolution des droits se fait désormais en SQL, sans redéploiement.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 1. MATRICE DES DROITS : rôle -> pages et rôle -> permissions
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS acces_role_page (
|
||||||
|
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
|
||||||
|
page VARCHAR(50) NOT NULL,
|
||||||
|
ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100,
|
||||||
|
|
||||||
|
PRIMARY KEY (role, page)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acces_role_permission (
|
||||||
|
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
|
||||||
|
permission VARCHAR(50) NOT NULL,
|
||||||
|
autorise BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
PRIMARY KEY (role, permission)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Pages autorisées — reprise à l'identique des droits V7.7.
|
||||||
|
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
|
||||||
|
('AGENT', 'agents', 10),
|
||||||
|
('AGENT', 'structures', 20),
|
||||||
|
|
||||||
|
('RESPONSABLE_STRUCTURE', 'planning', 10),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'structures', 20),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'agents', 30),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'coverage', 40),
|
||||||
|
|
||||||
|
('SERVICE_ENFANCE', 'planning', 10),
|
||||||
|
('SERVICE_ENFANCE', 'pending', 20),
|
||||||
|
('SERVICE_ENFANCE', 'coverage', 30),
|
||||||
|
('SERVICE_ENFANCE', 'structures', 40),
|
||||||
|
('SERVICE_ENFANCE', 'agents', 50),
|
||||||
|
('SERVICE_ENFANCE', 'annual', 60),
|
||||||
|
('SERVICE_ENFANCE', 'pta', 70),
|
||||||
|
('SERVICE_ENFANCE', 'teams', 80),
|
||||||
|
('SERVICE_ENFANCE', 'vacations', 90),
|
||||||
|
('SERVICE_ENFANCE', 'administration', 100)
|
||||||
|
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
|
||||||
|
|
||||||
|
-- Permissions fonctionnelles — reprise à l'identique des droits V7.7.
|
||||||
|
INSERT INTO acces_role_permission (role, permission, autorise) VALUES
|
||||||
|
('AGENT', 'read_only', TRUE),
|
||||||
|
('AGENT', 'can_edit_draft', FALSE),
|
||||||
|
('AGENT', 'can_edit_cross_structure', FALSE),
|
||||||
|
('AGENT', 'can_validate', FALSE),
|
||||||
|
('AGENT', 'can_reopen', FALSE),
|
||||||
|
('AGENT', 'can_use_templates', FALSE),
|
||||||
|
('AGENT', 'can_copy_full_week', FALSE),
|
||||||
|
('AGENT', 'can_view_pending', FALSE),
|
||||||
|
('AGENT', 'can_manage_coverage', FALSE),
|
||||||
|
('AGENT', 'can_manage_agents', FALSE),
|
||||||
|
('AGENT', 'can_manage_structures', FALSE),
|
||||||
|
('AGENT', 'can_manage_vacations', FALSE),
|
||||||
|
('AGENT', 'can_manage_teams', FALSE),
|
||||||
|
('AGENT', 'can_manage_pta', FALSE),
|
||||||
|
('AGENT', 'can_forecast_next_year', FALSE),
|
||||||
|
|
||||||
|
('RESPONSABLE_STRUCTURE', 'read_only', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_edit_draft', TRUE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_edit_cross_structure', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_validate', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_reopen', TRUE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_use_templates', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_copy_full_week', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_view_pending', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_coverage', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_agents', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_structures', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_vacations', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_teams', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_manage_pta', FALSE),
|
||||||
|
('RESPONSABLE_STRUCTURE', 'can_forecast_next_year', FALSE),
|
||||||
|
|
||||||
|
('SERVICE_ENFANCE', 'read_only', FALSE),
|
||||||
|
('SERVICE_ENFANCE', 'can_edit_draft', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_edit_cross_structure', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_validate', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_reopen', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_use_templates', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_copy_full_week', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_view_pending', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_coverage', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_agents', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_structures', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_vacations', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_teams', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_manage_pta', TRUE),
|
||||||
|
('SERVICE_ENFANCE', 'can_forecast_next_year', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE autorise = VALUES(autorise);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 2. VUES DE DROITS
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_acces_role_page AS
|
||||||
|
SELECT role, page, ordre_affichage
|
||||||
|
FROM acces_role_page;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_acces_role_permission AS
|
||||||
|
SELECT role, permission, autorise
|
||||||
|
FROM acces_role_permission;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 3. VUES D'IDENTITÉ
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Identité complète d'un agent actif : sert à valider la sélection de rôle et
|
||||||
|
-- à construire le bandeau de profil. Un agent inactif n'apparaît jamais ici,
|
||||||
|
-- il ne peut donc pas être choisi comme identité.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_identite_agent AS
|
||||||
|
SELECT
|
||||||
|
a.id_agent,
|
||||||
|
a.matricule,
|
||||||
|
a.nom,
|
||||||
|
a.prenom,
|
||||||
|
a.id_structure,
|
||||||
|
s.nom AS structure_nom,
|
||||||
|
s.code AS structure_code
|
||||||
|
FROM agent a
|
||||||
|
LEFT JOIN structure s
|
||||||
|
ON s.id_structure = a.id_structure
|
||||||
|
AND s.actif = TRUE
|
||||||
|
WHERE a.actif = TRUE;
|
||||||
|
|
||||||
|
-- Lieux d'affectation sélectionnables par un responsable de structure.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_identite_structure AS
|
||||||
|
SELECT
|
||||||
|
s.id_structure,
|
||||||
|
s.code,
|
||||||
|
s.nom,
|
||||||
|
s.type_affectation
|
||||||
|
FROM structure s
|
||||||
|
WHERE s.actif = TRUE;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- 4. VUES DE PÉRIMÈTRE
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Quels agents un lieu d'affectation a-t-il le droit de consulter ?
|
||||||
|
-- Deux origines : le rattachement principal, ou une planification effective
|
||||||
|
-- sur ce lieu. C'est la règle exacte que le PHP reconstruisait à la main.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_agent_par_structure AS
|
||||||
|
SELECT DISTINCT
|
||||||
|
a.id_agent,
|
||||||
|
a.id_structure AS id_structure_autorisee,
|
||||||
|
'RATTACHEMENT' AS origine
|
||||||
|
FROM agent a
|
||||||
|
WHERE a.actif = TRUE
|
||||||
|
AND a.id_structure IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT
|
||||||
|
a.id_agent,
|
||||||
|
p.id_structure AS id_structure_autorisee,
|
||||||
|
'PLANIFICATION' AS origine
|
||||||
|
FROM agent a
|
||||||
|
INNER JOIN planning p ON p.id_agent = a.id_agent
|
||||||
|
WHERE a.actif = TRUE;
|
||||||
|
|
||||||
|
-- Périmètre d'un créneau : à quel lieu et à quel planning appartient-il ?
|
||||||
|
-- Utilisé avant toute modification ou suppression d'affectation.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_perimetre_creneau AS
|
||||||
|
SELECT
|
||||||
|
c.id_creneau,
|
||||||
|
c.id_planning,
|
||||||
|
p.id_structure,
|
||||||
|
p.id_agent,
|
||||||
|
p.statut AS statut_planning,
|
||||||
|
s.nom AS structure_nom
|
||||||
|
FROM creneau_horaire c
|
||||||
|
INNER JOIN planning p ON p.id_planning = c.id_planning
|
||||||
|
INNER JOIN structure s ON s.id_structure = p.id_structure;
|
||||||
|
|
||||||
|
-- Périmètre d'un planning : utilisé avant validation ou réouverture.
|
||||||
|
CREATE OR REPLACE VIEW v_acces_perimetre_planning AS
|
||||||
|
SELECT
|
||||||
|
p.id_planning,
|
||||||
|
p.id_structure,
|
||||||
|
p.id_agent,
|
||||||
|
p.statut AS statut_planning,
|
||||||
|
s.nom AS structure_nom,
|
||||||
|
a.nom AS agent_nom,
|
||||||
|
a.prenom AS agent_prenom
|
||||||
|
FROM planning p
|
||||||
|
INNER JOIN structure s ON s.id_structure = p.id_structure
|
||||||
|
INNER JOIN agent a ON a.id_agent = p.id_agent;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Vérification recommandée après import :
|
||||||
|
-- SELECT role, COUNT(*) FROM acces_role_page GROUP BY role;
|
||||||
|
-- SELECT * FROM v_acces_agent_par_structure LIMIT 10;
|
||||||
|
-- ============================================================================
|
||||||
6
db.php
6
db.php
@@ -20,8 +20,12 @@ try {
|
|||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
]);
|
]);
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
|
// Le message PDO contient l'hôte, l'utilisateur et le nom de la base :
|
||||||
|
// il est journalisé côté serveur, jamais propagé vers le navigateur.
|
||||||
|
error_log(sprintf('[PTA] Connexion base impossible : %s', $e->getMessage()));
|
||||||
|
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
'Connexion à la base impossible. Vérifiez config.php, le nom de la base et que MySQL est démarré. Détail : ' . $e->getMessage(),
|
'Connexion à la base impossible. Vérifiez la configuration de la base de données et que le serveur MySQL est démarré.',
|
||||||
0,
|
0,
|
||||||
$e
|
$e
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ services:
|
|||||||
PTA_DB_NAME: ${DB_NAME:-pta}
|
PTA_DB_NAME: ${DB_NAME:-pta}
|
||||||
PTA_DB_USER: ${DB_USER:-pta}
|
PTA_DB_USER: ${DB_USER:-pta}
|
||||||
PTA_DB_PASS: ${DB_PASSWORD:-change_me}
|
PTA_DB_PASS: ${DB_PASSWORD:-change_me}
|
||||||
|
# Aucun détail technique n'est renvoyé au navigateur tant que ce drapeau
|
||||||
|
# n'est pas explicitement passé à 1. Ne jamais l'activer en production.
|
||||||
|
PTA_DEBUG: ${PTA_DEBUG:-0}
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -66,12 +69,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
PMA_HOST: db
|
||||||
PMA_PORT: 3306
|
PMA_PORT: 3306
|
||||||
PMA_USER: ${DB_USER:-pta}
|
# PMA_USER / PMA_PASSWORD sont volontairement absents : leur présence fait
|
||||||
PMA_PASSWORD: ${DB_PASSWORD:-change_me}
|
# basculer phpMyAdmin en auth_type=config, c'est-à-dire une session déjà
|
||||||
|
# authentifiée pour quiconque atteint le port. Sans eux, phpMyAdmin exige
|
||||||
|
# un identifiant et un mot de passe MariaDB à chaque connexion.
|
||||||
UPLOAD_LIMIT: 64M
|
UPLOAD_LIMIT: 64M
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
ports:
|
ports:
|
||||||
# Par défaut phpMyAdmin n'est accessible que depuis la machine hôte.
|
# phpMyAdmin n'écoute que sur la boucle locale. Pour y accéder à distance,
|
||||||
|
# passer par un tunnel SSH (voir README_DOCKER.md).
|
||||||
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80"
|
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -8,8 +8,67 @@ server {
|
|||||||
charset utf-8;
|
charset utf-8;
|
||||||
client_max_body_size 64M;
|
client_max_body_size 64M;
|
||||||
|
|
||||||
location / {
|
# Ne pas annoncer la version du serveur.
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
server_tokens off;
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# ORDRE IMPORTANT
|
||||||
|
# nginx évalue les expressions régulières dans l'ordre du fichier et retient
|
||||||
|
# la PREMIÈRE qui correspond. Tous les blocs de refus doivent donc précéder
|
||||||
|
# le bloc générique « location ~ \.php$ », sinon ils ne s'appliquent jamais
|
||||||
|
# et les fichiers d'amorçage deviennent exécutables depuis le navigateur.
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Fichiers inclus par l'application, jamais des points d'entrée.
|
||||||
|
location ~* ^/(config|db|page_bootstrap)\.php$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Classes, modèles, schémas SQL et documentation de version.
|
||||||
|
location ^~ /app/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /database/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /docker/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* ^/README[^/]*$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuration, dumps et journaux ne doivent jamais être téléchargeables.
|
||||||
|
location ~* \.(?:env|sql|sql\.gz|ini|log|bak|dump|yml|yaml|md)$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fichiers et dossiers cachés (.env, .git, .htaccess…).
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# Contenus servis
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
# Les réponses PHP portent leurs propres en-têtes de sécurité, CSP à
|
||||||
|
# nonce comprise (App\Core\Security) ; ici on couvre le statique.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
@@ -23,33 +82,13 @@ server {
|
|||||||
fastcgi_param DOCUMENT_ROOT $document_root;
|
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||||
fastcgi_param HTTP_PROXY "";
|
fastcgi_param HTTP_PROXY "";
|
||||||
|
|
||||||
|
# Les erreurs PHP restent gérées par l'application (JSON ou page HTML) :
|
||||||
|
# nginx ne les remplace pas par ses propres pages.
|
||||||
|
fastcgi_intercept_errors off;
|
||||||
fastcgi_read_timeout 120s;
|
fastcgi_read_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Les classes, modèles et fichiers SQL ne doivent jamais être servis directement.
|
location / {
|
||||||
location ^~ /app/ {
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /database/ {
|
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* ^/(config\.php|db\.php|page_bootstrap\.php|README[^/]*)$ {
|
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ /\. {
|
|
||||||
deny all;
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
|
|
||||||
expires 7d;
|
|
||||||
add_header Cache-Control "public, max-age=604800";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/app/autoload.php';
|
require_once __DIR__ . '/app/autoload.php';
|
||||||
|
|
||||||
use App\Controllers\PageController;
|
use App\Controllers\PageController;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
function runPage(string $method): void
|
function runPage(string $method): void
|
||||||
{
|
{
|
||||||
@@ -17,10 +18,14 @@ function runPage(string $method): void
|
|||||||
$controller->{$method}();
|
$controller->{$method}();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
$message = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
|
Security::sendHtmlHeaders();
|
||||||
|
$message = htmlspecialchars(
|
||||||
|
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de cette page.'),
|
||||||
|
ENT_QUOTES,
|
||||||
|
'UTF-8'
|
||||||
|
);
|
||||||
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
||||||
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
|
echo '<body><h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
|
||||||
echo '<h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
|
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
|
||||||
echo '<p>Vérifiez <code>config.php</code> et le schéma de la base de données.</p></body></html>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
role.php
11
role.php
@@ -5,13 +5,20 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/app/autoload.php';
|
require_once __DIR__ . '/app/autoload.php';
|
||||||
|
|
||||||
use App\Controllers\RoleController;
|
use App\Controllers\RoleController;
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = require __DIR__ . '/app/bootstrap.php';
|
$pdo = require __DIR__ . '/app/bootstrap.php';
|
||||||
(new RoleController($pdo))->index();
|
(new RoleController($pdo))->index();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
Security::sendHtmlHeaders();
|
||||||
|
$message = htmlspecialchars(
|
||||||
|
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de la sélection du rôle.'),
|
||||||
|
ENT_QUOTES,
|
||||||
|
'UTF-8'
|
||||||
|
);
|
||||||
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
|
||||||
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
|
echo '<body><h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . $message . '</p>';
|
||||||
echo '<h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</p></body></html>';
|
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user