Files
ecoles/api/db.php
T
Arnaud 5633969d98 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>
2026-06-03 21:35:41 +02:00

376 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}