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; }