Import
This commit is contained in:
1 parent
51c49327d4
commit
bb2686ef62
4 files changed
+918
No files matched your search
@@ -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>
|
||||||
@@ -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') ?> · <?= 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' : '' ?></h4>
|
||||||
|
<?php foreach ($group['cells'] as $c): ?>
|
||||||
|
<div class="cell-line">
|
||||||
|
<?= 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') ?>
|
||||||
|
<?php if (!empty($c['sub_id2'])): ?> · eNB/RNC <?= htmlspecialchars((string)$c['sub_id2'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
|
||||||
|
<?php if (!empty($c['band'])): ?> · <?= htmlspecialchars((string)$c['band'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
|
||||||
|
<?php if ($c['possible_dup_of']): ?> · <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 →</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
@@ -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> · <?= 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: '© <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
@@ -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>
|
||||||
Reference in new issue
Block a user