Init Git Prod Mars 2026

This commit is contained in:
Oleg Barykin committed 2026-03-05 10:25:39 +01:00
commit d81efe93b1
362 files changed
+86627

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
<?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>