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.

+ + +

+ +

+ + +
+

Imports ()

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#FichierDateUtilisateurStatutLignes totalesImporteesRejeteesActions
+ Voir erreurs + 0): ?> +
+ + + + + +
+ +
Aucun import pour le moment.
+ + 1): ?> + + +
+ + +
+

Erreurs de l'import #

+ +

Aucune erreur enregistree pour cet import.

+ + + + + + + + + + + + + + +
LigneMessageContenu brut
+ +
+ +
+ + \ 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.

+ +

+

+ +
+

Nouveau depot

+
+ +
+
+ + +
+
+
+

Selectionnez un fichier .ntm (taille maximale : 10 Mo)

+ +
+ +
+
+ + +
+

Resultat de l'import

+
+
ligne(s) lues
+
placee(s) en validation
+
deja connue(s) (doublon potentiel)
+
erreur(s)
+
+ +

Apercu du regroupement par site ( 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.

+ + +
+

· , · cellule 1 ? 's' : '' ?>

+ +
+ · + Cell ID · + Area code + · eNB/RNC + · + · Deja en base # +
+ +
+ + +

Aller a la page de validation →

+
+ + +
+

Historique des imports

+ + + + + + + + + + + + + + +
FichierPaysDateLignesEn validationErreursStatut
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.

+ +
+
Sites valides
+
Cellules validees
+
En attente de validation
+
Pays configures
+
+ +
+
+
+

Carte des antennes validees

+
+ + +
+
+ $color): ?> + + + Autre +
+
+

site(s) affiche(s) sur la carte.

+
+
+ +
+
+

Par operateur

+ + + + + + + + + + +
OperateurPaysSitesCellules
+
+ +
+

Par technologie

+ + + + + + +
TechnoCellules
Aucune cellule validee pour le moment.
+
+ +
+

Activite recente

+ +

Aucun site valide pour le moment.

+ + +
+ ·
+ +
Ajoute par le
+
+ +
+
+
+
+ + + + \ 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.

+

+
+ + + + + + +
+ + +
+
+ + \ No newline at end of file