Version initiale — carte des établissements scolaires

Carte Leaflet (annuaire Éducation nationale) + API PHP/MySQL :
fiche établissement (effectifs, IPS, population INSEE, DPE ADEME),
GED documents/photos, commentaires par groupe, gestion utilisateurs
et politique de mots de passe. Imports opendata (effectifs, IPS,
population, DPE, IRIS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:35:41 +02:00
commit 5633969d98
40 changed files with 7242 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
<?php
/**
* Socle commun : connexion PDO MySQL, sorties JSON, session/authentification,
* et import des établissements (partagé entre setup.php et api/import.php).
*
* Identifiants par défaut de Laragon (root / mot de passe vide).
*/
declare(strict_types=1);
const DB_HOST = '127.0.0.1';
const DB_PORT = 3306;
const DB_NAME = 'ecoles';
const DB_USER = 'root';
const DB_PASS = '';
// --- Application & envoi d'e-mails -----------------------------------------
// URL publique de base (pour les liens de réinitialisation). Adaptez si besoin.
const APP_BASE_URL = 'http://ecoles.test';
// Transport du mailer : 'log' (écrit dans private/mail_outbox.log, défaut local et sûr),
// 'mail' (fonction PHP mail()), ou 'smtp' (client SMTP intégré ci-dessous).
const MAIL_TRANSPORT = 'log';
const MAIL_FROM = 'no-reply@ecoles.test';
const MAIL_FROM_NAME = 'Carte des établissements';
// Paramètres SMTP (utilisés seulement si MAIL_TRANSPORT === 'smtp').
const SMTP_HOST = '';
const SMTP_PORT = 587;
const SMTP_USER = '';
const SMTP_PASS = '';
const SMTP_SECURE = 'tls'; // 'tls' (STARTTLS, port 587) | 'ssl' (port 465) | '' (aucun)
/** Connexion partagée à la base applicative. */
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
// ---------------------------------------------------------------------------
// Sorties JSON
// ---------------------------------------------------------------------------
/** Émet une réponse JSON et arrête le script. */
function json_out($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/** Émet une erreur JSON et arrête le script. */
function json_error(string $message, int $status = 400): void
{
json_out(['error' => $message], $status);
}
/** Corps de requête JSON décodé en tableau. */
function body_json(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '', true);
return is_array($data) ? $data : [];
}
// ---------------------------------------------------------------------------
// Session / authentification
// ---------------------------------------------------------------------------
function session_boot(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/** Utilisateur connecté (id, username, role) ou null. */
function current_user(): ?array
{
static $user = false;
if ($user !== false) {
return $user;
}
session_boot();
if (empty($_SESSION['uid'])) {
return $user = null;
}
$st = db()->prepare('SELECT id, username, role, must_change FROM users WHERE id = ?');
$st->execute([$_SESSION['uid']]);
$row = $st->fetch();
if ($row) {
$row['id'] = (int) $row['id'];
$row['must_change'] = (int) $row['must_change'];
}
return $user = ($row ?: null);
}
/** Exige une connexion ; renvoie l'utilisateur ou répond 401. */
function require_login(): array
{
$u = current_user();
if (!$u) {
json_error('Authentification requise.', 401);
}
return $u;
}
/** Exige le rôle super-admin ; renvoie l'utilisateur ou répond 403. */
function require_super(): array
{
$u = require_login();
if ($u['role'] !== 'super') {
json_error('Réservé à ladministrateur de la plateforme.', 403);
}
return $u;
}
/**
* L'utilisateur peut-il valider/modérer l'établissement $uai ?
* Vrai si super-admin, OU s'il a un périmètre personnel (user_scopes, legacy)
* correspondant, OU s'il est membre d'un groupe « valideur » (user_groups.can_validate)
* dont une commune (group_scopes) correspond à l'établissement.
* Les commentateurs (role « member ») n'ont aucun droit de validation.
*/
function can_validate_uai(array $user, string $uai): bool
{
if ($user['role'] === 'super') {
return true;
}
if ($user['role'] !== 'perimeter') {
return false; // « member » (commentateur) : pas de modération
}
$st = db()->prepare(
'SELECT 1 FROM etablissements e
JOIN user_scopes s ON s.com = e.com AND s.depc = e.depc
WHERE e.id = ? AND s.user_id = ?
LIMIT 1'
);
$st->execute([$uai, $user['id']]);
if ($st->fetchColumn()) {
return true;
}
$st = db()->prepare(
'SELECT 1 FROM etablissements e
JOIN group_members gm ON gm.user_id = ?
JOIN user_groups g ON g.id = gm.group_id AND g.can_validate = 1
JOIN group_scopes gs ON gs.group_id = g.id AND gs.com = e.com AND gs.depc = e.depc
WHERE e.id = ?
LIMIT 1'
);
$st->execute([$user['id'], $uai]);
return (bool) $st->fetchColumn();
}
/** Identifiants des groupes auxquels l'utilisateur appartient. */
function user_group_ids(int $userId): array
{
$st = db()->prepare('SELECT group_id FROM group_members WHERE user_id = ?');
$st->execute([$userId]);
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
// ---------------------------------------------------------------------------
// Politique de mots de passe
// ---------------------------------------------------------------------------
const PASSWORD_MIN_LEN = 8;
const PASSWORD_MIN_CLASSES = 3; // parmi : minuscule, majuscule, chiffre, caractère spécial
/**
* Vérifie la robustesse d'un mot de passe (≥ 8 caractères et au moins 3 types
* parmi minuscule/majuscule/chiffre/spécial). Renvoie null si conforme, sinon
* un message d'erreur en français.
*/
function password_policy_check(string $pwd): ?string
{
if (strlen($pwd) < PASSWORD_MIN_LEN) {
return 'Le mot de passe doit contenir au moins ' . PASSWORD_MIN_LEN . ' caractères.';
}
$classes = (preg_match('/[a-z]/', $pwd) ? 1 : 0)
+ (preg_match('/[A-Z]/', $pwd) ? 1 : 0)
+ (preg_match('/[0-9]/', $pwd) ? 1 : 0)
+ (preg_match('/[^A-Za-z0-9]/', $pwd) ? 1 : 0);
if ($classes < PASSWORD_MIN_CLASSES) {
return 'Le mot de passe doit combiner au moins ' . PASSWORD_MIN_CLASSES
. ' types parmi : minuscule, majuscule, chiffre, caractère spécial.';
}
return null;
}
/** Jeton de réinitialisation aléatoire (à stocker haché en base). */
function reset_token_new(): string
{
return bin2hex(random_bytes(32));
}
// ---------------------------------------------------------------------------
// Envoi d'e-mails (transport enfichable : log / mail / smtp)
// ---------------------------------------------------------------------------
/** Envoie un e-mail texte. Renvoie true si remis au transport. */
function send_mail(string $to, string $subject, string $body): bool
{
switch (MAIL_TRANSPORT) {
case 'mail':
$headers = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . ">\r\n"
. "Content-Type: text/plain; charset=utf-8\r\n";
return mail($to, $subject, $body, $headers);
case 'smtp':
return smtp_send($to, $subject, $body);
case 'log':
default:
$dir = dirname(__DIR__) . '/private';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$line = "==== " . date('Y-m-d H:i:s') . " ====\n"
. "To: $to\nSubject: $subject\n\n$body\n\n";
return (bool) @file_put_contents($dir . '/mail_outbox.log', $line, FILE_APPEND);
}
}
/** Client SMTP minimal (AUTH LOGIN, STARTTLS ou SSL). Renvoie true si accepté. */
function smtp_send(string $to, string $subject, string $body): bool
{
$host = SMTP_SECURE === 'ssl' ? 'ssl://' . SMTP_HOST : SMTP_HOST;
$fp = @fsockopen($host, SMTP_PORT, $errno, $errstr, 15);
if (!$fp) {
return false;
}
$read = static function () use ($fp): string {
$data = '';
while (($line = fgets($fp, 515)) !== false) {
$data .= $line;
if (strlen($line) < 4 || $line[3] === ' ') {
break;
}
}
return $data;
};
$cmd = static function (string $c) use ($fp, $read): string {
fwrite($fp, $c . "\r\n");
return $read();
};
$ok = static fn(string $r, string $code): bool => strncmp($r, $code, 3) === 0;
$read(); // bannière
$ehlo = 'EHLO ' . (parse_url(APP_BASE_URL, PHP_URL_HOST) ?: 'localhost');
$cmd($ehlo);
if (SMTP_SECURE === 'tls') {
if (!$ok($cmd('STARTTLS'), '220')) {
fclose($fp);
return false;
}
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp);
return false;
}
$cmd($ehlo);
}
if (SMTP_USER !== '') {
$cmd('AUTH LOGIN');
$cmd(base64_encode(SMTP_USER));
if (!$ok($cmd(base64_encode(SMTP_PASS)), '235')) {
fclose($fp);
return false;
}
}
$cmd('MAIL FROM:<' . MAIL_FROM . '>');
$cmd('RCPT TO:<' . $to . '>');
if (!$ok($cmd('DATA'), '354')) {
fclose($fp);
return false;
}
$headers = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . ">\r\n"
. 'To: <' . $to . ">\r\n"
. 'Subject: ' . $subject . "\r\n"
. "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n";
$data = $headers . "\r\n" . str_replace("\r\n.", "\r\n..", $body) . "\r\n.";
$sent = $ok($cmd($data), '250');
$cmd('QUIT');
fclose($fp);
return $sent;
}
// ---------------------------------------------------------------------------
// Import des établissements (opendata)
// ---------------------------------------------------------------------------
/**
* Convertit une ligne brute de l'annuaire opendata en enregistrement compact
* (mêmes clés courtes que data/etablissements.json). Renvoie null si pas de géoloc.
*/
function map_opendata_row(array $r): ?array
{
$lat = $r['latitude'] ?? null;
$lon = $r['longitude'] ?? null;
if ($lat === null || $lon === null) {
return null;
}
$opt = static fn($v) => ((int) ($v ?? 0) === 1) ? 1 : 0;
return [
'id' => $r['identifiant_de_l_etablissement'] ?? null,
'nom' => $r['nom_etablissement'] ?? null,
'type' => $r['type_etablissement'] ?? null,
'stat' => $r['statut_public_prive'] ?? null,
'reg' => $r['libelle_region'] ?? null,
'aca' => $r['libelle_academie'] ?? null,
'dep' => $r['libelle_departement'] ?? null,
'depc' => $r['code_departement'] ?? null,
'com' => $r['nom_commune'] ?? null,
'cp' => $r['code_postal'] ?? null,
'adr' => $r['adresse_1'] ?? null,
'tel' => $r['telephone'] ?? null,
'mail' => $r['mail'] ?? null,
'web' => $r['web'] ?? null,
'etat' => $r['etat'] ?? null,
'circ' => $r['nom_circonscription'] ?? null,
'lat' => round((float) $lat, 5),
'lon' => round((float) $lon, 5),
'rest' => $opt($r['restauration'] ?? 0),
'heb' => $opt($r['hebergement'] ?? 0),
'ulis' => $opt($r['ulis'] ?? 0),
'segp' => $opt($r['segpa'] ?? 0),
'appr' => $opt($r['apprentissage'] ?? 0),
];
}
/**
* Insère/met à jour une liste d'établissements (clés courtes) en base.
* Renvoie le nombre de lignes traitées.
*/
function import_etablissements(PDO $pdo, array $rows): int
{
$sql = 'INSERT INTO etablissements
(id,nom,type,stat,reg,aca,dep,depc,com,cp,adr,tel,mail,web,etat,circ,lat,lon,rest,heb,ulis,segp,appr)
VALUES (:id,:nom,:type,:stat,:reg,:aca,:dep,:depc,:com,:cp,:adr,:tel,:mail,:web,:etat,:circ,:lat,:lon,:rest,:heb,:ulis,:segp,:appr)
ON DUPLICATE KEY UPDATE
nom=VALUES(nom),type=VALUES(type),stat=VALUES(stat),reg=VALUES(reg),aca=VALUES(aca),
dep=VALUES(dep),depc=VALUES(depc),com=VALUES(com),cp=VALUES(cp),adr=VALUES(adr),
tel=VALUES(tel),mail=VALUES(mail),web=VALUES(web),etat=VALUES(etat),circ=VALUES(circ),
lat=VALUES(lat),lon=VALUES(lon),rest=VALUES(rest),heb=VALUES(heb),ulis=VALUES(ulis),
segp=VALUES(segp),appr=VALUES(appr)';
$ins = $pdo->prepare($sql);
$defaults = [
'nom' => null, 'type' => null, 'stat' => null, 'reg' => null, 'aca' => null,
'dep' => null, 'depc' => null, 'com' => null, 'cp' => null, 'adr' => null,
'tel' => null, 'mail' => null, 'web' => null, 'etat' => null, 'circ' => null,
'lat' => null, 'lon' => null, 'rest' => 0, 'heb' => 0, 'ulis' => 0, 'segp' => 0, 'appr' => 0,
];
$pdo->beginTransaction();
$n = 0;
foreach ($rows as $r) {
if (empty($r['id'])) {
continue;
}
$ins->execute(array_merge($defaults, array_intersect_key($r, $defaults), ['id' => $r['id']]));
if (++$n % 5000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();
return $n;
}