5633969d98
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>
376 lines
13 KiB
PHP
376 lines
13 KiB
PHP
<?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é à l’administrateur 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;
|
||
}
|