This commit is contained in:
Voxan committed 2026-10-21 22:00:31 +02:00
1 parent 51c49327d4
commit bb2686ef62
4 files changed
+918

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
<?php
/**
* admin/import_history.php
*
* Historique des imports .ntm et de leurs erreurs. Lecture seule sauf
* pour la purge optionnelle des lignes d'erreur d'un import, action
* protegee par CSRF + confirmation explicite.
*
* CORRIGE (20/09/2026, suite a erreur 500 en usage reel) :
* - require_once _nav.php manquait, provoquant une erreur fatale
* "Call to undefined function render_admin_nav()".
* - Les tables import_batches / import_errors necessaires a cette
* page n'existaient pas : voir
* admin/sql/migration_add_missing_tables.sql.
*
* IMPORTANT : import_batches/import_errors sont DISTINCTES de la table
* `imports` deja existante et alimentee par lib/ntm_import_to_staging.php
* (utilisee par admin/import_ntm.php). Tant qu'aucune page n'insere
* explicitement dans import_batches/import_errors, cette page
* s'affichera correctement mais restera VIDE ("Aucun import pour le
* moment"), meme si vous avez deja importe des fichiers .ntm via
* import_ntm.php. Une evolution future pourrait brancher cette page
* directement sur la table `imports` existante plutot que de dupliquer
* un systeme de suivi parallele jamais alimente.
*
* Corrige suite a l'audit du 20/09/2026 : le detail technique des
* exceptions n'est plus affiche a l'ecran ; il est journalise via
* error_log().
*
* Hypothese de schema :
* import_batches(id, filename, imported_at, imported_by, status,
* rows_total, rows_imported, rows_rejected, country_id)
* import_errors(id, batch_id, line_number, message, raw_line)
*/
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();
$feedback = null;
$feedbackType = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$action = $_POST['action'] ?? '';
$confirmed = isset($_POST['confirm']) && $_POST['confirm'] === '1';
$batchId = filter_input(INPUT_POST, 'batch_id', FILTER_VALIDATE_INT);
if ($action === 'purge_errors') {
if (!$confirmed || !$batchId) {
$feedback = 'Purge refusee : confirmation ou identifiant manquant.';
$feedbackType = 'error';
} else {
try {
$stmt = $db->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 '<span class="badge ' . $class . '">' . htmlspecialchars($status, ENT_QUOTES, 'UTF-8') . '</span>';
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Historique des imports — NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('import_history.php', $user); ?>
<main class="page-wrap">
<h1>Historique des imports .ntm</h1>
<p class="page-subtitle">Suivi en lecture seule des imports realises. La purge des erreurs d'un import est possible mais n'affecte jamais les donnees deja validees.</p>
<?php if ($feedback !== null): ?>
<p class="<?= $feedbackType === 'error' ? 'msg-err' : 'msg-ok' ?>">
<?= htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
</p>
<?php endif; ?>
<section class="card">
<h2>Imports (<?= $totalBatches ?>)</h2>
<table class="data-table">
<tr>
<th>#</th>
<th>Fichier</th>
<th>Date</th>
<th>Utilisateur</th>
<th>Statut</th>
<th>Lignes totales</th>
<th>Importees</th>
<th>Rejetees</th>
<th>Actions</th>
</tr>
<?php foreach ($batches as $batch): ?>
<tr>
<td><?= (int)$batch['id'] ?></td>
<td><?= htmlspecialchars((string)$batch['filename'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$batch['imported_at'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$batch['imported_by'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= status_badge((string)$batch['status']) ?></td>
<td><?= (int)$batch['rows_total'] ?></td>
<td><?= (int)$batch['rows_imported'] ?></td>
<td><?= (int)$batch['rows_rejected'] ?></td>
<td>
<a href="import_history.php?detail=<?= (int)$batch['id'] ?>">Voir erreurs</a>
<?php if ((int)$batch['rows_rejected'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Confirmer la purge des erreurs de cet import ?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="purge_errors">
<input type="hidden" name="confirm" value="1">
<input type="hidden" name="batch_id" value="<?= (int)$batch['id'] ?>">
<button type="submit" class="reject-small">Purger erreurs</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($batches)): ?>
<tr><td colspan="9" class="no-cadastre">Aucun import pour le moment.</td></tr>
<?php endif; ?>
</table>
<?php if ($totalPages > 1): ?>
<nav class="pagination">
<?php for ($p = 1; $p <= $totalPages; $p++): ?>
<a href="import_history.php?page=<?= $p ?>" class="<?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
<?php endfor; ?>
</nav>
<?php endif; ?>
</section>
<?php if ($detailBatchId): ?>
<section class="card">
<h2>Erreurs de l'import #<?= (int)$detailBatchId ?></h2>
<?php if (empty($errorsByBatch)): ?>
<p>Aucune erreur enregistree pour cet import.</p>
<?php else: ?>
<table class="data-table">
<tr>
<th>Ligne</th>
<th>Message</th>
<th>Contenu brut</th>
</tr>
<?php foreach ($errorsByBatch as $err): ?>
<tr>
<td><?= (int)$err['line_number'] ?></td>
<td><?= htmlspecialchars((string)$err['message'], ENT_QUOTES, 'UTF-8') ?></td>
<td><code><?= htmlspecialchars((string)$err['raw_line'], ENT_QUOTES, 'UTF-8') ?></code></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</section>
<?php endif; ?>
</main>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
<?php
/**
* admin/import_ntm.php
*
* Interface d'upload manuel d'un fichier .ntm directement depuis l'admin
* (en plus du flux existant via contribute.php). Reutilise
* importNtmFileToStaging() de lib/ntm_import_to_staging.php : le fichier
* est parse, chaque cellule extraite atterrit dans staging_cells (status
* pending), rien n'est ecrit dans sites/cells sans validation manuelle
* via /admin/review.php.
*
* Apres import, la page affiche un APERCU du regroupement par site
* (meme logique deriveSiteKey() que review.php) pour confirmer
* immediatement que les cellules d'un meme site sont bien reunies
* ensemble, avant meme d'aller sur la page de validation.
*
* Tous les fichiers numeriques passes a htmlspecialchars() sont castes
* en (string) au prealable (voir correctifs precedents sur review.php
* et edit_site.php pour PHP 8.3+).
*
* Corrige suite a l'audit du 20/09/2026 : le detail technique d'une
* exception ($e->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);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>NetMonster Admin - Import .ntm</title>
<link rel="stylesheet" href="assets/theme.css">
<style>
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.5rem; text-align: center; background: var(--surface-alt); }
.upload-zone input[type="file"] { margin-top: .8rem; }
.stats-inline { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
.preview-group { background: var(--surface-alt); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .8rem 1rem; margin-bottom: .7rem; }
.preview-group h4 { margin: 0 0 .4rem; font-size: .88rem; }
.preview-group .cell-line { font-size: .78rem; color: var(--text-muted); font-family: monospace; }
</style>
</head>
<body>
<?php render_admin_nav('import_ntm.php', $user); ?>
<div class="page-wrap">
<h1>Importer un fichier .ntm</h1>
<p class="page-subtitle">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.</p>
<?php if ($successMessage): ?><p class="msg-ok"><?= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<?php if ($errorMessage): ?><p class="msg-err"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<div class="card">
<h2>Nouveau depot</h2>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="form-grid" style="margin-bottom:1rem;">
<div>
<label>Pays concerne</label>
<select name="country_id" required>
<option value="">-- choisir --</option>
<?php foreach ($countries as $c): ?>
<option value="<?= (int)$c['id'] ?>"><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="upload-zone">
<p>Selectionnez un fichier <strong>.ntm</strong> (taille maximale : 10 Mo)</p>
<input type="file" name="ntm_file" accept=".ntm" required>
</div>
<button type="submit" class="primary" style="margin-top:1rem;">Importer et envoyer en validation</button>
</form>
</div>
<?php if ($importStats): ?>
<div class="card">
<h2>Resultat de l'import</h2>
<div class="stats-inline">
<div class="stat-chip"><strong><?= (int)$importStats['total'] ?></strong>ligne(s) lues</div>
<div class="stat-chip"><strong><?= (int)$importStats['staged'] ?></strong>placee(s) en validation</div>
<div class="stat-chip"><strong><?= (int)$importStats['already_known'] ?></strong>deja connue(s) (doublon potentiel)</div>
<div class="stat-chip"><strong><?= (int)$importStats['error'] ?></strong>erreur(s)</div>
</div>
<h2 style="margin-top:1.4rem;">Apercu du regroupement par site (<?= count($previewGroups) ?> site(s) detecte(s))</h2>
<p class="page-subtitle">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.</p>
<?php foreach ($previewGroups as $group): ?>
<div class="preview-group">
<h4><?= htmlspecialchars($group['operator_brand'] ?? 'Operateur inconnu', ENT_QUOTES, 'UTF-8') ?> &middot; <?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>, <?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?> &middot; <?= count($group['cells']) ?> cellule<?= count($group['cells']) > 1 ? 's' : '' ?></h4>
<?php foreach ($group['cells'] as $c): ?>
<div class="cell-line">
<?= htmlspecialchars((string)$c['tech'], ENT_QUOTES, 'UTF-8') ?> &middot;
Cell ID <?= htmlspecialchars((string)$c['cell_id'], ENT_QUOTES, 'UTF-8') ?> &middot;
Area code <?= htmlspecialchars((string)$c['area_code'], ENT_QUOTES, 'UTF-8') ?>
<?php if (!empty($c['sub_id2'])): ?> &middot; eNB/RNC <?= htmlspecialchars((string)$c['sub_id2'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
<?php if (!empty($c['band'])): ?> &middot; <?= htmlspecialchars((string)$c['band'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
<?php if ($c['possible_dup_of']): ?> &middot; <span class="badge badge-dup">Deja en base #<?= (int)$c['possible_dup_of'] ?></span><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<p style="margin-top:1rem;"><a href="review.php" class="btn primary" style="text-decoration:none;display:inline-block;">Aller a la page de validation &rarr;</a></p>
</div>
<?php endif; ?>
<div class="card">
<h2>Historique des imports</h2>
<table class="data-table">
<tr><th>Fichier</th><th>Pays</th><th>Date</th><th>Lignes</th><th>En validation</th><th>Erreurs</th><th>Statut</th></tr>
<?php foreach ($importHistory as $imp): ?>
<tr>
<td><?= htmlspecialchars($imp['filename'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars($imp['country_name'] ?? '?', ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars(date('d/m/Y H:i', strtotime($imp['uploaded_at'])), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$imp['total_lines'] ?></td>
<td><?= (int)$imp['staged_count'] ?></td>
<td><?= (int)$imp['error_count'] ?></td>
<td><?= htmlspecialchars($imp['status'], ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($importHistory)): ?><tr><td colspan="7" class="no-cadastre">Aucun import pour le moment.</td></tr><?php endif; ?>
</table>
</div>
</div>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
<?php
/**
* admin/index.php
*
* Corrige suite a l'audit du 20/09/2026 : les popups Leaflet
* n'injectent plus de HTML construit par concatenation de chaines a
* partir de donnees issues de la base (operateur, pays, description,
* technos). Ces valeurs peuvent provenir d'imports .ntm ou de saisies
* manuelles ; sans cette correction, un contenu malveillant stocke en
* base (ex: location_desc contenant une balise <img onerror=...>)
* 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);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>NetMonster Admin - Accueil</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<link rel="stylesheet" href="assets/theme.css">
<style>
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.4rem; }
.kpi-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.3rem; box-shadow: var(--shadow); }
.kpi-card .kpi-value { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.kpi-card .kpi-label { font-size: .82rem; color: var(--text-muted); margin-top: .2rem; }
.two-col { display: grid; grid-template-columns: 2fr 1fr; gap: 1.4rem; align-items: start; }
.legend-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: .8rem; font-size: .82rem; color: var(--text-muted); }
.legend-row .dot-legend { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: .3rem; }
.activity-item { padding: .6rem 0; border-bottom: 1px solid var(--border); font-size: .85rem; }
.activity-item:last-child { border-bottom: none; }
.activity-item .meta { color: var(--text-muted); font-size: .76rem; }
.popup-line { margin: 0; }
.popup-title { font-weight: 700; }
@media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } .two-col { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<?php render_admin_nav('index.php', $user); ?>
<div class="page-wrap">
<h1>Recapitulatif des antennes</h1>
<p class="page-subtitle">Vue d'ensemble de la base validee, tous pays et operateurs confondus.</p>
<div class="kpi-grid">
<div class="kpi-card"><div class="kpi-value"><?= $totalSites ?></div><div class="kpi-label">Sites valides</div></div>
<div class="kpi-card"><div class="kpi-value"><?= $totalCells ?></div><div class="kpi-label">Cellules validees</div></div>
<div class="kpi-card"><div class="kpi-value"><?= $totalPending ?></div><div class="kpi-label">En attente de validation</div></div>
<div class="kpi-card"><div class="kpi-value"><?= $totalCountries ?></div><div class="kpi-label">Pays configures</div></div>
</div>
<div class="two-col">
<div>
<div class="card">
<h2>Carte des antennes validees</h2>
<form class="filters" method="get">
<select name="country" onchange="this.form.submit()">
<option value="">Tous les pays</option>
<?php foreach ($countries as $c): ?>
<option value="<?= htmlspecialchars($c['iso2'], ENT_QUOTES, 'UTF-8') ?>" <?= $countryFilter === $c['iso2'] ? 'selected' : '' ?>><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<select name="operator" onchange="this.form.submit()">
<option value="">Tous les operateurs</option>
<?php foreach ($operators as $o): ?>
<option value="<?= (int)$o['id'] ?>" <?= (string)$operatorFilter === (string)$o['id'] ? 'selected' : '' ?>><?= htmlspecialchars($o['brand'], ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($o['mcc'].'-'.$o['mnc'], ENT_QUOTES, 'UTF-8') ?>)</option>
<?php endforeach; ?>
</select>
</form>
<div class="legend-row">
<?php foreach ($operatorColors as $brand => $color): ?>
<span><span class="dot-legend" style="background:<?= htmlspecialchars($color, ENT_QUOTES, 'UTF-8') ?>"></span><?= htmlspecialchars($brand, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
<span><span class="dot-legend" style="background:#666"></span>Autre</span>
</div>
<div id="map"></div>
<p class="page-subtitle" style="margin:0;"><?= count($sites) ?> site(s) affiche(s) sur la carte.</p>
</div>
</div>
<div>
<div class="card">
<h2>Par operateur</h2>
<table class="data-table">
<tr><th>Operateur</th><th>Pays</th><th>Sites</th><th>Cellules</th></tr>
<?php foreach ($byOperator as $o): ?>
<tr>
<td><?= htmlspecialchars($o['brand'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars($o['country_name'] ?? '?', ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$o['site_count'] ?></td>
<td><?= (int)$o['cell_count'] ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<div class="card">
<h2>Par technologie</h2>
<table class="data-table">
<tr><th>Techno</th><th>Cellules</th></tr>
<?php foreach ($byTech as $t): ?>
<tr><td><?= htmlspecialchars($t['tech'], ENT_QUOTES, 'UTF-8') ?></td><td><?= (int)$t['cnt'] ?></td></tr>
<?php endforeach; ?>
<?php if (empty($byTech)): ?><tr><td colspan="2" class="no-cadastre">Aucune cellule validee pour le moment.</td></tr><?php endif; ?>
</table>
</div>
<div class="card">
<h2>Activite recente</h2>
<?php if (empty($recentActivity)): ?>
<p class="no-cadastre">Aucun site valide pour le moment.</p>
<?php endif; ?>
<?php foreach ($recentActivity as $a): ?>
<div class="activity-item">
<strong><?= htmlspecialchars($a['operator_brand'], ENT_QUOTES, 'UTF-8') ?></strong> &middot; <?= htmlspecialchars($a['country_name'], ENT_QUOTES, 'UTF-8') ?><br>
<?= htmlspecialchars($a['location_desc'] ?: ($a['lat'] . ', ' . $a['lon']), ENT_QUOTES, 'UTF-8') ?>
<div class="meta">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') ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<script>
const map = L.map('map').setView([<?= (float)$activeCountry['default_lat'] ?>, <?= (float)$activeCountry['default_lon'] ?>], <?= (int)$activeCountry['default_zoom'] ?>);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
}).addTo(map);
const operatorColors = <?= json_encode($operatorColors, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const sites = <?= json_encode($mapSites, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const clusterGroup = L.markerClusterGroup();
/**
* Construit le contenu d'une popup via des elements DOM avec
* textContent (jamais innerHTML/template literal), afin qu'un
* contenu malveillant stocke en base (nom d'operateur, description,
* technos) ne puisse jamais s'executer comme HTML/JavaScript.
*/
function buildSitePopup(site) {
const container = document.createElement('div');
const title = document.createElement('div');
title.className = 'popup-title';
title.textContent = `${site.operator ?? ''} - ${site.country ?? ''}`;
container.appendChild(title);
const technos = document.createElement('div');
technos.className = 'popup-line';
technos.textContent = `Technos : ${site.techs ?? '-'}`;
container.appendChild(technos);
const cellCount = document.createElement('div');
cellCount.className = 'popup-line';
cellCount.textContent = `${site.cell_count} cellule(s)`;
container.appendChild(cellCount);
if (site.desc) {
const desc = document.createElement('div');
desc.className = 'popup-line';
desc.textContent = site.desc;
container.appendChild(desc);
}
return container;
}
sites.forEach(s => {
const color = operatorColors[s.operator] || '#666';
const marker = L.circleMarker([s.lat, s.lon], { radius: 8, color: color, fillColor: color, fillOpacity: 0.8, weight: 2 })
.bindPopup(buildSitePopup(s));
clusterGroup.addLayer(marker);
});
map.addLayer(clusterGroup);
if (sites.length > 0) {
map.fitBounds(L.latLngBounds(sites.map(s => [s.lat, s.lon])), { padding: [30, 30] });
}
</script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* admin/login.php
*
* Durci suite a l'audit du 20/09/2026 :
* - Rate limiting deplace en base (voir admin_auth.php / table
* login_attempts), avec deux seuils separes (compte+IP, et IP
* seule) pour eviter qu'une IP partagee (ex: sortie Tailscale
* commune) ne bloque tout le monde a cause d'un seul identifiant
* en echec.
* - CAPTCHA Cloudflare Turnstile OPTIONNEL et configurable, pour
* bloquer les scripts automatises de bruteforce. Desactive par
* defaut tant que config/turnstile.php n'existe pas, afin de ne
* jamais bloquer l'acces admin par erreur de configuration.
* - Verification explicite de la disponibilite de l'extension
* php-curl avant d'activer Turnstile : si l'extension est absente,
* Turnstile est desactive proprement (avec un log) plutot que de
* provoquer une erreur fatale sur la page de connexion.
* - "Fail mode" configurable si Cloudflare est injoignable : par
* defaut TURNSTILE_FAIL_CLOSED=false, pour ne pas te bloquer toi-
* meme si Cloudflare est indisponible ou si la VM perd
* temporairement l'acces Internet sortant. Passez-le a true
* uniquement si vous avez un acces de secours (ex: SSH+CLI pour
* reinitialiser un mot de passe) en cas de probleme reseau.
*
* Configuration (fichier NON versionne, cf config/turnstile.example.php) :
* TURNSTILE_ENABLED : true/false, active ou non le CAPTCHA
* TURNSTILE_SITE_KEY : cle publique, visible cote client
* TURNSTILE_SECRET_KEY : cle secrete, JAMAIS exposee cote client
* TURNSTILE_FAIL_CLOSED : true/false, comportement si Cloudflare
* est injoignable (defaut recommande: false)
*
* Obtenir ces cles : https://dash.cloudflare.com/ > 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.';
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>Connexion - NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
<?php if ($turnstileEnabled): ?>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<?php endif; ?>
<style>
body { display:flex; align-items:center; justify-content:center; height:100vh; }
.login-card { width: 320px; }
.login-card .brand { display:flex; align-items:center; gap:.6rem; justify-content:center; margin-bottom:1rem; font-weight:700; font-size:1.1rem; }
.cf-turnstile { margin: 1rem 0; display:flex; justify-content:center; }
</style>
</head>
<body>
<div class="card login-card">
<div class="brand"><span class="dot"></span> NetMonster Admin</div>
<?php if ($timeout): ?><p class="msg-err">Session expiree, veuillez vous reconnecter.</p><?php endif; ?>
<?php if ($error): ?><p class="msg-err"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<form method="post">
<?= csrf_field() ?>
<label>Utilisateur</label>
<input type="text" name="username" required autocomplete="username">
<label>Mot de passe</label>
<input type="password" name="password" required autocomplete="current-password">
<?php if ($turnstileEnabled): ?>
<div class="cf-turnstile" data-sitekey="<?= htmlspecialchars(TURNSTILE_SITE_KEY, ENT_QUOTES, 'UTF-8') ?>"></div>
<?php endif; ?>
<button type="submit" class="primary" style="width:100%;margin-top:1rem;">Se connecter</button>
</form>
</div>
</body>
</html>