355 lines
16 KiB
PHP
355 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* /admin/tools/purge_conversations.php
|
|
* Purge des conversations où deleted_for_praticien = 1 ET deleted_for_client = 1.
|
|
* - Affichage Bootstrap
|
|
* - DRY-RUN par défaut ; purge LIVE via POST + CSRF
|
|
* - AUCUNE fonction globale déclarée (helpers en closures) -> pas de collision avec index.php
|
|
*/
|
|
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
|
if (($_GET['page'] ?? '') !== 'purge_conversations') { return; }
|
|
|
|
// Sécurité (l'admin/index.php a normalement déjà vérifié ça)
|
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
|
|
|
// Lang (pour $translations)
|
|
|
|
|
|
$roles = (array)($_SESSION['role'] ?? []);
|
|
if (!in_array('admin', $roles, 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; }
|
|
// PDO si absent
|
|
if (!isset($pdo)) {
|
|
$mb = realpath(__DIR__ . '/../../messages/_bootstrap.php');
|
|
if ($mb && is_file($mb)) { require_once $mb; }
|
|
}
|
|
|
|
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
|
echo '<div class="container my-4"><div class="alert alert-danger">'
|
|
. htmlspecialchars((string)($translations['admin_db_unavailable'] ?? 'Connexion DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
|
. '</div></div>';
|
|
return;
|
|
}
|
|
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (\Throwable $e) {}
|
|
|
|
// CSRF
|
|
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
|
|
$CSRF = $_SESSION['csrf_token'];
|
|
|
|
// -------- Helpers en closures (pas de fonctions globales !) ----------
|
|
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
|
$logs = [];
|
|
$log = static function(string $m) use (&$logs) { $logs[] = $m; };
|
|
$siteRoot = realpath(__DIR__ . '/../../');
|
|
$safeWithin = static function(string $path, string $base) {
|
|
$rp = realpath($path); $rb = realpath($base);
|
|
return $rp && $rb && strpos($rp, $rb) === 0;
|
|
};
|
|
$tableExists = static function(PDO $pdo, string $table): bool {
|
|
$sql = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?";
|
|
$st = $pdo->prepare($sql); $st->execute([$table]); return (bool)$st->fetchColumn();
|
|
};
|
|
$placeholders = static function(int $n): string { return implode(',', array_fill(0,$n,'?')); };
|
|
$chunked = static function(array $ids, int $size=500): array {
|
|
$out=[]; $chunk=[]; foreach ($ids as $id) { $chunk[]=(int)$id; if (count($chunk)>=$size){$out[]=$chunk;$chunk=[];} }
|
|
if ($chunk) $out[]=$chunk; return $out;
|
|
};
|
|
// -------------------------------------------------------------------
|
|
|
|
$retentionDays = isset($_REQUEST['days']) ? max(0, (int)$_REQUEST['days']) : 0; // ex 30
|
|
$doLive = false;
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'purge') {
|
|
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
|
echo '<div class="container my-4"><div class="alert alert-danger">'
|
|
. $e($translations['csrf_invalid'] ?? 'Token CSRF invalide.')
|
|
. '</div></div>';
|
|
return;
|
|
}
|
|
$doLive = true;
|
|
}
|
|
$dry = !$doLive;
|
|
|
|
$errors = [];
|
|
$stats = ['conversations'=>0,'messages'=>0,'attachments'=>0,'counters'=>0,'read_markers'=>0,'duration'=>0.0];
|
|
$candidates = []; // [id, updated_at]
|
|
$attachList = []; // max 50
|
|
|
|
$start = microtime(true);
|
|
|
|
try {
|
|
// Tables
|
|
foreach (['conversations','messages'] as $t) {
|
|
if (!$tableExists($pdo, $t)) {
|
|
$tpl = (string)($translations['purge_table_missing'] ?? "Table <code>{TABLE}</code> introuvable.");
|
|
$errors[] = str_replace('{TABLE}', $e($t), $tpl);
|
|
}
|
|
}
|
|
$hasCounters = $tableExists($pdo, 'conversation_counters');
|
|
$hasReadMk = $tableExists($pdo, 'read_markers');
|
|
|
|
if (empty($errors)) {
|
|
// 1) Candidats
|
|
$lastSql = "SELECT id, updated_at FROM conversations WHERE deleted_for_praticien=1 AND deleted_for_client=1";
|
|
if ($retentionDays > 0) { $lastSql .= " AND updated_at < (NOW() - INTERVAL {$retentionDays} DAY)"; }
|
|
$rows = $pdo->query($lastSql)->fetchAll(PDO::FETCH_ASSOC);
|
|
foreach ($rows as $r) { $candidates[] = [(int)$r['id'], (string)$r['updated_at']]; }
|
|
$stats['conversations'] = count($candidates);
|
|
|
|
if ($stats['conversations'] > 0) {
|
|
$ids = array_column($candidates, 0);
|
|
|
|
// 2) Stats pré-purge
|
|
foreach ($chunked($ids, 500) as $batch) {
|
|
$ph = $placeholders(count($batch));
|
|
|
|
// messages
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM messages WHERE conversation_id IN ($ph)");
|
|
$st->execute($batch);
|
|
$stats['messages'] += (int)$st->fetchColumn();
|
|
|
|
// attachments (sample)
|
|
$st = $pdo->prepare("SELECT attachment_path FROM messages WHERE conversation_id IN ($ph)
|
|
AND attachment_path IS NOT NULL AND attachment_path<>''");
|
|
$st->execute($batch);
|
|
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
|
$p = (string)$row['attachment_path'];
|
|
if ($p !== '') {
|
|
$stats['attachments']++;
|
|
if (count($attachList) < 50) { $attachList[] = $p; }
|
|
}
|
|
}
|
|
|
|
if ($hasCounters) {
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM conversation_counters WHERE conversation_id IN ($ph)");
|
|
$st->execute($batch);
|
|
$stats['counters'] += (int)$st->fetchColumn();
|
|
}
|
|
if ($hasReadMk) {
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM read_markers WHERE conversation_id IN ($ph)");
|
|
$st->execute($batch);
|
|
$stats['read_markers'] += (int)$st->fetchColumn();
|
|
}
|
|
}
|
|
|
|
// 3) Purge LIVE
|
|
if ($doLive) {
|
|
$pdo->beginTransaction();
|
|
try {
|
|
// supprimer fichiers de pièces jointes
|
|
foreach ($chunked($ids, 500) as $batch) {
|
|
$ph = $placeholders(count($batch));
|
|
$st = $pdo->prepare("SELECT attachment_path FROM messages WHERE conversation_id IN ($ph)
|
|
AND attachment_path IS NOT NULL AND attachment_path<>''");
|
|
$st->execute($batch);
|
|
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
|
$rel = (string)$row['attachment_path']; // ex: messages/uploads/xxx
|
|
$abs = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $rel);
|
|
if ($safeWithin($abs, $siteRoot) && is_file($abs)) {
|
|
if (@unlink($abs)) { $log("[OK] ".$e($translations['purge_log_file_deleted'] ?? 'Fichier supprimé').": {$rel}"); }
|
|
else { $log("[WARN] ".$e($translations['purge_log_file_delete_failed'] ?? 'Échec suppression fichier').": {$rel}"); }
|
|
} else {
|
|
$log("[INFO] ".$e($translations['purge_log_file_missing_or_outside'] ?? 'Fichier introuvable/hors périmètre').": {$rel}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// dépendances
|
|
foreach ($chunked($ids, 500) as $batch) {
|
|
$ph = $placeholders(count($batch));
|
|
$st = $pdo->prepare("DELETE FROM messages WHERE conversation_id IN ($ph)");
|
|
$st->execute($batch);
|
|
$log("[OK] ".$e($translations['purge_log_messages_deleted_batch'] ?? 'messages supprimés (batch)').": ".$st->rowCount());
|
|
|
|
if ($hasCounters) {
|
|
$st = $pdo->prepare("DELETE FROM conversation_counters WHERE conversation_id IN ($ph)");
|
|
$st->execute($batch);
|
|
$log("[OK] ".$e($translations['purge_log_counters_deleted_batch'] ?? 'conversation_counters supprimés (batch)').": ".$st->rowCount());
|
|
}
|
|
if ($hasReadMk) {
|
|
$st = $pdo->prepare("DELETE FROM read_markers WHERE conversation_id IN ($ph)");
|
|
$st->execute($batch);
|
|
$log("[OK] ".$e($translations['purge_log_read_markers_deleted_batch'] ?? 'read_markers supprimés (batch)').": ".$st->rowCount());
|
|
}
|
|
}
|
|
|
|
// parent
|
|
foreach ($chunked($ids, 500) as $batch) {
|
|
$ph = $placeholders(count($batch));
|
|
$st = $pdo->prepare("DELETE FROM conversations WHERE id IN ($ph)");
|
|
$st->execute($batch);
|
|
$log("[OK] ".$e($translations['purge_log_conversations_deleted_batch'] ?? 'conversations supprimées (batch)').": ".$st->rowCount());
|
|
}
|
|
|
|
$pdo->commit();
|
|
$log($translations['purge_done_ok'] ?? 'Purge effectuée avec succès.');
|
|
} catch (\Throwable $ex) {
|
|
$pdo->rollBack();
|
|
$tpl = (string)($translations['purge_error'] ?? 'Erreur purge: {ERR}');
|
|
$errors[] = str_replace('{ERR}', $e($ex->getMessage()), $tpl);
|
|
}
|
|
} else {
|
|
$log($translations['purge_dry_run_hint'] ?? 'DRY-RUN: aucune suppression; utilisez le bouton Purger en LIVE pour exécuter.');
|
|
}
|
|
} else {
|
|
$log($translations['purge_none'] ?? 'Aucune conversation à purger.');
|
|
}
|
|
}
|
|
} catch (\Throwable $ex) {
|
|
$tpl = (string)($translations['purge_exception'] ?? 'Exception: {ERR}');
|
|
$errors[] = str_replace('{ERR}', $e($ex->getMessage()), $tpl);
|
|
}
|
|
|
|
$stats['duration'] = microtime(true) - $start;
|
|
?>
|
|
<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"><?= $e($translations['purge_title'] ?? 'Purger les conversations supprimées des deux côtés') ?></h3>
|
|
<span class="badge text-bg-<?= $dry ? 'secondary' : 'danger' ?>">
|
|
<?= $dry ? $e($translations['purge_mode_dry'] ?? 'DRY-RUN (simulation)') : $e($translations['purge_mode_live'] ?? 'LIVE (suppression activée)') ?>
|
|
</span>
|
|
<span class="badge text-bg-info">
|
|
<?php
|
|
$tpl = (string)($translations['purge_retention_badge'] ?? 'Rétention: {N} jour(s)');
|
|
echo $e(str_replace('{N}', (string)((int)$retentionDays), $tpl));
|
|
?>
|
|
</span>
|
|
</div>
|
|
|
|
<?php if (!empty($errors)): ?>
|
|
<div class="alert alert-danger">
|
|
<strong><?= $e($translations['errors_title'] ?? 'Erreurs') ?></strong>
|
|
<ul class="mb-0">
|
|
<?php foreach ($errors as $er): ?><li><?= $er ?></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="<?= $e($CSRF) ?>">
|
|
<div class="col-12 col-md-3">
|
|
<label class="form-label"><?= $e($translations['purge_retention_label'] ?? 'Rétention (jours) avant purge') ?></label>
|
|
<input type="number" class="form-control" name="days" min="0" value="<?= (int)$retentionDays ?>">
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button type="submit" name="action" value="scan" class="btn btn-outline-secondary">
|
|
<?= $e($translations['btn_scan'] ?? 'Scanner') ?>
|
|
</button>
|
|
<button type="submit" name="action" value="purge" class="btn btn-danger"
|
|
onclick="return confirm('<?= $e($translations['confirm_purge_definitive'] ?? 'Confirmer la purge définitive ?') ?>');">
|
|
<?= $e($translations['btn_purge'] ?? 'Purger') ?>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-lg-5">
|
|
<div class="card h-100">
|
|
<div class="card-header"><?= $e($translations['purge_summary'] ?? 'Résumé') ?></div>
|
|
<div class="card-body">
|
|
<ul class="list-group list-group-flush">
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<?= $e($translations['purge_targeted_conversations'] ?? 'Conversations ciblées') ?>
|
|
<span class="badge text-bg-secondary"><?= (int)$stats['conversations'] ?></span>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<?= $e($translations['purge_impacted_messages'] ?? 'Messages impactés') ?>
|
|
<span class="badge text-bg-secondary"><?= (int)$stats['messages'] ?></span>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<?= $e($translations['purge_attachments_files'] ?? 'Pièces jointes (fichiers)') ?>
|
|
<span class="badge text-bg-<?= $stats['attachments'] ? 'warning':'secondary' ?>">
|
|
<?= (int)$stats['attachments'] ?>
|
|
</span>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<?= $e($translations['purge_counters_to_delete'] ?? 'Counters à supprimer') ?>
|
|
<span class="badge text-bg-secondary"><?= (int)$stats['counters'] ?></span>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<?= $e($translations['purge_read_markers_to_delete'] ?? 'Read markers à supprimer') ?>
|
|
<span class="badge text-bg-secondary"><?= (int)$stats['read_markers'] ?></span>
|
|
</li>
|
|
<li class="list-group-item d-flex justify-content-between">
|
|
<?= $e($translations['purge_duration'] ?? 'Durée') ?>
|
|
<span class="badge text-bg-secondary"><?= number_format($stats['duration'], 3) ?> s</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-lg-7">
|
|
<div class="card h-100">
|
|
<div class="card-header"><?= $e($translations['purge_candidates_preview'] ?? 'Conversations candidates (aperçu)') ?></div>
|
|
<div class="card-body p-0">
|
|
<?php if (empty($candidates)): ?>
|
|
<div class="p-3 text-muted"><?= $e($translations['purge_none'] ?? 'Aucune conversation à purger.') ?></div>
|
|
<?php else: ?>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th><?= $e($translations['th_id'] ?? 'ID') ?></th>
|
|
<th><?= $e($translations['th_last_update'] ?? 'Dernière MAJ') ?></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach (array_slice($candidates, 0, 100) as [$cid, $upd]): ?>
|
|
<tr>
|
|
<td><?= (int)$cid ?></td>
|
|
<td><time><?= $e($upd) ?></time></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<?php if (count($candidates) > 100): ?>
|
|
<div class="p-2 small text-muted">
|
|
<?php
|
|
$tpl = (string)($translations['purge_more_count'] ?? '(+ {N} de plus...)');
|
|
echo $e(str_replace('{N}', (string)(count($candidates)-100), $tpl));
|
|
?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt-3">
|
|
<div class="card-header"><?= $e($translations['purge_attachments_examples'] ?? 'Exemples de pièces jointes (max 50)') ?></div>
|
|
<div class="card-body">
|
|
<?php if (empty($attachList)): ?>
|
|
<div class="text-muted"><?= $e($translations['purge_no_attachments_found'] ?? 'Aucune pièce jointe trouvée pour ces conversations.') ?></div>
|
|
<?php else: ?>
|
|
<ul class="mb-0">
|
|
<?php foreach ($attachList as $p): ?>
|
|
<li><code><?= $e($p) ?></code></li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt-3">
|
|
<div class="card-header"><?= $e($translations['purge_log_title'] ?? 'Journal') ?></div>
|
|
<div class="card-body">
|
|
<pre class="small mb-0" style="white-space:pre-wrap;"><?= $e(implode("\n", $logs)) ?></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|