Files
maria_uslugi/admin/tools/gc.php
T
2026-03-05 10:25:39 +01:00

369 lines
17 KiB
PHP

<?php
/**
* /admin/tools/purger_mess_upload.php
* Affichage Bootstrap pour purger messages/uploads (orphans + fix DB).
* - S'affiche seulement si ?page=purger_mess_upload
* - DRY-RUN par défaut (scan), purge LIVE via POST + CSRF.
*/
require_once __DIR__ . '/../../public/includes/init_admin.php';
if (($_GET['page'] ?? '') !== 'purger_mess_upload') {
return; // ne rien afficher hors de la page ciblée
}
// -- Sécurité: on suppose que admin/index.php a déjà fait require_login_or_redirect(['admin'])
// Mais on ajoute une ceinture si besoin :
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['role']) || !in_array('admin', (array)$_SESSION['role'], true)) {
echo '<div class="container my-4"><div class="alert alert-danger">'
. htmlspecialchars((string)($translations['admin_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
. '</div></div>';
return;
}
// reauth (10 min)
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
// -- CSRF
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$CSRF = $_SESSION['csrf_token'];
// -----------------------
// Paramètres utilisateur
// -----------------------
$uploadsSubdir = 'messages/uploads'; // relatif à la racine du site
$minAgeDays = isset($_REQUEST['min_age']) ? max(0, (int)$_REQUEST['min_age']) : 2;
$fixDbRefs = isset($_REQUEST['fix_db']) ? ($_REQUEST['fix_db'] === '1') : true;
$doLive = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'purge') {
// Vérif CSRF
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
echo '<div class="container my-4"><div class="alert alert-danger">'
. htmlspecialchars((string)($translations['csrf_invalid'] ?? 'Token CSRF invalide.'), ENT_QUOTES, 'UTF-8')
. '</div></div>';
return;
}
$doLive = true;
}
// -----------------------
// Résolution des chemins
// -----------------------
$siteRoot = realpath(__DIR__ . '/../../'); // racine du site
$uploadsDir = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $uploadsSubdir);
// -----------------------
// Garde-fous fichiers
// -----------------------
$errors = [];
if (!$siteRoot || !is_dir($siteRoot)) {
$errors[] = htmlspecialchars((string)($translations['purge_mess_root_missing'] ?? 'Racine du site introuvable.'), ENT_QUOTES, 'UTF-8');
}
if (!is_dir($uploadsDir)) {
$tpl = (string)($translations['purge_mess_uploads_dir_missing'] ?? 'Dossier uploads introuvable: <code>{PATH}</code>');
$errors[] = str_replace('{PATH}', htmlspecialchars((string)$uploadsDir, ENT_QUOTES, 'UTF-8'), $tpl);
}
if (!is_readable($uploadsDir)) {
$errors[] = htmlspecialchars((string)($translations['purge_mess_uploads_not_readable'] ?? 'Dossier uploads non lisible.'), ENT_QUOTES, 'UTF-8');
}
if ($doLive && !is_writable($uploadsDir)) {
$errors[] = htmlspecialchars((string)($translations['purge_mess_uploads_not_writable_live'] ?? 'Dossier uploads non inscriptible alors que purge LIVE demandée.'), ENT_QUOTES, 'UTF-8');
}
// -----------------------
// Utilitaires
// -----------------------
$logs = [];
function logln(string $msg){ global $logs; $logs[] = $msg; }
function ageDays(int $mtime): float { return (time() - $mtime) / 86400.0; }
function safeWithinUploads(string $path, string $uploadsRoot): bool {
$rp = realpath($path);
$ur = realpath($uploadsRoot);
return $rp && $ur && strpos($rp, $ur) === 0;
}
// -----------------------
// Exécution (si OK)
// -----------------------
$start = microtime(true);
$stats = [
'seen' => 0,
'deleted' => 0,
'skipped' => 0,
'fixed' => 0,
];
$deletedList = [];
$dry = !$doLive;
if (empty($errors)) {
// 1) Chemins référencés en DB
$dbPaths = [];
if (isset($pdo) && $pdo instanceof PDO) {
$stmt = $pdo->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$p = trim((string)$row['attachment_path']);
if ($p === '') continue;
if (strpos($p, $uploadsSubdir . '/') === 0 || $p === $uploadsSubdir) {
$dbPaths[$p] = true;
}
}
$tpl = (string)($translations['purge_mess_db_refs_loaded'] ?? 'Références DB chargées: {N}');
logln(str_replace('{N}', (string)count($dbPaths), $tpl));
} else {
logln($translations['purge_mess_pdo_unavailable_info'] ?? "[INFO] PDO indisponible: l'étape DB sera limitée.");
}
// 2) Balayage disque
$rii = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_FILEINFO)
);
foreach ($rii as $fi) {
/** @var SplFileInfo $fi */
if (!$fi->isFile()) continue;
$stats['seen']++;
$basename = $fi->getFilename();
if ($basename === '.htaccess' || $basename[0] === '.') continue;
$absPath = $fi->getRealPath();
if (!$absPath) continue;
$relFromUploads = ltrim(str_replace($uploadsDir, '', $absPath), DIRECTORY_SEPARATOR);
$webRel = rtrim($uploadsSubdir, '/').'/'.str_replace(DIRECTORY_SEPARATOR, '/', $relFromUploads);
// référencé ?
if (isset($dbPaths[$webRel])) continue;
// âge
$age = ageDays($fi->getMTime());
if ($age < $minAgeDays) {
$stats['skipped']++;
continue;
}
// garde-fou
if (!safeWithinUploads($absPath, $uploadsDir)) {
$tpl = (string)($translations['purge_mess_alert_outside_scope_skip'] ?? '[ALERTE] Hors périmètre uploads (skip): {PATH}');
logln(str_replace('{PATH}', $absPath, $tpl));
continue;
}
if ($dry) {
$deletedList[] = [$webRel, $age, 'simulation'];
$tpl = (string)($translations['purge_mess_log_dry_orphan_sim'] ?? '[DRY] Orphelin: {FILE} ({DAYS} j) -> suppression simulée');
logln(str_replace(
['{FILE}','{DAYS}'],
[$webRel, number_format($age,1)],
$tpl
));
} else {
if (@unlink($absPath)) {
$stats['deleted']++;
$deletedList[] = [$webRel, $age, 'supprimé'];
$tpl = (string)($translations['purge_mess_log_ok_deleted'] ?? '[OK] Supprimé: {FILE} ({DAYS} j)');
logln(str_replace(
['{FILE}','{DAYS}'],
[$webRel, number_format($age,1)],
$tpl
));
} else {
$tpl = (string)($translations['purge_mess_log_warn_delete_failed'] ?? '[WARN] Échec suppression: {FILE}');
logln(str_replace('{FILE}', $webRel, $tpl));
}
}
}
// 3) Normaliser DB si fichier manquant
if ($fixDbRefs && isset($pdo) && $pdo instanceof PDO) {
$stmt = $pdo->query("SELECT id, attachment_path FROM messages WHERE attachment_path IS NOT NULL");
$update = $pdo->prepare("UPDATE messages SET attachment_path = NULL, attachment_name = NULL WHERE id = ?");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$id = (int)$row['id'];
$p = (string)$row['attachment_path'];
if ($p === '' || strpos($p, $uploadsSubdir . '/') !== 0) continue;
$abs = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $p);
$absReal = realpath($abs);
if (!$absReal || !is_file($absReal)) {
if ($dry) {
$tpl = (string)($translations['purge_mess_log_dry_db_fix'] ?? '[DRY] DB FIX id={ID} : {PATH} -> NULL (fichier manquant)');
logln(str_replace(['{ID}','{PATH}'], [(string)$id, $p], $tpl));
} else {
$update->execute([$id]);
$stats['fixed'] += $update->rowCount();
$tpl = (string)($translations['purge_mess_log_ok_db_fix'] ?? '[OK] DB FIX id={ID} : {PATH} -> NULL');
logln(str_replace(['{ID}','{PATH}'], [(string)$id, $p], $tpl));
}
}
}
}
}
$dur = microtime(true) - $start;
// -----------------------
// Rendu Bootstrap
// -----------------------
?>
<div class="shadow p-4 rounded bg-light">
<div class="container my-4">
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
<h3 class="m-0"><?= htmlspecialchars((string)($translations['purge_mess_title'] ?? 'Purger PJ messagerie'), ENT_QUOTES, 'UTF-8') ?></h3>
<span class="badge text-bg-<?= $dry ? 'secondary' : 'danger' ?>">
<?= $dry
? htmlspecialchars((string)($translations['purge_mess_mode_dry'] ?? 'DRY-RUN'), ENT_QUOTES, 'UTF-8')
: htmlspecialchars((string)($translations['purge_mess_mode_live'] ?? 'LIVE (suppression activée)'), ENT_QUOTES, 'UTF-8')
?>
</span>
<span class="badge text-bg-info">
<?php
$tpl = (string)($translations['purge_mess_age_threshold_badge'] ?? 'Seuil d’âge ≥ {N} j');
echo htmlspecialchars(str_replace('{N}', (string)((int)$minAgeDays), $tpl), ENT_QUOTES, 'UTF-8');
?>
</span>
<span class="badge text-bg-<?= $fixDbRefs ? 'primary':'secondary' ?>">
<?php
$tpl = (string)($translations['purge_mess_fix_db_badge'] ?? 'Fix refs DB : {VAL}');
$val = $fixDbRefs ? ($translations['yes'] ?? 'OUI') : ($translations['no'] ?? 'NON');
echo htmlspecialchars(str_replace('{VAL}', (string)$val, $tpl), ENT_QUOTES, 'UTF-8');
?>
</span>
</div>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger mb-3">
<strong><?= htmlspecialchars((string)($translations['errors_title'] ?? 'Erreurs'), ENT_QUOTES, 'UTF-8') ?></strong><br>
<ul class="mb-0">
<?php foreach ($errors as $e): ?>
<li><?= $e ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card mb-3">
<div class="card-body">
<form method="post" class="row g-3">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($CSRF, ENT_QUOTES, 'UTF-8') ?>">
<div class="col-12 col-md-3">
<label class="form-label"><?= htmlspecialchars((string)($translations['purge_mess_min_age_label'] ?? 'Âge minimum (jours)'), ENT_QUOTES, 'UTF-8') ?></label>
<input type="number" class="form-control" name="min_age" min="0" value="<?= (int)$minAgeDays ?>">
</div>
<div class="col-12 col-md-3">
<label class="form-label"><?= htmlspecialchars((string)($translations['purge_mess_fix_db_label'] ?? 'Fixer les refs DB manquantes'), ENT_QUOTES, 'UTF-8') ?></label>
<select class="form-select" name="fix_db">
<option value="1" <?= $fixDbRefs ? 'selected':'' ?>><?= htmlspecialchars((string)($translations['yes'] ?? 'Oui'), ENT_QUOTES, 'UTF-8') ?></option>
<option value="0" <?= !$fixDbRefs ? 'selected':'' ?>><?= htmlspecialchars((string)($translations['no'] ?? 'Non'), ENT_QUOTES, 'UTF-8') ?></option>
</select>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" name="action" value="scan" class="btn btn-outline-secondary">
<?= htmlspecialchars((string)($translations['btn_scan'] ?? 'Scanner'), ENT_QUOTES, 'UTF-8') ?>
</button>
<button type="submit" name="action" value="purge" class="btn btn-danger"
onclick="return confirm('<?= htmlspecialchars((string)($translations['confirm_purge_orphan_files'] ?? 'Confirmer la purge réelle des fichiers orphelins ?'), ENT_QUOTES, 'UTF-8') ?>');">
<?= htmlspecialchars((string)($translations['btn_purge'] ?? 'Purger'), ENT_QUOTES, 'UTF-8') ?>
</button>
</div>
</form>
</div>
</div>
<?php if (empty($errors)): ?>
<div class="row g-3">
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header"><?= htmlspecialchars((string)($translations['purge_mess_summary'] ?? 'Résumé'), ENT_QUOTES, 'UTF-8') ?></div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<?= htmlspecialchars((string)($translations['purge_mess_uploads_dir'] ?? 'Dossier uploads'), ENT_QUOTES, 'UTF-8') ?>
<code class="ms-2"><?= htmlspecialchars($uploadsDir, ENT_QUOTES, 'UTF-8') ?></code>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= htmlspecialchars((string)($translations['purge_mess_files_scanned'] ?? 'Fichiers scannés'), ENT_QUOTES, 'UTF-8') ?>
<span class="badge text-bg-secondary ms-2"><?= (int)$stats['seen'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?php
$tpl = (string)($translations['purge_mess_orphans_line'] ?? 'Orphelins {STATE}');
$state = $dry ? ($translations['purge_mess_orphans_detected'] ?? 'détectés') : ($translations['purge_mess_orphans_deleted'] ?? 'supprimés');
echo htmlspecialchars(str_replace('{STATE}', (string)$state, $tpl), ENT_QUOTES, 'UTF-8');
?>
<span class="badge text-bg-<?= $dry ? 'warning' : 'danger' ?> ms-2"><?= (int)$stats['deleted'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= htmlspecialchars((string)($translations['purge_mess_orphans_too_recent'] ?? 'Orphelins trop récents (ignorés)'), ENT_QUOTES, 'UTF-8') ?>
<span class="badge text-bg-info ms-2"><?= (int)$stats['skipped'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= htmlspecialchars((string)($translations['purge_mess_db_rows_fixed'] ?? 'Lignes DB normalisées'), ENT_QUOTES, 'UTF-8') ?>
<span class="badge text-bg-primary ms-2"><?= (int)$stats['fixed'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= htmlspecialchars((string)($translations['purge_mess_duration'] ?? 'Durée'), ENT_QUOTES, 'UTF-8') ?>
<span class="badge text-bg-secondary ms-2"><?= number_format($dur, 3) ?> s</span>
</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header">
<?php
$tpl = (string)($translations['purge_mess_details_header'] ?? 'Détails {MODE}');
$mode = $dry ? ($translations['purge_mess_details_sim'] ?? '(simulation)') : ($translations['purge_mess_details_live'] ?? '(LIVE)');
echo htmlspecialchars(str_replace('{MODE}', (string)$mode, $tpl), ENT_QUOTES, 'UTF-8');
?>
</div>
<div class="card-body p-0">
<?php if (empty($deletedList)): ?>
<div class="p-3 text-muted"><?= htmlspecialchars((string)($translations['purge_mess_no_orphans_found'] ?? 'Aucun fichier orphelin ancien trouvé.'), ENT_QUOTES, 'UTF-8') ?></div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th><?= htmlspecialchars((string)($translations['th_file'] ?? 'Fichier'), ENT_QUOTES, 'UTF-8') ?></th>
<th><?= htmlspecialchars((string)($translations['th_age_days'] ?? 'Âge (j)'), ENT_QUOTES, 'UTF-8') ?></th>
<th><?= htmlspecialchars((string)($translations['th_action'] ?? 'Action'), ENT_QUOTES, 'UTF-8') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($deletedList as [$webRel, $age, $act]): ?>
<tr>
<td><code><?= htmlspecialchars($webRel, ENT_QUOTES, 'UTF-8') ?></code></td>
<td><?= number_format($age, 1) ?></td>
<td>
<span class="badge text-bg-<?= $act === 'supprimé' ? 'danger' : 'secondary' ?>">
<?= htmlspecialchars((string)($translations[$act === 'supprimé' ? 'status_deleted' : 'status_simulation'] ?? $act), ENT_QUOTES, 'UTF-8') ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><?= htmlspecialchars((string)($translations['purge_mess_log_title'] ?? 'Journal'), ENT_QUOTES, 'UTF-8') ?></div>
<div class="card-body">
<pre class="small mb-0" style="white-space:pre-wrap;"><?= htmlspecialchars(implode("\n", $logs), ENT_QUOTES, 'UTF-8') ?></pre>
</div>
</div>
<?php endif; ?>
</div>
</div>