diff --git a/admin/import_history.php b/admin/import_history.php
new file mode 100644
index 0000000..8256bd5
--- /dev/null
+++ b/admin/import_history.php
@@ -0,0 +1,226 @@
+prepare('DELETE FROM import_errors WHERE batch_id = ?');
+ $stmt->execute([$batchId]);
+ $feedback = sprintf('%d ligne(s) d\'erreur purgee(s) pour l\'import #%d.', $stmt->rowCount(), $batchId);
+ } catch (Throwable $e) {
+ error_log('[import_history] purge_errors: ' . $e->getMessage());
+ $feedback = 'Une erreur est survenue lors de la purge.';
+ $feedbackType = 'error';
+ }
+ }
+ }
+}
+
+$page = max(1, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
+$perPage = 25;
+$offset = ($page - 1) * $perPage;
+
+$totalBatches = 0;
+$batches = [];
+try {
+ $totalBatches = (int)$db->query('SELECT COUNT(*) FROM import_batches')->fetchColumn();
+
+ $stmt = $db->prepare(
+ 'SELECT id, filename, imported_at, imported_by, status, rows_total, rows_imported, rows_rejected
+ FROM import_batches
+ ORDER BY imported_at DESC
+ LIMIT :limit OFFSET :offset'
+ );
+ $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
+ $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
+ $stmt->execute();
+ $batches = $stmt->fetchAll(PDO::FETCH_ASSOC);
+} catch (Throwable $e) {
+ error_log('[import_history] liste batches: ' . $e->getMessage());
+ $feedback = 'Impossible de charger l\'historique des imports. Consultez les journaux du serveur.';
+ $feedbackType = 'error';
+}
+
+$errorsByBatch = [];
+$detailBatchId = filter_input(INPUT_GET, 'detail', FILTER_VALIDATE_INT);
+if ($detailBatchId) {
+ try {
+ $stmt = $db->prepare(
+ 'SELECT line_number, message, raw_line
+ FROM import_errors
+ WHERE batch_id = ?
+ ORDER BY line_number ASC
+ LIMIT 500'
+ );
+ $stmt->execute([$detailBatchId]);
+ $errorsByBatch = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ } catch (Throwable $e) {
+ error_log('[import_history] detail erreurs: ' . $e->getMessage());
+ }
+}
+
+$totalPages = max(1, (int)ceil($totalBatches / $perPage));
+$user = current_admin_username();
+
+function status_badge(string $status): string
+{
+ $map = [
+ 'success' => 'badge-success',
+ 'partial' => 'badge-warning',
+ 'failed' => 'badge-error',
+ ];
+ $class = $map[$status] ?? 'badge';
+ return '' . htmlspecialchars($status, ENT_QUOTES, 'UTF-8') . '';
+}
+?>
+
+
+
+
+ Historique des imports — NetMonster Admin
+
+
+
+
+
+
+ Historique des imports .ntm
+ Suivi en lecture seule des imports realises. La purge des erreurs d'un import est possible mais n'affecte jamais les donnees deja validees.
+
+
+
+ = htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Imports (= $totalBatches ?>)
+
+
+ | # |
+ Fichier |
+ Date |
+ Utilisateur |
+ Statut |
+ Lignes totales |
+ Importees |
+ Rejetees |
+ Actions |
+
+
+
+ | = (int)$batch['id'] ?> |
+ = htmlspecialchars((string)$batch['filename'], ENT_QUOTES, 'UTF-8') ?> |
+ = htmlspecialchars((string)$batch['imported_at'], ENT_QUOTES, 'UTF-8') ?> |
+ = htmlspecialchars((string)$batch['imported_by'], ENT_QUOTES, 'UTF-8') ?> |
+ = status_badge((string)$batch['status']) ?> |
+ = (int)$batch['rows_total'] ?> |
+ = (int)$batch['rows_imported'] ?> |
+ = (int)$batch['rows_rejected'] ?> |
+
+ Voir erreurs
+ 0): ?>
+
+
+ |
+
+
+
+ | Aucun import pour le moment. |
+
+
+
+ 1): ?>
+
+
+
+
+
+
+ Erreurs de l'import #= (int)$detailBatchId ?>
+
+ Aucune erreur enregistree pour cet import.
+
+
+
+ | Ligne |
+ Message |
+ Contenu brut |
+
+
+
+ | = (int)$err['line_number'] ?> |
+ = htmlspecialchars((string)$err['message'], ENT_QUOTES, 'UTF-8') ?> |
+ = htmlspecialchars((string)$err['raw_line'], ENT_QUOTES, 'UTF-8') ?> |
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/import_ntm.php b/admin/import_ntm.php
new file mode 100644
index 0000000..2c474cb
--- /dev/null
+++ b/admin/import_ntm.php
@@ -0,0 +1,249 @@
+getMessage()) n'est plus affiche a l'ecran ; il est
+ * journalise via error_log() et un message generique est montre a
+ * l'administrateur.
+ */
+declare(strict_types=1);
+
+require_once __DIR__ . '/admin_auth.php';
+require_once __DIR__ . '/../lib/db.php';
+require_once __DIR__ . '/../lib/ntm_import_to_staging.php';
+require_once __DIR__ . '/_nav.php';
+
+require_admin_auth();
+$db = pdo();
+$user = current_admin_username();
+
+$successMessage = null;
+$errorMessage = null;
+$importStats = null;
+$previewGroups = [];
+$lastImportId = null;
+
+/**
+ * Meme logique de regroupement que admin/review.php, dupliquee ici
+ * volontairement pour ne pas creer de dependance croisee entre pages
+ * admin. Si vous ajustez le decalage de bits ou la logique de
+ * regroupement dans review.php, reportez le changement ici aussi.
+ */
+function deriveSiteKeyForPreview(array $row): string
+{
+ $mcc = $row['mcc'];
+ $mnc = $row['mnc'];
+
+ if ($row['tech'] === '4G' && !empty($row['sub_id2'])) {
+ return "enb:{$mcc}:{$mnc}:{$row['area_code']}:{$row['sub_id2']}";
+ }
+
+ $latR = $row['lat'] !== null ? round((float)$row['lat'], 4) : 'na';
+ $lonR = $row['lon'] !== null ? round((float)$row['lon'], 4) : 'na';
+ return "geo:{$mcc}:{$mnc}:{$row['area_code']}:{$latR}:{$lonR}";
+}
+
+const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; // 10 Mo, garde-fou raisonnable pour un fichier .ntm
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ require_csrf_token();
+
+ $countryId = (int)($_POST['country_id'] ?? 0);
+
+ if (!$countryId) {
+ $errorMessage = "Selectionnez un pays avant d'importer.";
+ } elseif (empty($_FILES['ntm_file']) || $_FILES['ntm_file']['error'] !== UPLOAD_ERR_OK) {
+ $uploadErrors = [
+ UPLOAD_ERR_INI_SIZE => "Le fichier depasse la taille maximale autorisee par le serveur (php.ini).",
+ UPLOAD_ERR_FORM_SIZE => "Le fichier depasse la taille maximale autorisee par le formulaire.",
+ UPLOAD_ERR_PARTIAL => "Le fichier n'a ete que partiellement televerse. Reessayez.",
+ UPLOAD_ERR_NO_FILE => "Aucun fichier selectionne.",
+ ];
+ $code = $_FILES['ntm_file']['error'] ?? UPLOAD_ERR_NO_FILE;
+ $errorMessage = $uploadErrors[$code] ?? "Erreur lors du televersement du fichier.";
+ } else {
+ $tmpPath = $_FILES['ntm_file']['tmp_name'];
+ $originalName = $_FILES['ntm_file']['name'];
+ $fileSize = $_FILES['ntm_file']['size'];
+
+ $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+
+ if ($extension !== 'ntm') {
+ $errorMessage = "Seuls les fichiers .ntm sont acceptes (extension detectee : ." . htmlspecialchars($extension, ENT_QUOTES, 'UTF-8') . ").";
+ } elseif ($fileSize > MAX_UPLOAD_SIZE_BYTES) {
+ $errorMessage = "Le fichier depasse la limite de " . (MAX_UPLOAD_SIZE_BYTES / 1024 / 1024) . " Mo.";
+ } elseif (!is_uploaded_file($tmpPath)) {
+ $errorMessage = "Televersement invalide (fichier non reconnu comme upload legitime).";
+ } else {
+ $safeFilename = preg_replace('/[^A-Za-z0-9._-]/', '_', $originalName);
+ $uploadsDir = __DIR__ . '/../uploads';
+ if (!is_dir($uploadsDir)) {
+ mkdir($uploadsDir, 0750, true);
+ }
+ $destPath = $uploadsDir . '/' . uniqid('ntm_', true) . '_' . $safeFilename;
+
+ if (!move_uploaded_file($tmpPath, $destPath)) {
+ $errorMessage = "Impossible d'enregistrer le fichier televerse sur le serveur.";
+ } else {
+ try {
+ $importStats = importNtmFileToStaging($destPath, $safeFilename, $countryId);
+ $successMessage = "Import termine : {$importStats['staged']} ligne(s) placee(s) en validation"
+ . ($importStats['already_known'] > 0 ? ", dont {$importStats['already_known']} deja connue(s) en base" : "")
+ . ($importStats['error'] > 0 ? ", {$importStats['error']} erreur(s)" : "") . ".";
+
+ // Recupere l'ID de l'import qui vient d'etre cree pour l'apercu de regroupement
+ $lastImportId = (int)$db->query('SELECT MAX(id) FROM imports')->fetchColumn();
+
+ $previewStmt = $db->prepare('
+ SELECT sc.*, o.brand AS operator_brand
+ FROM staging_cells sc
+ LEFT JOIN operators o ON o.id = sc.operator_guess
+ WHERE sc.import_id = ?
+ ');
+ $previewStmt->execute([$lastImportId]);
+ $importedRows = $previewStmt->fetchAll(PDO::FETCH_ASSOC);
+
+ foreach ($importedRows as $row) {
+ $key = deriveSiteKeyForPreview($row);
+ $previewGroups[$key]['cells'][] = $row;
+ $previewGroups[$key]['operator_brand'] = $row['operator_brand'];
+ $previewGroups[$key]['lat'] = $previewGroups[$key]['lat'] ?? $row['lat'];
+ $previewGroups[$key]['lon'] = $previewGroups[$key]['lon'] ?? $row['lon'];
+ }
+ } catch (Throwable $e) {
+ error_log('[import_ntm] ' . $e->getMessage());
+ $errorMessage = "Une erreur est survenue lors du traitement du fichier. Consultez les journaux du serveur.";
+ }
+ }
+ }
+ }
+}
+
+$countries = $db->query('SELECT id, iso2, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
+
+$importHistory = $db->query('
+ SELECT i.*, c.name AS country_name
+ FROM imports i
+ LEFT JOIN countries c ON c.id = i.country_id
+ ORDER BY i.uploaded_at DESC
+ LIMIT 20
+')->fetchAll(PDO::FETCH_ASSOC);
+?>
+
+
+
+NetMonster Admin - Import .ntm
+
+
+
+
+
+
+
+
Importer un fichier .ntm
+
Deposez un fichier .ntm pour l'analyser et placer ses antennes en file de validation. Rien n'est ecrit dans la base finale sans validation manuelle.
+
+
= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?>
+
= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
Resultat de l'import
+
+
= (int)$importStats['total'] ?>ligne(s) lues
+
= (int)$importStats['staged'] ?>placee(s) en validation
+
= (int)$importStats['already_known'] ?>deja connue(s) (doublon potentiel)
+
= (int)$importStats['error'] ?>erreur(s)
+
+
+
Apercu du regroupement par site (= count($previewGroups) ?> site(s) detecte(s))
+
Verification rapide que les cellules d'un meme site (meme eNB pour la 4G, ou meme position GPS pour les autres technos) sont bien reunies ensemble.
+
+
+
+
= htmlspecialchars($group['operator_brand'] ?? 'Operateur inconnu', ENT_QUOTES, 'UTF-8') ?> · = htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>, = htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?> · = count($group['cells']) ?> cellule= count($group['cells']) > 1 ? 's' : '' ?>
+
+
+ = htmlspecialchars((string)$c['tech'], ENT_QUOTES, 'UTF-8') ?> ·
+ Cell ID = htmlspecialchars((string)$c['cell_id'], ENT_QUOTES, 'UTF-8') ?> ·
+ Area code = htmlspecialchars((string)$c['area_code'], ENT_QUOTES, 'UTF-8') ?>
+ · eNB/RNC = htmlspecialchars((string)$c['sub_id2'], ENT_QUOTES, 'UTF-8') ?>
+ · = htmlspecialchars((string)$c['band'], ENT_QUOTES, 'UTF-8') ?>
+ · Deja en base #= (int)$c['possible_dup_of'] ?>
+
+
+
+
+
+
Aller a la page de validation →
+
+
+
+
+
Historique des imports
+
+ | Fichier | Pays | Date | Lignes | En validation | Erreurs | Statut |
+
+
+ | = htmlspecialchars($imp['filename'], ENT_QUOTES, 'UTF-8') ?> |
+ = htmlspecialchars($imp['country_name'] ?? '?', ENT_QUOTES, 'UTF-8') ?> |
+ = htmlspecialchars(date('d/m/Y H:i', strtotime($imp['uploaded_at'])), ENT_QUOTES, 'UTF-8') ?> |
+ = (int)$imp['total_lines'] ?> |
+ = (int)$imp['staged_count'] ?> |
+ = (int)$imp['error_count'] ?> |
+ = htmlspecialchars($imp['status'], ENT_QUOTES, 'UTF-8') ?> |
+
+
+ | Aucun import pour le moment. |
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/index.php b/admin/index.php
new file mode 100644
index 0000000..9f09980
--- /dev/null
+++ b/admin/index.php
@@ -0,0 +1,272 @@
+)
+ * s'executait comme JavaScript dans le navigateur de l'administrateur
+ * connecte, permettant de voler le jeton CSRF present dans le DOM et
+ * d'agir a sa place.
+ *
+ * Les popups sont maintenant construites via des elements DOM avec
+ * textContent, qui n'interprete jamais son contenu comme HTML/JS.
+ */
+declare(strict_types=1);
+
+require_once __DIR__ . '/admin_auth.php';
+require_once __DIR__ . '/../lib/db.php';
+require_once __DIR__ . '/_nav.php';
+
+require_admin_auth();
+$db = pdo();
+$user = current_admin_username();
+
+$countryFilter = $_GET['country'] ?? '';
+$operatorFilter = $_GET['operator'] ?? '';
+
+$totalSites = (int)$db->query('SELECT COUNT(*) FROM sites')->fetchColumn();
+$totalCells = (int)$db->query('SELECT COUNT(*) FROM cells')->fetchColumn();
+$totalPending = (int)$db->query('SELECT COUNT(*) FROM staging_cells WHERE status = "pending"')->fetchColumn();
+$totalCountries = (int)$db->query('SELECT COUNT(*) FROM countries')->fetchColumn();
+
+$byOperator = $db->query('
+ SELECT o.brand, o.mcc, o.mnc, c.name AS country_name, COUNT(DISTINCT s.id) AS site_count, COUNT(cl.id) AS cell_count
+ FROM operators o
+ LEFT JOIN countries c ON c.id = o.country_id
+ LEFT JOIN sites s ON s.operator_id = o.id
+ LEFT JOIN cells cl ON cl.site_id = s.id
+ GROUP BY o.id
+ ORDER BY c.name, o.brand
+')->fetchAll(PDO::FETCH_ASSOC);
+
+$byTech = $db->query('
+ SELECT tech, COUNT(*) AS cnt FROM cells GROUP BY tech ORDER BY FIELD(tech, "2G","3G","4G","5G","CDMA")
+')->fetchAll(PDO::FETCH_ASSOC);
+
+$recentActivity = $db->query('
+ SELECT s.id, s.lat, s.lon, s.location_desc, s.created_at, s.created_by, o.brand AS operator_brand, c.name AS country_name
+ FROM sites s
+ JOIN operators o ON o.id = s.operator_id
+ JOIN countries c ON c.id = s.country_id
+ ORDER BY s.created_at DESC
+ LIMIT 8
+')->fetchAll(PDO::FETCH_ASSOC);
+
+$sql = '
+ SELECT s.id AS site_id, s.lat, s.lon, s.location_desc,
+ o.brand AS operator_brand, c.name AS country_name, c.iso2,
+ GROUP_CONCAT(DISTINCT cl.tech ORDER BY cl.tech SEPARATOR ", ") AS techs,
+ COUNT(cl.id) AS cell_count
+ FROM sites s
+ JOIN operators o ON o.id = s.operator_id
+ JOIN countries c ON c.id = s.country_id
+ LEFT JOIN cells cl ON cl.site_id = s.id
+ WHERE 1=1
+';
+$params = [];
+if ($countryFilter !== '') { $sql .= ' AND c.iso2 = ?'; $params[] = $countryFilter; }
+if ($operatorFilter !== '') { $sql .= ' AND o.id = ?'; $params[] = $operatorFilter; }
+$sql .= ' GROUP BY s.id ORDER BY s.created_at DESC';
+
+$stmt = $db->prepare($sql);
+$stmt->execute($params);
+$sites = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
+$countries = $db->query('SELECT iso2, name, default_lat, default_lon, default_zoom FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
+
+$activeCountry = null;
+if ($countryFilter !== '') {
+ foreach ($countries as $c) { if ($c['iso2'] === $countryFilter) { $activeCountry = $c; break; } }
+}
+if (!$activeCountry && !empty($countries)) {
+ $activeCountry = $countries[0];
+}
+if (!$activeCountry) {
+ $activeCountry = ['default_lat' => 20, 'default_lon' => 0, 'default_zoom' => 2];
+}
+
+$operatorColors = ['POST' => '#0057A0', 'Tango' => '#E30613', 'Orange' => '#FF7900', 'LoL' => '#2E7D32'];
+$mapSites = array_map(fn($s) => [
+ 'lat' => (float)$s['lat'], 'lon' => (float)$s['lon'], 'operator' => $s['operator_brand'],
+ 'techs' => $s['techs'], 'cell_count' => (int)$s['cell_count'], 'desc' => $s['location_desc'], 'country' => $s['country_name'],
+], $sites);
+?>
+
+
+
+NetMonster Admin - Accueil
+
+
+
+
+
+
+
+
+
+
+
+
+
Recapitulatif des antennes
+
Vue d'ensemble de la base validee, tous pays et operateurs confondus.
+
+
+
= $totalSites ?>
Sites valides
+
= $totalCells ?>
Cellules validees
+
= $totalPending ?>
En attente de validation
+
= $totalCountries ?>
Pays configures
+
+
+
+
+
+
Carte des antennes validees
+
+
+ $color): ?>
+ = htmlspecialchars($brand, ENT_QUOTES, 'UTF-8') ?>
+
+ Autre
+
+
+
= count($sites) ?> site(s) affiche(s) sur la carte.
+
+
+
+
+
+
Par operateur
+
+ | Operateur | Pays | Sites | Cellules |
+
+
+ | = htmlspecialchars($o['brand'], ENT_QUOTES, 'UTF-8') ?> |
+ = htmlspecialchars($o['country_name'] ?? '?', ENT_QUOTES, 'UTF-8') ?> |
+ = (int)$o['site_count'] ?> |
+ = (int)$o['cell_count'] ?> |
+
+
+
+
+
+
+
Par technologie
+
+ | Techno | Cellules |
+
+ | = htmlspecialchars($t['tech'], ENT_QUOTES, 'UTF-8') ?> | = (int)$t['cnt'] ?> |
+
+ | Aucune cellule validee pour le moment. |
+
+
+
+
+
Activite recente
+
+
Aucun site valide pour le moment.
+
+
+
+
= htmlspecialchars($a['operator_brand'], ENT_QUOTES, 'UTF-8') ?> · = htmlspecialchars($a['country_name'], ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars($a['location_desc'] ?: ($a['lat'] . ', ' . $a['lon']), ENT_QUOTES, 'UTF-8') ?>
+
Ajoute par = htmlspecialchars($a['created_by'] ?? '?', ENT_QUOTES, 'UTF-8') ?> le = htmlspecialchars(date('d/m/Y H:i', strtotime($a['created_at'])), ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/login.php b/admin/login.php
new file mode 100644
index 0000000..b9d803c
--- /dev/null
+++ b/admin/login.php
@@ -0,0 +1,171 @@
+ Turnstile > Add site
+ * (gratuit, aucune carte bancaire requise).
+ */
+declare(strict_types=1);
+require_once __DIR__ . '/admin_auth.php';
+require_once __DIR__ . '/../lib/db.php';
+
+// Fichier de config NON versionne (a creer manuellement sur le
+// serveur, en dehors du depot Git) : voir config/turnstile.example.php
+$turnstileConfigPath = __DIR__ . '/../config/turnstile.php';
+$turnstileEnabled = false;
+
+if (is_file($turnstileConfigPath)) {
+ require_once $turnstileConfigPath;
+
+ $turnstileEnabled = defined('TURNSTILE_ENABLED') && TURNSTILE_ENABLED
+ && defined('TURNSTILE_SITE_KEY') && TURNSTILE_SITE_KEY !== ''
+ && defined('TURNSTILE_SECRET_KEY') && TURNSTILE_SECRET_KEY !== '';
+
+ if ($turnstileEnabled && !function_exists('curl_init')) {
+ error_log('[login] Turnstile configure mais extension php-curl absente : CAPTCHA desactive automatiquement.');
+ $turnstileEnabled = false;
+ }
+}
+
+if (!defined('TURNSTILE_FAIL_CLOSED')) {
+ define('TURNSTILE_FAIL_CLOSED', false);
+}
+
+$db = pdo();
+$error = null;
+$timeout = isset($_GET['timeout']);
+
+/**
+ * Verifie le jeton Turnstile aupres de l'API Cloudflare.
+ *
+ * Retourne true si le jeton est valide. Si l'appel reseau vers
+ * Cloudflare echoue (timeout, DNS, panne), le comportement depend de
+ * TURNSTILE_FAIL_CLOSED :
+ * - false (par defaut) : on laisse passer la tentative (le login
+ * reste protege par le mot de passe et le rate limiting SQL), pour
+ * ne jamais bloquer l'acces admin a cause d'une panne Cloudflare
+ * ou d'une coupure Internet sortante temporaire sur la VM.
+ * - true : on bloque la tentative tant que Turnstile n'a pas
+ * confirme, pour un site expose publiquement sans acces de secours
+ * alternatif.
+ */
+function verifyTurnstileToken(string $token, string $remoteIp): bool
+{
+ if ($token === '') {
+ return false;
+ }
+
+ $ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_POSTFIELDS => http_build_query([
+ 'secret' => TURNSTILE_SECRET_KEY,
+ 'response' => $token,
+ 'remoteip' => $remoteIp,
+ ]),
+ ]);
+ $response = curl_exec($ch);
+ $curlError = curl_errno($ch);
+ curl_close($ch);
+
+ if ($curlError !== 0 || $response === false) {
+ error_log('[login] Turnstile injoignable (curl errno ' . $curlError . '). Fail-closed=' . (TURNSTILE_FAIL_CLOSED ? 'oui' : 'non') . '.');
+ return !TURNSTILE_FAIL_CLOSED;
+ }
+
+ $decoded = json_decode($response, true);
+ return is_array($decoded) && !empty($decoded['success']);
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ require_csrf_token();
+ $username = trim((string)($_POST['username'] ?? ''));
+ $password = (string)($_POST['password'] ?? '');
+
+ $turnstileVerified = true; // reste a true si Turnstile est desactive
+ if ($turnstileEnabled) {
+ $turnstileToken = (string)($_POST['cf-turnstile-response'] ?? '');
+ $turnstileVerified = verifyTurnstileToken($turnstileToken, client_ip_address());
+ }
+
+ if (!$turnstileVerified) {
+ $error = 'Verification anti-robot echouee. Reessayez.';
+ } elseif ($username === '') {
+ $error = 'Identifiants incorrects.';
+ } elseif (is_login_locked_out($db, $username)) {
+ $error = 'Trop de tentatives echouees. Reessayez dans quelques minutes.';
+ } else {
+ if (attempt_admin_login($db, $username, $password)) {
+ record_login_attempt($db, $username, true, $turnstileVerified);
+ header('Location: index.php');
+ exit;
+ }
+ record_login_attempt($db, $username, false, $turnstileVerified);
+ $error = 'Identifiants incorrects.';
+ }
+}
+?>
+
+
+
+Connexion - NetMonster Admin
+
+
+
+
+
+
+
+
+
NetMonster Admin
+
Session expiree, veuillez vous reconnecter.
+
= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
\ No newline at end of file