Init Git Prod Mars 2026
This commit is contained in:
commit
d81efe93b1
362 files changed
+86627
No files matched your search
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// /admin/tools/_lib_audit.php
|
||||
if (!class_exists('AdminAudit')) {
|
||||
final class AdminAudit {
|
||||
public static function log(PDO $pdo, array $d): void {
|
||||
// $d: admin_id, action, scope?, target_table?, target_id?, meta?(array), ip?, ua?
|
||||
$sql = "INSERT INTO admin_audit(admin_id, action, scope, target_table, target_id, meta_json, ip, user_agent)
|
||||
VALUES (:admin_id,:action,:scope,:tt,:tid,:meta,:ip,:ua)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':admin_id' => (int)($d['admin_id'] ?? 0),
|
||||
':action' => (string)($d['action'] ?? 'unknown'),
|
||||
':scope' => $d['scope'] ?? null,
|
||||
':tt' => $d['target_table'] ?? null,
|
||||
':tid' => isset($d['target_id']) ? (int)$d['target_id'] : null,
|
||||
':meta' => isset($d['meta']) ? json_encode($d['meta'], JSON_UNESCAPED_UNICODE) : null,
|
||||
':ip' => isset($_SERVER['REMOTE_ADDR']) ? inet_pton($_SERVER['REMOTE_ADDR']) : null,
|
||||
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
/** УКАЖИ ID администратора-практиканта здесь */
|
||||
const ADMIN_PRACTICIEN_ID = 88888888; // <-- заменить на реальный ID
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$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)) { require_once '../../includes/db.php'; }
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// helpers
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
$flash = ['type'=>null,'msg'=>null];
|
||||
|
||||
// ---- Обработка POST ----
|
||||
$action = $_POST['do'] ?? null;
|
||||
|
||||
try {
|
||||
if ($action === 'update_main') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['id'] ?? 0);
|
||||
$url = trim($_POST['video'] ?? '');
|
||||
$stmt = $pdo->prepare("UPDATE praticien SET video = :video WHERE id = :id");
|
||||
$stmt->execute(['video' => ($url !== '' ? $url : null), 'id' => $pid]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_main_saved'] ?? 'Основное видео сохранено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
|
||||
if ($action === 'add_extra') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['id'] ?? 0);
|
||||
$url = trim($_POST['url'] ?? '');
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$ordre = (int)($_POST['ordre'] ?? 0);
|
||||
if ($url === '') throw new Exception($translations['admin_video_err_url_required'] ?? 'Укажите URL');
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO praticien_video (praticien_id, url, title, ordre) VALUES (?,?,?,?)");
|
||||
$stmt->execute([$pid, $url, ($title !== '' ? $title : null), $ordre]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_added'] ?? 'Видео добавлено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
|
||||
if ($action === 'update_extra') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['pid'] ?? 0);
|
||||
$vid = (int)($_POST['vid'] ?? 0);
|
||||
$url = trim($_POST['url'] ?? '');
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$ordre = (int)($_POST['ordre'] ?? 0);
|
||||
if ($vid <= 0 || $url === '') throw new Exception($translations['admin_video_err_check_fields'] ?? 'Проверьте поля');
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE praticien_video SET url=?, title=?, ordre=? WHERE id=?");
|
||||
$stmt->execute([$url, ($title !== '' ? $title : null), $ordre, $vid]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_updated'] ?? 'Видео обновлено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
|
||||
if ($action === 'delete_extra') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['pid'] ?? 0);
|
||||
$vid = (int)($_POST['vid'] ?? 0);
|
||||
if ($vid <= 0) throw new Exception($translations['admin_video_err_unknown_id'] ?? 'Неизвестный ID');
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM praticien_video WHERE id=?");
|
||||
$stmt->execute([$vid]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_deleted'] ?? 'Видео удалено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$flash = ['type'=>'danger','msg'=>($translations['err_prefixe'] ?? 'Ошибка: ') . $ex->getMessage()];
|
||||
}
|
||||
|
||||
// ---- РЕЖИМ A: список всех практиков ----
|
||||
if (empty($_GET['id'])) {
|
||||
$rows = $pdo->query("
|
||||
SELECT id, titre, nom, prenom, specialite, photo, video
|
||||
FROM praticien
|
||||
ORDER BY nom ASC, prenom ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3 text-center"><?= e($translations['admin_video_title'] ?? '🎬 Видео практиков — Администрирование') ?></h2>
|
||||
|
||||
<?php if ($flash['msg']): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="filter"
|
||||
placeholder="<?= e($translations['admin_video_filter_placeholder'] ?? 'Фильтр (имя, специальность)…') ?>">
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle mobile-cards" id="tab">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:70px;"><?= e($translations['admin_video_th_photo'] ?? 'Фото') ?></th>
|
||||
<th><?= e($translations['admin_video_th_name'] ?? 'Имя') ?></th>
|
||||
<th><?= e($translations['admin_video_th_specialite'] ?? 'Специальность') ?></th>
|
||||
<th><?= e($translations['admin_video_th_main_video'] ?? 'Основное видео') ?></th>
|
||||
<th class="text-center" style="width:150px;"><?= e($translations['admin_video_th_actions'] ?? 'Действия') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r):
|
||||
$name = trim($r['prenom'].' '.$r['nom']);
|
||||
$photo = !empty($r['photo']) ? '/public/uploads/'.e($r['photo']) : '/public/uploads/img/user_placeholder.png';
|
||||
?>
|
||||
<tr data-hay="<?= e(mb_strtolower($name.' '.$r['specialite'])) ?>">
|
||||
<td><img src="<?= $photo ?>" alt="" class="rounded" style="width:48px;height:48px;object-fit:cover;"></td>
|
||||
<td><?= e($name) ?></td>
|
||||
<td><?= e($r['specialite'] ?? '') ?></td>
|
||||
<td>
|
||||
<?php if (!empty($r['video'])): ?>
|
||||
<span class="badge bg-success"><?= e($translations['admin_video_badge_set'] ?? 'указано') ?></span>
|
||||
<a class="ms-2" href="<?= e($r['video']) ?>" target="_blank" rel="noopener">
|
||||
<?= e($translations['admin_video_open'] ?? 'открыть') ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary"><?= e($translations['admin_video_badge_not_set'] ?? 'не указано') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a class="btn btn-sm btn-danger" href="index.php?page=add_video&id=<?= (int)$r['id'] ?>">
|
||||
<?= e($translations['admin_video_manage'] ?? 'Управлять') ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Простой фильтр по таблице (на клиенте)
|
||||
document.getElementById('filter').addEventListener('input', function(){
|
||||
const q = (this.value || '').trim().toLowerCase();
|
||||
document.querySelectorAll('#tab tbody tr').forEach(tr=>{
|
||||
const hay = (tr.dataset.hay || '').toLowerCase();
|
||||
tr.style.display = !q || hay.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- РЕЖИМ B: управление конкретным практиком ----
|
||||
$pid = (int)$_GET['id'];
|
||||
|
||||
$st = $pdo->prepare("SELECT id, titre, nom, prenom, specialite, photo, video FROM praticien WHERE id=?");
|
||||
$st->execute([$pid]);
|
||||
$praticien = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$praticien) {
|
||||
echo '<div class="container py-5"><div class="alert alert-warning">'
|
||||
. e($translations['admin_video_not_found'] ?? 'Практикант не найден.')
|
||||
. '</div><a href="add_video.php" class="btn btn-secondary mt-2">⬅️ '
|
||||
. e($translations['admin_video_back'] ?? 'Назад')
|
||||
. '</a></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$extras = $pdo->prepare("SELECT id, url, title, ordre, created_at FROM praticien_video WHERE praticien_id=? ORDER BY ordre ASC, id DESC");
|
||||
$extras->execute([$pid]);
|
||||
$videos = $extras->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$name = trim($praticien['prenom'].' '.$praticien['nom']);
|
||||
$photo = !empty($praticien['photo']) ? '/public/uploads/'.e($praticien['photo']) : '/public/uploads/img/user_placeholder.png';
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
|
||||
<a href="index.php?page=add_video" class="btn btn-outline-secondary mb-3" style="border-radius:999px;">
|
||||
⬅️ <?= e($translations['admin_video_back_to_list'] ?? 'Назад к списку') ?>
|
||||
</a>
|
||||
|
||||
<?php if ($flash['msg']): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body d-flex gap-3 align-items-center">
|
||||
<img src="<?= $photo ?>" class="rounded" style="width:64px;height:64px;object-fit:cover;" alt="">
|
||||
<div>
|
||||
<h5 class="mb-1"><?= e($name) ?></h5>
|
||||
<div class="text-muted"><?= e($praticien['specialite'] ?? '') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основное видео (praticien.video) -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light"><strong><?= e($translations['admin_video_main_block_title'] ?? 'Основное видео') ?></strong></div>
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= e($translations['admin_video_main_url_label'] ?? 'URL видео (YouTube, Vimeo, Dailymotion, MP4…)') ?></label>
|
||||
<input type="url" name="video" class="form-control"
|
||||
placeholder="<?= e($translations['admin_video_url_placeholder'] ?? 'https://…') ?>"
|
||||
value="<?= e($praticien['video'] ?? '') ?>">
|
||||
<div class="form-text"><?= e($translations['admin_video_main_help'] ?? 'Поле сохраняется в praticien.video и используется на публичных страницах.') ?></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$pid ?>">
|
||||
<input type="hidden" name="do" value="update_main">
|
||||
<button class="btn btn-danger" style="border-radius:999px;"><?= e($translations['enregistrer'] ?? 'Сохранить') ?></button>
|
||||
<?php if (!empty($praticien['video'])): ?>
|
||||
<a class="btn btn-outline-secondary ms-2" target="_blank" rel="noopener" href="<?= e($praticien['video']) ?>">
|
||||
<?= e($translations['admin_video_open_video'] ?? 'Открыть видео') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Дополнительные видео (praticien_video) -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><strong><?= e($translations['admin_video_extra_block_title'] ?? 'Дополнительные видео (необязательно)') ?></strong></div>
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= e($translations['admin_video_title_label_optional'] ?? 'Название (необязательно)') ?></label>
|
||||
<input type="text" name="title" class="form-control"
|
||||
placeholder="<?= e($translations['admin_video_title_placeholder'] ?? 'Презентация кабинета') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?= e($translations['admin_video_order_label'] ?? 'Порядок') ?></label>
|
||||
<input type="number" name="ordre" class="form-control" value="0" min="0" step="1">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= e($translations['admin_video_url_label'] ?? 'URL видео') ?></label>
|
||||
<input type="url" name="url" class="form-control"
|
||||
placeholder="<?= e($translations['admin_video_url_placeholder'] ?? 'https://…') ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$pid ?>">
|
||||
<input type="hidden" name="do" value="add_extra">
|
||||
<button class="btn btn-success" style="border-radius:999px;"><?= e($translations['admin_video_add'] ?? 'Добавить') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($videos)): ?>
|
||||
<div class="text-muted"><?= e($translations['admin_video_no_extras'] ?? 'Пока нет дополнительных видео.') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:80px;"><?= e($translations['admin_video_th_order'] ?? 'Порядок') ?></th>
|
||||
<th><?= e($translations['admin_video_th_title'] ?? 'Название') ?></th>
|
||||
<th><?= e($translations['admin_video_th_url'] ?? 'URL') ?></th>
|
||||
<th style="width:180px;"><?= e($translations['admin_video_th_actions'] ?? 'Действия') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($videos as $v): ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<td style="width:80px;">
|
||||
<input type="number" name="ordre" class="form-control" value="<?= (int)$v['ordre'] ?>" min="0" step="1">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="title" class="form-control" value="<?= e($v['title']) ?>"
|
||||
placeholder="<?= e($translations['admin_video_title_optional_placeholder'] ?? 'Название (необязательно)') ?>">
|
||||
</td>
|
||||
<td>
|
||||
<input type="url" name="url" class="form-control" value="<?= e($v['url']) ?>">
|
||||
</td>
|
||||
<td class="d-flex gap-2">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="pid" value="<?= (int)$pid ?>">
|
||||
<input type="hidden" name="vid" value="<?= (int)$v['id'] ?>">
|
||||
<button name="do" value="update_extra" class="btn btn-outline-primary btn-sm">
|
||||
<?= e($translations['enregistrer'] ?? 'Сохранить') ?>
|
||||
</button>
|
||||
<button name="do" value="delete_extra" class="btn btn-outline-danger btn-sm"
|
||||
onclick="return confirm('<?= e($translations['admin_video_confirm_delete'] ?? 'Удалить это видео?') ?>')">
|
||||
<?= e($translations['supprimer'] ?? 'Удалить') ?>
|
||||
</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// Повторная аутентификация админа (10 минут)
|
||||
$ttl = 600;
|
||||
if (empty($_SESSION['admin_reauth_at']) || (time() - (int)$_SESSION['admin_reauth_at']) > $ttl) {
|
||||
$redir = $_SERVER['REQUEST_URI'] ?? 'index.php?page=afficher_messages';
|
||||
header('Location: ?page=reauth&redir='.rawurlencode($redir));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {}
|
||||
|
||||
$e = static fn($s)=>htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
// CSRF helper
|
||||
if (!function_exists('require_post_csrf')) {
|
||||
function require_post_csrf() {
|
||||
global $translations;
|
||||
if ($_SERVER['REQUEST_METHOD']!=='POST') { http_response_code(405); exit($translations['contactad_method_not_allowed'] ?? 'Méthode non autorisée'); }
|
||||
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'])) {
|
||||
http_response_code(419); exit($translations['contactad_csrf_invalid'] ?? 'CSRF invalide');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отправка письма через PHPMailer (HTML + текст)
|
||||
function admin_send_reply(string $to, string $subject, string $html, string $text): array {
|
||||
/** @var array $MAILCFG */
|
||||
global $MAILCFG;
|
||||
$m = new PHPMailer(true);
|
||||
try {
|
||||
$m->isSMTP();
|
||||
$m->Host = $MAILCFG['smtp']['host'];
|
||||
$m->Port = $MAILCFG['smtp']['port'];
|
||||
$m->SMTPSecure = $MAILCFG['smtp']['secure'];
|
||||
$m->SMTPAuth = $MAILCFG['smtp']['auth'];
|
||||
$m->Username = $MAILCFG['smtp']['username'];
|
||||
$m->Password = $MAILCFG['smtp']['password'];
|
||||
$m->CharSet = 'UTF-8';
|
||||
$m->Encoding = 'base64';
|
||||
$m->XMailer = '';
|
||||
|
||||
if (!empty($MAILCFG['bounce'])) {
|
||||
// Envelope From / return-path (для SPF/DMARC)
|
||||
$m->Sender = $MAILCFG['bounce'];
|
||||
}
|
||||
$from = $MAILCFG['from'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
|
||||
$rpto = $MAILCFG['reply_to'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
|
||||
$m->setFrom($from[0], $from[1] ?? '');
|
||||
if (!empty($rpto[0])) {
|
||||
$m->addReplyTo($rpto[0], $rpto[1] ?? '');
|
||||
}
|
||||
|
||||
$m->addAddress($to);
|
||||
|
||||
// Анти-автоответы (по желанию)
|
||||
if (!empty($MAILCFG['suppress_auto'])) {
|
||||
$m->addCustomHeader('Auto-Submitted', 'auto-generated');
|
||||
$m->addCustomHeader('X-Auto-Response-Suppress', 'All');
|
||||
}
|
||||
|
||||
$m->isHTML(true);
|
||||
$m->Subject = $subject;
|
||||
$m->Body = $html;
|
||||
$m->AltBody = $text;
|
||||
|
||||
$m->send();
|
||||
return [true, null];
|
||||
} catch (Throwable $e) {
|
||||
error_log('PHPMailer admin reply error: '.$e->getMessage().($m->ErrorInfo ? ' | '.$m->ErrorInfo : ''));
|
||||
return [false, $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Действия POST: ответ / удаление
|
||||
$err = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_post_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'reply') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$txt = trim((string)($_POST['reply'] ?? ''));
|
||||
if ($id <= 0 || $txt === '') {
|
||||
$err = $translations['contactad_err_invalid_data'] ?? 'Données invalides';
|
||||
} else {
|
||||
$st = $pdo->prepare("SELECT * FROM contact_messages WHERE id=? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$m = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$m) {
|
||||
$err = $translations['contactad_err_not_found'] ?? 'Message introuvable';
|
||||
} else {
|
||||
$to = (string)$m['email'];
|
||||
$subj = $translations['contactad_mail_subject_reply'] ?? "Réponse de l'administrateur";
|
||||
$greet= $translations['contactad_mail_greeting'] ?? "Bonjour";
|
||||
$orig = $translations['contactad_mail_original_label'] ?? "Votre message initial :";
|
||||
|
||||
// HTML версия
|
||||
$origHtml = nl2br($e($m['message']));
|
||||
$html = '
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#111;">
|
||||
<p>'.$e($greet).' '.$e($m['prenom'].' '.$m['nom']).',</p>
|
||||
<p>'.$e($txt).'</p>
|
||||
<hr>
|
||||
<p><strong>'.$e($orig).'</strong></p>
|
||||
<blockquote style="margin:0;padding:10px 12px;border-left:4px solid #ddd;background:#f7f7f7;">'.$origHtml.'</blockquote>
|
||||
</div>';
|
||||
|
||||
// Текстовая версия
|
||||
$text = $greet.' '.($m['prenom'].' '.$m['nom']).","."\n\n".
|
||||
$txt."\n\n".
|
||||
"-----------------------\n".$orig."\n".$m['message']."\n";
|
||||
|
||||
// Отправка
|
||||
[$ok,$errSend] = admin_send_reply($to, $subj, $html, $text);
|
||||
|
||||
// Записываем статус "repondu" в любом случае (как раньше)
|
||||
$stU = $pdo->prepare("UPDATE contact_messages
|
||||
SET statut='repondu', reponse=?, replied_by=?, replied_at=NOW()
|
||||
WHERE id=?");
|
||||
$stU->execute([$txt, (int)($_SESSION['compte_id'] ?? 0), $id]);
|
||||
|
||||
if (function_exists('log_admin_action')) {
|
||||
log_admin_action(
|
||||
$pdo,
|
||||
(int)($_SESSION['compte_id'] ?? 0),
|
||||
'contact_reply',
|
||||
json_encode(['id'=>$id,'email'=>$to,'sent'=>(bool)$ok,'error'=>$errSend], JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
// Возврат к списку (без id)
|
||||
header('Location: ?page=afficher_messages&flash=1');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$del = $pdo->prepare("DELETE FROM contact_messages WHERE id=?");
|
||||
$del->execute([$id]);
|
||||
if (function_exists('log_admin_action')) {
|
||||
log_admin_action($pdo, (int)($_SESSION['compte_id'] ?? 0), 'contact_delete',
|
||||
json_encode(['id'=>$id,'deleted'=>$del->rowCount()>0], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
header('Location: ?page=afficher_messages&deleted=1'); return;
|
||||
} else {
|
||||
$err = $translations['contactad_err_invalid_id'] ?? 'ID invalide';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Открытое сообщение?
|
||||
$msg = null;
|
||||
if (isset($_GET['id'])) {
|
||||
$id = (int)$_GET['id'];
|
||||
$st = $pdo->prepare("SELECT * FROM contact_messages WHERE id=? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$msg = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($msg && $msg['statut'] === 'nouveau') {
|
||||
$pdo->prepare("UPDATE contact_messages SET statut='lu' WHERE id=?")->execute([$id]);
|
||||
$msg['statut'] = 'lu';
|
||||
}
|
||||
}
|
||||
|
||||
// Список (пагинация)
|
||||
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||
$per = 25; $off = ($page - 1) * $per;
|
||||
$total = (int)$pdo->query("SELECT COUNT(*) FROM contact_messages")->fetchColumn();
|
||||
$rows = $pdo->query("SELECT id, nom, prenom, email, sujet, statut, created_at
|
||||
FROM contact_messages
|
||||
ORDER BY id DESC
|
||||
LIMIT $per OFFSET $off")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$pages = max(1, (int)ceil($total / $per));
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['contactad_title'] ?? 'Messages utilisateurs') ?></h3>
|
||||
<span class="badge text-bg-secondary"><?= (int)$total ?></span>
|
||||
<!-- Переключатель языков удалён -->
|
||||
</div>
|
||||
|
||||
<?php if(isset($_GET['flash'])): ?>
|
||||
<div class="alert alert-success"><?= $e($translations['contactad_flash_sent_ok'] ?? 'Réponse envoyée') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if(isset($_GET['deleted'])): ?>
|
||||
<div class="alert alert-success"><?= $e($translations['contactad_flash_deleted_ok'] ?? 'Message supprimé.') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if($err): ?><div class="alert alert-danger"><?= $e($err) ?></div><?php endif; ?>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Список: на весь экран если деталка не открыта -->
|
||||
<div class="<?= $msg ? 'col-lg-6' : 'col-12' ?>">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['contactad_list_title'] ?? 'Messages') ?></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle mobile-cards">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $e($translations['contactad_th_id'] ?? '#') ?></th>
|
||||
<th><?= $e($translations['contactad_th_from'] ?? 'De') ?></th>
|
||||
<th><?= $e($translations['contactad_th_email'] ?? 'Email') ?></th>
|
||||
<th><?= $e($translations['contactad_th_subject'] ?? 'Sujet') ?></th>
|
||||
<th><?= $e($translations['contactad_th_date'] ?? 'Date') ?></th>
|
||||
<th class="text-end"><?= $e($translations['contactad_th_actions'] ?? 'Actions') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r): $st=$r['statut']; ?>
|
||||
<tr>
|
||||
<td><?= (int)$r['id'] ?></td>
|
||||
<td><?= $e($r['prenom'].' '.$r['nom']) ?></td>
|
||||
<td><a href="mailto:<?= $e($r['email']) ?>"><?= $e($r['email']) ?></a></td>
|
||||
<td>
|
||||
<?= $e($r['sujet']) ?>
|
||||
<span class="badge ms-2 text-bg-<?= $st==='nouveau'?'warning':($st==='repondu'?'success':'secondary') ?>">
|
||||
<?php
|
||||
$label = $st;
|
||||
if ($st === 'nouveau') $label = $translations['contactad_status_nouveau'] ?? 'Nouveau';
|
||||
elseif ($st === 'lu') $label = $translations['contactad_status_lu'] ?? 'Lu';
|
||||
elseif ($st === 'repondu') $label = $translations['contactad_status_repondu'] ?? 'Répondu';
|
||||
echo $e($label);
|
||||
?>
|
||||
</span>
|
||||
</td>
|
||||
<td><small><?= $e($r['created_at']) ?></small></td>
|
||||
<td class="text-end">
|
||||
<!-- Кнопки выровнены: d-flex + gap-2, форма без внешних отступов -->
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="?page=afficher_messages&id=<?= (int)$r['id'] ?>">
|
||||
<?= $e($translations['contactad_btn_open'] ?? 'Ouvrir') ?>
|
||||
</a>
|
||||
<form method="post" class="m-0" onsubmit="return confirm('<?= $e($translations['contactad_confirm_delete'] ?? 'Supprimer ce message ?') ?>')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $e($_SESSION['csrf_token'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger" name="action" value="delete">
|
||||
<?= $e($translations['contactad_btn_delete'] ?? 'Supprimer') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; if (!$rows): ?>
|
||||
<tr><td colspan="6" class="p-3 text-muted"><?= $e($translations['contactad_no_messages'] ?? 'Aucun message.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($pages > 1): ?>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<?php
|
||||
$tpl = $translations['contactad_pager_label'] ?? 'Page {P} / {N}';
|
||||
echo $e(str_replace(['{P}','{N}'], [(string)(int)$page, (string)(int)$pages], $tpl));
|
||||
?>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<?php for ($i=max(1,$page-2); $i<=min($pages,$page+2); $i++):
|
||||
$q=$_GET; $q['p']=$i; ?>
|
||||
<a class="btn btn-sm <?= $i===$page?'btn-dark':'btn-outline-dark' ?>" href="?<?= http_build_query($q) ?>"><?= $i ?></a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><?= $e($translations['contactad_detail_title'] ?? 'Détails') ?></span>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="?page=afficher_messages">
|
||||
<?= $e($translations['contactad_btn_close_detail'] ?? 'Fermer') ?>
|
||||
</a>
|
||||
<form method="post" class="m-0" onsubmit="return confirm('<?= $e($translations['contactad_confirm_delete'] ?? 'Supprimer ce message ?') ?>')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $e($_SESSION['csrf_token'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$msg['id'] ?>">
|
||||
<button class="btn btn-sm btn-danger" name="action" value="delete"><?= $e($translations['contactad_btn_delete'] ?? 'Supprimer') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<strong>#<?= (int)$msg['id'] ?></strong> • <?= $e($msg['created_at']) ?>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<strong><?= $e($msg['prenom'].' '.$msg['nom']) ?></strong> —
|
||||
<a href="mailto:<?= $e($msg['email']) ?>"><?= $e($msg['email']) ?></a>
|
||||
</div>
|
||||
<div class="mb-2"><em><?= $e($msg['sujet']) ?></em></div>
|
||||
<pre class="p-2 bg-light border rounded" style="white-space:pre-wrap"><?= $e($msg['message']) ?></pre>
|
||||
|
||||
<hr>
|
||||
<form method="post" class="d-grid gap-2">
|
||||
<input type="hidden" name="csrf_token" value="<?= $e($_SESSION['csrf_token'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$msg['id'] ?>">
|
||||
<textarea class="form-control" name="reply" rows="5"
|
||||
placeholder="<?= $e($translations['contactad_reply_placeholder'] ?? 'Répondre') ?>"
|
||||
required><?= $e($msg['reponse'] ?? '') ?></textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-dark" name="action" value="reply"><?= $e($translations['contactad_btn_send'] ?? 'Envoyer') ?></button>
|
||||
<a class="btn btn-outline-secondary" href="?page=afficher_messages"><?= $e($translations['contactad_btn_back'] ?? 'Retour') ?></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
// admin/tools/ajax_fiche_user.php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// ID "super admin"
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok'=>false, 'error'=>'Доступ запрещён.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$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; }
|
||||
|
||||
|
||||
function jerr(string $msg, int $code = 400): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok'=>false, 'error'=>$msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function h($s) {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// CSRF
|
||||
$csrf = $_POST['csrf'] ?? '';
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $csrf)) {
|
||||
jerr('Ошибка CSRF. Обновите страницу.', 400);
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jerr('Неверный идентификатор пользователя.', 400);
|
||||
}
|
||||
|
||||
// Загружаем запись из compte
|
||||
$st = $pdo->prepare("SELECT * FROM compte WHERE id = ?");
|
||||
$st->execute([$id]);
|
||||
$c = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$c) {
|
||||
jerr('Аккаунт не найден.', 404);
|
||||
}
|
||||
|
||||
$role = $c['role'] ?? '';
|
||||
|
||||
$client = null;
|
||||
$prat = null;
|
||||
$secBase = null; // если для секретаря будет отдельная таблица в будущем
|
||||
|
||||
if ($role === 'client') {
|
||||
$refId = (int)($c['reference_id'] ?? 0);
|
||||
if ($refId > 0) {
|
||||
$st2 = $pdo->prepare("SELECT * FROM client WHERE id = ?");
|
||||
$st2->execute([$refId]);
|
||||
$client = $st2->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
} elseif ($role === 'praticien' || $role === 'secretaire') {
|
||||
$refId = (int)($c['reference_id'] ?? 0);
|
||||
if ($refId > 0) {
|
||||
$st2 = $pdo->prepare("SELECT * FROM praticien WHERE id = ?");
|
||||
$st2->execute([$refId]);
|
||||
$prat = $st2->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
|
||||
// Строим HTML
|
||||
ob_start();
|
||||
?>
|
||||
<div class="text-start">
|
||||
<h5 class="mb-3">Основная информация аккаунта</h5>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr>
|
||||
<th style="width:180px;">ID аккаунта</th>
|
||||
<td><?= (int)$c['id'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>E-mail</th>
|
||||
<td><?= h($c['email']) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Роль</th>
|
||||
<td>
|
||||
<?php
|
||||
if ($role === 'client') echo 'Клиент';
|
||||
elseif ($role === 'praticien') echo 'Практик';
|
||||
elseif ($role === 'secretaire') echo 'Секретарь';
|
||||
else echo h($role);
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата создания</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($c['cree_le'])) {
|
||||
echo h((new DateTime($c['cree_le']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата изменения</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($c['modifie_le'])) {
|
||||
echo h((new DateTime($c['modifie_le']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус e-mail</th>
|
||||
<td>
|
||||
<?php if ((int)($c['email_valide'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Подтверждён</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-warning text-dark">Не подтверждён</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID в связанной таблице (reference_id)</th>
|
||||
<td><?= (int)($c['reference_id'] ?? 0) ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($role === 'client'): ?>
|
||||
<h5 class="mt-4 mb-3">Данные клиента</h5>
|
||||
<?php if (!$client): ?>
|
||||
<div class="alert alert-warning">Запись клиента не найдена.</div>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr><th style="width:180px;">ID клиента</th><td><?= (int)$client['id'] ?></td></tr>
|
||||
<tr><th>Фамилия</th><td><?= h($client['nom']) ?></td></tr>
|
||||
<tr><th>Имя</th><td><?= h($client['prenom']) ?></td></tr>
|
||||
<tr><th>E-mail</th><td><?= h($client['email']) ?></td></tr>
|
||||
<tr><th>Телефон</th><td><?= h($client['telephone']) ?></td></tr>
|
||||
<tr><th>Дата рождения</th><td><?= h($client['date_naissance']) ?></td></tr>
|
||||
<tr><th>Адрес</th><td><?= nl2br(h($client['adresse'])) ?></td></tr>
|
||||
<tr><th>Город</th><td><?= h($client['ville']) ?></td></tr>
|
||||
<tr><th>Индекс</th><td><?= h($client['code_postal']) ?></td></tr>
|
||||
<tr><th>Пол</th><td><?= h($client['genre']) ?></td></tr>
|
||||
<tr>
|
||||
<th>CGU (accepted_cgu)</th>
|
||||
<td>
|
||||
<?php if ((int)($client['accepted_cgu'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Принял(а)</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">Не принял(а) или не указано</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php elseif ($role === 'praticien' || $role === 'secretaire'): ?>
|
||||
<h5 class="mt-4 mb-3">
|
||||
<?= $role === 'praticien' ? 'Данные практика' : 'Данные, связанные с секретарём' ?>
|
||||
</h5>
|
||||
<?php if (!$prat): ?>
|
||||
<div class="alert alert-warning">Запись практика не найдена.</div>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr><th style="width:200px;">ID практика</th><td><?= (int)$prat['id'] ?></td></tr>
|
||||
<tr>
|
||||
<th>Фото (файл)</th>
|
||||
<td>
|
||||
<div style="width:120px; height:120px; overflow:hidden; border-radius:10px;">
|
||||
<img src="/../public/uploads/<?= h($prat['photo']) ?>"
|
||||
alt="Фото практика"
|
||||
style="width:100%; height:100%; object-fit:cover;">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Тариф</th><td><?= h($prat['titre']) ?></td></tr>
|
||||
<tr><th>Длительность приёма (мин)</th><td><?= (int)$prat['duree_rdv'] ?></td></tr>
|
||||
<tr><th>Фамилия</th><td><?= h($prat['nom']) ?></td></tr>
|
||||
<tr><th>Имя</th><td><?= h($prat['prenom']) ?></td></tr>
|
||||
<tr><th>E-mail</th><td><?= h($prat['email']) ?></td></tr>
|
||||
<tr><th>Телефон</th><td><?= h($prat['telephone']) ?></td></tr>
|
||||
<tr><th>Специальность</th><td><?= h($prat['specialite']) ?></td></tr>
|
||||
<tr><th>Адрес</th><td><?= nl2br(h($prat['adresse'])) ?></td></tr>
|
||||
<tr><th>Город</th><td><?= h($prat['ville']) ?></td></tr>
|
||||
<tr><th>Индекс</th><td><?= h($prat['code_postal']) ?></td></tr>
|
||||
<tr><th>SIRET</th><td><?= h($prat['siret']) ?></td></tr>
|
||||
<tr>
|
||||
<th>CGU (accepted_cgu)</th>
|
||||
<td>
|
||||
<?php if ((int)($prat['accepted_cgu'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Принял(а)</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">Не принял(а) или не указано</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Презентация</th><td><?= nl2br(h($prat['presentation'])) ?></td></tr>
|
||||
<tr><th>Основное видео (URL)</th><td><?= h($prat['video']) ?></td></tr>
|
||||
<tr><th>Доступность (сырой текст)</th><td><?= nl2br(h($prat['disponibilite'])) ?></td></tr>
|
||||
<tr><th>Тип календаря</th><td><?= h($prat['calendrier_type']) ?></td></tr>
|
||||
|
||||
<tr><th>Абонемент ID</th><td><?= h($prat['abonnement_id']) ?></td></tr>
|
||||
<tr><th>Дата окончания абонемента</th><td><?= h($prat['date_expiration']) ?></td></tr>
|
||||
<tr>
|
||||
<th>Синхронизация активна</th>
|
||||
<td>
|
||||
<?php if ((int)($prat['synchronisation_active'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Да</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary">Нет</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Создан</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($prat['created_at'])) {
|
||||
echo h((new DateTime($prat['created_at']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Изменён</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($prat['updated_at'])) {
|
||||
echo h((new DateTime($prat['updated_at']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="alert alert-info mt-3">
|
||||
Для этой роли подробная карточка ещё не настроена.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$html = ob_get_clean();
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'html' => $html
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
// admin/tools/ajax_send_validation_link.php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// ID "super admin"
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok'=>false, 'error'=>'Доступ запрещён.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
function jerr(string $msg, int $code = 400): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok'=>false, 'error'=>$msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF
|
||||
$csrf = $_POST['csrf'] ?? '';
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $csrf)) {
|
||||
jerr('Ошибка CSRF. Обновите страницу.', 400);
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jerr('Неверный идентификатор пользователя.', 400);
|
||||
}
|
||||
|
||||
// Загружаем аккаунт
|
||||
$st = $pdo->prepare("SELECT id, email, role FROM compte WHERE id = ? AND role IN ('client','praticien','secretaire')");
|
||||
$st->execute([$id]);
|
||||
$compte = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$compte) {
|
||||
jerr('Пользователь не найден или роль не поддерживается.', 404);
|
||||
}
|
||||
|
||||
$email = trim($compte['email'] ?? '');
|
||||
if ($email === '') {
|
||||
jerr('У пользователя не указан e-mail.', 400);
|
||||
}
|
||||
|
||||
// Генерируем новый токен
|
||||
try {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
} catch (Exception $e) {
|
||||
jerr('Не удалось сгенерировать токен.', 500);
|
||||
}
|
||||
|
||||
// Сохраняем токен и сбрасываем флаг подтверждения
|
||||
$upd = $pdo->prepare("UPDATE compte SET token_validation = ?, email_valide = 0, modifie_le = NOW() WHERE id = ?");
|
||||
$upd->execute([$token, $compte['id']]);
|
||||
|
||||
// Строим ссылку подтверждения
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'uslugi.fr';
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$validateUrl = $scheme . '://' . $host . '/auth/validate.php?token=' . urlencode($token);
|
||||
|
||||
// Отправка письма (простой mail(), можно заменить на PHPMailer)
|
||||
|
||||
$subject = 'Подтверждение регистрации на uslugi.fr';
|
||||
|
||||
// On prépare le contenu HTML (avec des sauts de ligne <br>)
|
||||
$htmlBody = nl2br(
|
||||
"Здравствуйте!\n\n" .
|
||||
"Вы (или кто-то другой) зарегистрировались на сайте uslugi.fr с этим адресом электронной почты.\n\n" .
|
||||
"Чтобы подтвердить регистрацию, перейдите по следующей ссылке:\n" .
|
||||
"<a href='$validateUrl'>$validateUrl</a>\n\n" .
|
||||
"Если вы не регистрировались на сайте, просто проигнорируйте это письмо.\n\n" .
|
||||
"С уважением,\n" .
|
||||
"Команда uslugi.fr\n"
|
||||
);
|
||||
|
||||
// On garde une version texte pur sans HTML
|
||||
$textBody = strip_tags($htmlBody);
|
||||
|
||||
// REMPLACEMENT DE mail() PAR uslugi_mail()
|
||||
list($sent, $err) = uslugi_mail(
|
||||
$email,
|
||||
$subject, // Plus besoin de base64_encode ici
|
||||
$htmlBody,
|
||||
$textBody,
|
||||
['to_name' => "Пользователь"] // Optionnel : vous pouvez passer le nom si vous l'avez
|
||||
);
|
||||
|
||||
// On adapte le test de succès
|
||||
if (!$sent) {
|
||||
// On affiche l'erreur réelle retournée par Brevo/PHPMailer pour le debug
|
||||
jerr('Ошибка отправки (Brevo): ' . $err, 500);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'message' => 'Ссылка для подтверждения регистрации успешно отправлена на ' . $email
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
if (($_GET['page'] ?? '') !== 'audit') return;
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['audite_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require_once __DIR__.'/_lib_audit.php';
|
||||
// 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; }
|
||||
if (!isset($pdo)) { $db = realpath(__DIR__.'/../../includes/db.php'); if ($db) require_once $db; }
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['audite_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch(Throwable) {}
|
||||
|
||||
$e = static fn($s)=>htmlspecialchars((string)$s,ENT_QUOTES,'UTF-8');
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||||
$CSRF=$_SESSION['csrf_token'];
|
||||
|
||||
// filtres
|
||||
$qAdmin = (int)($_GET['admin_id'] ?? 0);
|
||||
$qAct = trim($_GET['action'] ?? '');
|
||||
$qFrom = trim($_GET['from'] ?? '');
|
||||
$qTo = trim($_GET['to'] ?? '');
|
||||
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||
$per = min(200, max(10,(int)($_GET['per'] ?? 50)));
|
||||
|
||||
$w=[]; $p=[];
|
||||
if ($qAdmin>0){ $w[]='admin_id=:aid'; $p[':aid']=$qAdmin; }
|
||||
if ($qAct!==''){ $w[]='action=:act'; $p[':act']=$qAct; }
|
||||
if ($qFrom!==''){ $w[]='created_at>=:f'; $p[':f']=$qFrom.' 00:00:00'; }
|
||||
if ($qTo!==''){ $w[]='created_at<=:t'; $p[':t']=$qTo.' 23:59:59'; }
|
||||
$where = $w?('WHERE '.implode(' AND ',$w)):'';
|
||||
|
||||
$cnt = $pdo->prepare("SELECT COUNT(*) FROM admin_audit $where");
|
||||
$cnt->execute($p); $total=(int)$cnt->fetchColumn();
|
||||
$pages = max(1,(int)ceil($total/$per));
|
||||
$off = ($page-1)*$per;
|
||||
|
||||
$sql = "SELECT id,admin_id,action,scope,target_table,target_id,meta_json,INET6_NTOA(ip) ip,user_agent,created_at
|
||||
FROM admin_audit $where ORDER BY id DESC LIMIT $per OFFSET $off";
|
||||
$stmt=$pdo->prepare($sql); $stmt->execute($p);
|
||||
$rows=$stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['audite_title'] ?? 'Journal d’audit (actions admin)') ?></h3>
|
||||
<span class="badge text-bg-secondary">
|
||||
<?php
|
||||
$tpl = (string)($translations['audite_total_badge'] ?? '{N} lignes');
|
||||
echo $e(str_replace('{N}', (string)((int)$total), $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form class="row g-2 mb-3" method="get">
|
||||
<input type="hidden" name="page" value="audit">
|
||||
<div class="col-6 col-md-2">
|
||||
<input class="form-control" name="admin_id" type="number"
|
||||
placeholder="<?= $e($translations['audite_ph_admin_id'] ?? 'admin_id') ?>"
|
||||
value="<?= $qAdmin ?>">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<input class="form-control" name="action"
|
||||
placeholder="<?= $e($translations['audite_ph_action'] ?? 'action') ?>"
|
||||
value="<?= $e($qAct) ?>">
|
||||
</div>
|
||||
<div class="col-6 col-md-2"><input class="form-control" name="from" type="date" value="<?= $e($qFrom) ?>"></div>
|
||||
<div class="col-6 col-md-2"><input class="form-control" name="to" type="date" value="<?= $e($qTo) ?>"></div>
|
||||
<div class="col-6 col-md-2">
|
||||
<select class="form-select" name="per">
|
||||
<?php foreach ([25,50,100,200] as $n): ?>
|
||||
<option value="<?= $n ?>" <?= $per===$n?'selected':'' ?>>
|
||||
<?php
|
||||
$tpl = (string)($translations['audite_per_page'] ?? '{N}/page');
|
||||
echo $e(str_replace('{N}', (string)$n, $tpl));
|
||||
?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 d-grid">
|
||||
<button class="btn btn-outline-secondary"><?= $e($translations['audite_btn_filter'] ?? 'Filtrer') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle mb-0 mobile-cards">
|
||||
<thead><tr>
|
||||
<th><?= $e($translations['audite_th_id'] ?? '#') ?></th>
|
||||
<th><?= $e($translations['audite_th_admin'] ?? 'Admin') ?></th>
|
||||
<th><?= $e($translations['audite_th_action'] ?? 'Action') ?></th>
|
||||
<th><?= $e($translations['audite_th_scope'] ?? 'Scope') ?></th>
|
||||
<th><?= $e($translations['audite_th_target'] ?? 'Cible') ?></th>
|
||||
<th><?= $e($translations['audite_th_ip'] ?? 'IP') ?></th>
|
||||
<th><?= $e($translations['audite_th_date'] ?? 'Date') ?></th>
|
||||
<th><?= $e($translations['audite_th_meta'] ?? 'Meta') ?></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($rows as $r): ?>
|
||||
<tr>
|
||||
<td><?= (int)$r['id'] ?></td>
|
||||
<td><?= (int)$r['admin_id'] ?></td>
|
||||
<td><span class="badge text-bg-dark"><?= $e($r['action']) ?></span></td>
|
||||
<td><?= $e($r['scope'] ?? '') ?></td>
|
||||
<td><small><?= $e($r['target_table'] ?? '') ?><?= $r['target_id']?(' #'.(int)$r['target_id']):'' ?></small></td>
|
||||
<td><small><?= $e($r['ip'] ?? '') ?></small></td>
|
||||
<td><small><?= $e($r['created_at']) ?></small></td>
|
||||
<td><code class="small"><?= $e($r['meta_json'] ?? '') ?></code></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if ($pages>1): ?>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<div>
|
||||
<?php
|
||||
$tpl = (string)($translations['audite_page_x_y'] ?? 'Page {X} / {Y}');
|
||||
echo $e(str_replace(['{X}','{Y}'], [(string)$page, (string)$pages], $tpl));
|
||||
?>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<?php for($i=max(1,$page-2);$i<=min($pages,$page+2);$i++):
|
||||
$q=$_GET; $q['p']=$i; ?>
|
||||
<a class="btn btn-sm <?= $i===$page?'btn-dark':'btn-outline-dark' ?>" href="?<?= http_build_query($q) ?>"><?= $i ?></a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
if (($_GET['page'] ?? '') !== 'backup_db') return;
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
$roles=(array)($_SESSION['role']??[]);
|
||||
if(!in_array('admin',$roles,true)){
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['backup_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
if (!isset($pdo)) { $db=realpath(__DIR__.'/../../includes/db.php'); if($db) require_once $db; }
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['backup_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try{$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);}catch(Throwable){}
|
||||
|
||||
require_once __DIR__.'/_lib_audit.php';
|
||||
|
||||
// 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; }
|
||||
|
||||
$e=static fn($s)=>htmlspecialchars((string)$s,ENT_QUOTES,'UTF-8');
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||||
$CSRF=$_SESSION['csrf_token'];
|
||||
|
||||
$siteRoot=realpath(__DIR__.'/../../');
|
||||
$backupDir=$siteRoot.'/backups/db';
|
||||
@is_dir($backupDir) || @mkdir($backupDir,0755,true);
|
||||
|
||||
$msg=null; $ok=null;
|
||||
|
||||
// Params
|
||||
$retentionDays = max(0,(int)($_POST['retention'] ?? ($_GET['retention'] ?? 30)));
|
||||
$useGzip = true;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD']==='POST') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
$msg = (string)($translations['backup_csrf_invalid'] ?? 'CSRF invalide.');
|
||||
}
|
||||
else {
|
||||
$act = $_POST['action'] ?? '';
|
||||
if ($act==='create') {
|
||||
// Dump via PDO (fallback si mysqldump non dispo)
|
||||
$dbName = $pdo->query("SELECT DATABASE()")->fetchColumn();
|
||||
$fname = 'db_'.$dbName.'_'.date('Ymd_His').'.sql';
|
||||
$tmp = $backupDir.'/'.$fname;
|
||||
|
||||
// --- export ---
|
||||
$fh = @fopen($tmp,'wb');
|
||||
if(!$fh){
|
||||
$tpl = (string)($translations['backup_cannot_write_dir'] ?? "Impossible d'écrire dans {DIR}");
|
||||
$msg = str_replace('{DIR}', (string)$backupDir, $tpl);
|
||||
}
|
||||
else {
|
||||
fwrite($fh, "-- Backup of `$dbName` on ".date('c')."\nSET FOREIGN_KEY_CHECKS=0;\n");
|
||||
// tables
|
||||
$tables = $pdo->query("SHOW FULL TABLES WHERE Table_Type='BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($tables as $t) {
|
||||
// DDL
|
||||
$row = $pdo->query("SHOW CREATE TABLE `$t`")->fetch(PDO::FETCH_ASSOC);
|
||||
fwrite($fh, "\n-- Structure `$t`\nDROP TABLE IF EXISTS `$t`;\n".$row['Create Table'].";\n");
|
||||
// DML
|
||||
$st = $pdo->query("SELECT * FROM `$t`", PDO::FETCH_ASSOC);
|
||||
$cols = array_map(fn($c)=>'`'.$c.'`', array_keys($st->fetch() ?: []));
|
||||
if ($cols) {
|
||||
// Refaire la requête pour ne pas “perdre” la première ligne
|
||||
$st = $pdo->query("SELECT * FROM `$t`", PDO::FETCH_ASSOC);
|
||||
$colList = implode(',', $cols);
|
||||
$placeholders = '(' . implode(',', array_fill(0, count($cols), '?')) . ')';
|
||||
$batch=[]; $n=0;
|
||||
foreach($st as $row){
|
||||
$vals = array_values($row);
|
||||
$vals = array_map(function($v) use($pdo){ return isset($v)?$pdo->quote((string)$v):"NULL"; }, $vals);
|
||||
$batch[] = '('.implode(',', $vals).')';
|
||||
if (++$n % 200 === 0) { fwrite($fh, "INSERT INTO `$t` ($colList) VALUES\n".implode(",\n",$batch).";\n"); $batch=[]; }
|
||||
}
|
||||
if ($batch){ fwrite($fh, "INSERT INTO `$t` ($colList) VALUES\n".implode(",\n",$batch).";\n"); }
|
||||
}
|
||||
}
|
||||
fwrite($fh, "SET FOREIGN_KEY_CHECKS=1;\n");
|
||||
fclose($fh);
|
||||
|
||||
// gzip
|
||||
if ($useGzip) {
|
||||
$gz = $tmp.'.gz';
|
||||
$gzh = gzopen($gz,'wb9');
|
||||
$src = fopen($tmp,'rb');
|
||||
while(!feof($src)){ gzwrite($gzh, fread($src, 1<<20)); }
|
||||
fclose($src); gzclose($gzh);
|
||||
@unlink($tmp);
|
||||
$final=$gz;
|
||||
} else { $final=$tmp; }
|
||||
|
||||
$tpl = (string)($translations['backup_created_ok'] ?? 'Sauvegarde créée : {FILE}');
|
||||
$ok = str_replace('{FILE}', basename($final), $tpl);
|
||||
|
||||
AdminAudit::log($pdo, [
|
||||
'admin_id'=>(int)($_SESSION['user_id']??0),
|
||||
'action'=>'backup_create',
|
||||
'scope'=>'maintenance',
|
||||
'meta'=>['file'=>basename($final),'dir'=>'backups/db']
|
||||
]);
|
||||
}
|
||||
}
|
||||
elseif ($act==='prune') {
|
||||
$cut = time()-($retentionDays*86400);
|
||||
$n=0; foreach (glob($backupDir.'/db_*.sql*') as $f){ if(filemtime($f)<$cut){ @unlink($f); $n++; } }
|
||||
|
||||
$tpl = (string)($translations['backup_retention_applied'] ?? 'Rétention appliquée : {N} fichier(s) supprimé(s) (> {D} j)');
|
||||
$ok = str_replace(['{N}','{D}'], [(string)$n, (string)$retentionDays], $tpl);
|
||||
|
||||
AdminAudit::log($pdo, [
|
||||
'admin_id'=>(int)($_SESSION['user_id']??0),
|
||||
'action'=>'backup_prune',
|
||||
'scope'=>'maintenance',
|
||||
'meta'=>['retention_days'=>$retentionDays]
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Liste
|
||||
$files = array_map('basename', glob($backupDir.'/db_*.sql*') ?: []);
|
||||
usort($files, fn($a,$b)=>strcmp($b,$a)); // plus récent d'abord
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['backup_title'] ?? 'Sauvegardes de la base') ?></h3>
|
||||
<span class="badge text-bg-secondary">
|
||||
<?php
|
||||
$tpl = (string)($translations['backup_files_badge'] ?? '{N} fichier(s)');
|
||||
echo $e(str_replace('{N}', (string)count($files), $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if($ok): ?><div class="alert alert-success"><?= $e($ok) ?></div><?php endif; ?>
|
||||
<?php if($msg): ?><div class="alert alert-danger"><?= $e($msg) ?></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 d-flex gap-2">
|
||||
<button class="btn btn-dark" name="action" value="create"
|
||||
onclick="return confirm('<?= $e($translations['backup_confirm_create'] ?? 'Créer un dump SQL compressé ?') ?>')">
|
||||
<?= $e($translations['backup_btn_create'] ?? 'Sauvegarder') ?>
|
||||
</button>
|
||||
<div class="ms-auto"></div>
|
||||
<div class="input-group" style="max-width:320px">
|
||||
<span class="input-group-text"><?= $e($translations['backup_retention_label'] ?? 'Rétention (jours)') ?></span>
|
||||
<input class="form-control" type="number" min="0" name="retention" value="<?= (int)$retentionDays ?>">
|
||||
<button class="btn btn-outline-danger" name="action" value="prune"
|
||||
onclick="return confirm('<?= $e($translations['backup_confirm_prune'] ?? 'Supprimer les backups plus anciens ?') ?>')">
|
||||
<?= $e($translations['backup_btn_prune'] ?? 'Go') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['backup_folder_line'] ?? 'Dossier: {DIR}');
|
||||
echo $e(str_replace('{DIR}', (string)$backupDir, $tpl));
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 mobile-cards">
|
||||
<thead><tr>
|
||||
<th><?= $e($translations['backup_th_file'] ?? 'Fichier') ?></th>
|
||||
<th><?= $e($translations['backup_th_size'] ?? 'Taille') ?></th>
|
||||
<th><?= $e($translations['backup_th_date'] ?? 'Date') ?></th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($files as $f):
|
||||
$full=$backupDir.'/'.$f; ?>
|
||||
<tr>
|
||||
<td><code><?= $e($f) ?></code></td>
|
||||
<td><?= number_format((filesize($full)?:0)/1024,1) ?> KB</td>
|
||||
<td><?= date('Y-m-d H:i:s', filemtime($full)?:time()) ?></td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="/admin/tools/backup_download.php?f=<?= $e(urlencode($f)) ?>&csrf=<?= $e($CSRF) ?>">
|
||||
<?= $e($translations['backup_btn_download'] ?? 'Télécharger') ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; if(!$files): ?>
|
||||
<tr><td colspan="4" class="text-muted p-3"><?= $e($translations['backup_none_yet'] ?? 'Aucun backup encore.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
if (empty($_SESSION['role']) || !in_array('admin',(array)$_SESSION['role'],true)) { http_response_code(403); exit('Forbidden'); }
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_GET['csrf'] ?? '')) { http_response_code(400); exit('CSRF'); }
|
||||
|
||||
$siteRoot=realpath(__DIR__.'/../../'); $dir=$siteRoot.'/backups/db';
|
||||
$f=basename($_GET['f'] ?? ''); $path=realpath($dir.'/'.$f);
|
||||
if(!$path || !str_starts_with($path, realpath($dir)) || !is_file($path)){ http_response_code(404); exit('Not found'); }
|
||||
|
||||
while(ob_get_level()) ob_end_clean();
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: attachment; filename="'.basename($path).'"');
|
||||
header('Content-Length: '.filesize($path));
|
||||
readfile($path); exit;
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
/**
|
||||
* clean_uploads.php
|
||||
* Compatible:
|
||||
* - accès direct /admin/tools/clean_uploads.php
|
||||
* - inclusion via /admin/index.php?page=clean_uploads
|
||||
*/
|
||||
|
||||
// Si APP_HTDOCS n'existe pas, c'est qu'on n'est pas passé par le bootstrap admin
|
||||
if (! defined('APP_HTDOCS')) {
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
}
|
||||
|
||||
// Dossier uploads (chez toi: htdocs/public/uploads/)
|
||||
$uploadDir = rtrim(APP_HTDOCS, '/\\') . '/public/uploads/';
|
||||
if (! is_dir($uploadDir)) {
|
||||
http_response_code(500);
|
||||
exit("Uploads introuvable: " . $uploadDir);
|
||||
}
|
||||
$uploadDir = rtrim($uploadDir, "/\\") . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Assurer PDO
|
||||
if (! isset($pdo) || ! ($pdo instanceof PDO)) {
|
||||
http_response_code(500);
|
||||
exit("PDO introuvable (DB non initialisée).");
|
||||
}
|
||||
|
||||
// ⚙️ Sources DB : tu peux en ajouter plusieurs (plusieurs tables/colonnes)
|
||||
$dbSources = [
|
||||
[
|
||||
'sql' => "SELECT photo FROM praticien WHERE photo IS NOT NULL AND photo <> ''",
|
||||
'col' => 'photo',
|
||||
],
|
||||
// Exemple si tu as d'autres tables :
|
||||
// ['sql' => "SELECT image FROM articles WHERE image IS NOT NULL AND image <> ''", 'col' => 'image'],
|
||||
];
|
||||
|
||||
// --- CSRF token ---
|
||||
if (empty($_SESSION['csrf_clean_uploads'])) {
|
||||
$_SESSION['csrf_clean_uploads'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$csrf = $_SESSION['csrf_clean_uploads'];
|
||||
|
||||
// --- Paramètres ---
|
||||
$limitList = 400; // pour éviter une page énorme si milliers de fichiers
|
||||
$action = $_POST['action'] ?? '';
|
||||
$confirmed = isset($_POST['confirm_delete']) && $_POST['confirm_delete'] === '1';
|
||||
|
||||
// --- 1) Charger noms d'images depuis la DB ---
|
||||
$dbNames = [];
|
||||
foreach ($dbSources as $src) {
|
||||
$stmt = $pdo->query($src['sql']);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($rows as $name) {
|
||||
$name = trim((string) $name);
|
||||
if ($name !== '') {
|
||||
$dbNames[$name] = true;
|
||||
}
|
||||
// set
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) Scanner le dossier uploads (fichiers seulement) ---
|
||||
$files = @scandir($uploadDir);
|
||||
if (! is_array($files)) {
|
||||
http_response_code(500);
|
||||
exit('Impossible de lire le dossier uploads.');
|
||||
}
|
||||
|
||||
$orphans = []; // [ ['name'=>..., 'size'=>..., 'mtime'=>...] ]
|
||||
$totalOrphanSize = 0;
|
||||
|
||||
foreach ($files as $f) {
|
||||
if ($f === '.' || $f === '..') {
|
||||
continue;
|
||||
if ($f[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ignore fichiers système fréquents
|
||||
if ($f === '.DS_Store' || $f === 'Thumbs.db' || $f === '.htaccess') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$full = $uploadDir . $f;
|
||||
|
||||
// uniquement les fichiers (pas sous-dossiers)
|
||||
if (! is_file($full)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// si le fichier n'est pas dans la base => orphelin
|
||||
if (! isset($dbNames[$f])) {
|
||||
$size = filesize($full);
|
||||
$mtime = filemtime($full);
|
||||
$orphans[] = [
|
||||
'name' => $f,
|
||||
'size' => is_int($size) ? $size : 0,
|
||||
'mtime' => is_int($mtime) ? $mtime : 0,
|
||||
];
|
||||
$totalOrphanSize += (is_int($size) ? $size : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Tri : plus gros d'abord (pratique pour voir vite les gains)
|
||||
usort($orphans, fn($a, $b) => ($b['size'] <=> $a['size']));
|
||||
|
||||
// --- 3) Suppression si demandé ---
|
||||
$deleted = [];
|
||||
$errors = [];
|
||||
|
||||
if ($action === 'delete') {
|
||||
// CSRF
|
||||
$postedCsrf = (string) ($_POST['csrf'] ?? '');
|
||||
if (! hash_equals($csrf, $postedCsrf)) {
|
||||
$errors[] = "CSRF invalide. Recharge la page.";
|
||||
} elseif (! $confirmed) {
|
||||
$errors[] = "Coche la confirmation avant de supprimer.";
|
||||
} else {
|
||||
// On supprime uniquement les orphelins trouvés à l’instant T
|
||||
foreach ($orphans as $o) {
|
||||
$name = $o['name'];
|
||||
|
||||
// Sécurité anti traversal
|
||||
if (str_contains($name, '/') || str_contains($name, '\\') || $name === '') {
|
||||
$errors[] = "Nom de fichier suspect ignoré: " . h($name);
|
||||
continue;
|
||||
}
|
||||
|
||||
$full = $uploadDir . $name;
|
||||
|
||||
// recheck réel
|
||||
if (! is_file($full)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// s'assurer qu'on reste dans uploadDir
|
||||
$real = realpath($full);
|
||||
if ($real === false || strncmp($real, $uploadDir, strlen($uploadDir)) !== 0) {
|
||||
$errors[] = "Chemin refusé: " . h($name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (@unlink($real)) {
|
||||
$deleted[] = $name;
|
||||
} else {
|
||||
$errors[] = "Impossible de supprimer: " . h($name);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-générer un nouveau token après action destructive
|
||||
$_SESSION['csrf_clean_uploads'] = bin2hex(random_bytes(32));
|
||||
$csrf = $_SESSION['csrf_clean_uploads'];
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function format_bytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = 0;
|
||||
$val = (float) $bytes;
|
||||
while ($val >= 1024 && $i < count($units) - 1) {$val /= 1024;
|
||||
$i++;}
|
||||
return ($i === 0 ? (string) $bytes : number_format($val, 2, '.', ' ')) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Nettoyage uploads (orphelins)</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:20px;line-height:1.35}
|
||||
.box{padding:12px 14px;border:1px solid #ddd;border-radius:10px;margin:12px 0}
|
||||
.ok{background:#f2fff5;border-color:#bde8c7}
|
||||
.err{background:#fff4f4;border-color:#f0b9b9}
|
||||
table{border-collapse:collapse;width:100%;margin-top:10px}
|
||||
th,td{border-bottom:1px solid #eee;padding:8px 6px;text-align:left;font-size:14px}
|
||||
th{background:#fafafa}
|
||||
code{background:#f6f6f6;padding:2px 6px;border-radius:6px}
|
||||
.muted{color:#666}
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
||||
.btn{display:inline-block;padding:10px 14px;border-radius:10px;border:1px solid #ccc;background:#fff;cursor:pointer}
|
||||
.btn-danger{border-color:#e3a4a4;background:#fff1f1}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>Nettoyage du dossier uploads</h2>
|
||||
<div class="box">
|
||||
<div class="muted">Dossier :</div>
|
||||
<div><code><?php echo h($uploadDir) ?></code></div>
|
||||
</div>
|
||||
|
||||
<?php if (! empty($deleted)): ?>
|
||||
<div class="box ok">
|
||||
✅ Supprimés : <b><?php echo count($deleted) ?></b>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
Exemple : <?php echo h($deleted[0]) ?><?php echo count($deleted) > 1 ? ' …' : '' ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($errors)): ?>
|
||||
<div class="box err">
|
||||
❌ Erreurs :
|
||||
<ul>
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<li><?php echo $e ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<div>Fichiers orphelins : <b><?php echo count($orphans) ?></b></div>
|
||||
<div>Taille totale : <b><?php echo h(format_bytes((int) $totalOrphanSize)) ?></b></div>
|
||||
<div class="muted">(triés par taille décroissante)</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($orphans) === 0): ?>
|
||||
<p class="muted" style="margin-top:10px;">Aucun fichier orphelin trouvé ✅</p>
|
||||
<?php else: ?>
|
||||
<form method="post" onsubmit="return confirm('Confirmer la suppression des fichiers orphelins ?');" style="margin-top:12px;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="csrf" value="<?php echo h($csrf) ?>">
|
||||
|
||||
<label style="display:flex;gap:8px;align-items:center;margin:10px 0;">
|
||||
<input type="checkbox" name="confirm_delete" value="1">
|
||||
<span>Je confirme la suppression de <b><?php echo count($orphans) ?></b> fichiers orphelins (<?php echo h(format_bytes((int) $totalOrphanSize)) ?>).</span>
|
||||
</label>
|
||||
|
||||
<button class="btn btn-danger" type="submit">Supprimer maintenant</button>
|
||||
<span class="muted" style="margin-left:10px;">(Tu as déjà une copie sur ton PC, parfait.)</span>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Fichier</th>
|
||||
<th>Taille</th>
|
||||
<th>Dernière modif</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$max = min(count($orphans), $limitList);
|
||||
for ($i = 0; $i < $max; $i++):
|
||||
$o = $orphans[$i];
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo $i + 1 ?></td>
|
||||
<td><?php echo h($o['name']) ?></td>
|
||||
<td><?php echo h(format_bytes((int) $o['size'])) ?></td>
|
||||
<td><?php echo $o['mtime'] ? h(date('Y-m-d H:i:s', (int) $o['mtime'])) : '-' ?></td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if (count($orphans) > $limitList): ?>
|
||||
<p class="muted">Affichage limité à <?php echo (int) $limitList ?> fichiers. (Mais la suppression concerne tous les orphelins trouvés.)</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
// Lang (pour $translations)
|
||||
|
||||
|
||||
function e($s): string {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. e($translations['admin_access_denied'] ?? 'Доступ запрещён.')
|
||||
. '</div></div>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 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->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// Récupération des comptes non validés depuis >6h
|
||||
$sql = "SELECT * FROM compte WHERE email_valide = 0 AND TIMESTAMPDIFF(HOUR, cree_le, NOW()) > 6";
|
||||
$stmt = $pdo->query($sql);
|
||||
$comptes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Ajouter nom, prénom et âge du compte
|
||||
foreach ($comptes as &$c) {
|
||||
$nom = $prenom = ($translations['label_unknown'] ?? '(inconnu)');
|
||||
|
||||
if ($c['role'] === 'client') {
|
||||
$stmt2 = $pdo->prepare("SELECT nom, prenom FROM client WHERE id = :id");
|
||||
$stmt2->execute(['id' => $c['reference_id']]);
|
||||
$info = $stmt2->fetch(PDO::FETCH_ASSOC);
|
||||
if ($info) { $nom = $info['nom']; $prenom = $info['prenom']; }
|
||||
} elseif ($c['role'] === 'praticien') {
|
||||
$stmt2 = $pdo->prepare("SELECT nom, prenom FROM praticien WHERE id = :id");
|
||||
$stmt2->execute(['id' => $c['reference_id']]);
|
||||
$info = $stmt2->fetch(PDO::FETCH_ASSOC);
|
||||
if ($info) { $nom = $info['nom']; $prenom = $info['prenom']; }
|
||||
}
|
||||
|
||||
$c['nom'] = $nom;
|
||||
$c['prenom'] = $prenom;
|
||||
|
||||
// Calculer la durée depuis création
|
||||
$diff = time() - strtotime($c['cree_le']);
|
||||
$heures = floor($diff / 3600);
|
||||
$minutes = floor(($diff % 3600) / 60);
|
||||
$c['age_humain'] = sprintf("%dh %02dmin", $heures, $minutes);
|
||||
}
|
||||
unset($c);
|
||||
|
||||
// --- Suppression sur POST ---
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST'
|
||||
&& isset($_POST['csrf_token'])
|
||||
&& hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
|
||||
&& !empty($_POST['ids'])) {
|
||||
|
||||
$idsToDelete = array_map('intval', $_POST['ids']);
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($comptes as $compte) {
|
||||
if (!in_array($compte['id'], $idsToDelete, true)) continue;
|
||||
|
||||
$id = $compte['id'];
|
||||
$role = $compte['role'];
|
||||
$ref_id = $compte['reference_id'];
|
||||
|
||||
// Supprimer fiche associée
|
||||
if ($role === 'client') {
|
||||
$pdo->prepare("DELETE FROM client WHERE id = :id")->execute(['id' => $ref_id]);
|
||||
} elseif ($role === 'praticien') {
|
||||
$pdo->prepare("DELETE FROM praticien WHERE id = :id")->execute(['id' => $ref_id]);
|
||||
}
|
||||
|
||||
// Supprimer le compte
|
||||
$pdo->prepare("DELETE FROM compte WHERE id = :id")->execute(['id' => $id]);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$msgTpl = ($translations['flash_unverified_deleted'] ?? '✅ {N} compte(s) non validé(s) supprimé(s).');
|
||||
$msg = str_replace('{N}', (string)$deleted, $msgTpl);
|
||||
|
||||
echo '<div class="alert alert-success text-center p-4">'
|
||||
. e($msg)
|
||||
. '</div>';
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3 text-center">
|
||||
<?= e($translations['h2_unverified_accounts_6h'] ?? '🕒 Comptes non validés depuis plus de 6h') ?>
|
||||
</h2>
|
||||
|
||||
<?php if (empty($comptes)): ?>
|
||||
<div class="alert alert-success text-center">
|
||||
<?= e($translations['alert_no_unverified_accounts'] ?? 'Aucun compte non validé à supprimer 🎉') ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" onsubmit="return confirm('<?= e($translations['confirm_delete_checked_accounts'] ?? '⚠️ Supprimer les comptes cochés ?') ?>');">
|
||||
<input type="hidden" name="csrf_token" value="<?= e($CSRF) ?>">
|
||||
|
||||
<table class="table table-bordered table-sm align-middle text-center">
|
||||
<thead class="table-secondary">
|
||||
<tr>
|
||||
<th><input type="checkbox" id="checkAll" aria-label="<?= e($translations['aria_check_all'] ?? 'Tout cocher') ?>"></th>
|
||||
<th><?= e($translations['th_id'] ?? 'ID') ?></th>
|
||||
<th><?= e($translations['th_email'] ?? 'Email') ?></th>
|
||||
<th><?= e($translations['th_nom'] ?? 'Nom') ?></th>
|
||||
<th><?= e($translations['th_prenom'] ?? 'Prénom') ?></th>
|
||||
<th><?= e($translations['th_role'] ?? 'Rôle') ?></th>
|
||||
<th><?= e($translations['th_created_at'] ?? 'Créé le') ?></th>
|
||||
<th><?= e($translations['th_account_age'] ?? 'Âge du compte') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($comptes as $compte): ?>
|
||||
<tr>
|
||||
<td><input type="checkbox" name="ids[]" value="<?= (int)$compte['id'] ?>"></td>
|
||||
<td><?= e($compte['id']) ?></td>
|
||||
<td><?= e($compte['email']) ?></td>
|
||||
<td><?= e($compte['nom']) ?></td>
|
||||
<td><?= e($compte['prenom']) ?></td>
|
||||
<td><?= e($compte['role']) ?></td>
|
||||
<td><?= e($compte['cree_le']) ?></td>
|
||||
<td><?= e($compte['age_humain']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted">
|
||||
<?php
|
||||
$tpl = ($translations['label_accounts_listed'] ?? 'Comptes listés : {N}');
|
||||
echo e(str_replace('{N}', (string)count($comptes), $tpl));
|
||||
?>
|
||||
</small>
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<?= e($translations['btn_delete_checked_accounts'] ?? '🗑️ Supprimer les comptes cochés') ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Cocher / décocher tout
|
||||
document.getElementById('checkAll').addEventListener('change', function() {
|
||||
const boxes = document.querySelectorAll('input[name="ids[]"]');
|
||||
boxes.forEach(b => b.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
/**
|
||||
* /admin/tools/cleanup_rdv.php
|
||||
* Nettoyage des rendez-vous (table `rendez_vous`)
|
||||
* - Cibles: annulé (>N jours), prévu passé (>N jours), (option) terminé (>N jours), orphelins, doublons exacts
|
||||
* - DRY-RUN par défaut ; purge LIVE via POST + CSRF
|
||||
* - Reauth admin (10 min)
|
||||
* - Journal d’audit (admin_audit)
|
||||
*/
|
||||
|
||||
if (($_GET['page'] ?? '') !== 'cleanup_rdv') return;
|
||||
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['clean_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['clean_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {}
|
||||
|
||||
require_once __DIR__.'/_lib_audit.php';
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
|
||||
// ---- Helpers (closures, pas de fonctions globales)
|
||||
$e = static fn($s)=>htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// ---- Paramètres UI
|
||||
$days = max(0, (int)($_REQUEST['days'] ?? 30));
|
||||
$mode = ($_REQUEST['mode'] ?? 'dry') === 'live' ? 'live' : 'dry';
|
||||
$dry = ($mode !== 'live');
|
||||
|
||||
$opt_cancelled = isset($_REQUEST['cancelled']) ? ($_REQUEST['cancelled']==='1') : true; // "annulé"
|
||||
$opt_past_prevu = isset($_REQUEST['past_prevu']) ? ($_REQUEST['past_prevu']==='1') : true; // "prévu" passé
|
||||
$opt_terminated = isset($_REQUEST['terminated']) ? ($_REQUEST['terminated']==='1') : false; // "terminé" (option)
|
||||
$opt_orphans = isset($_REQUEST['orphans']) ? ($_REQUEST['orphans']==='1') : true; // orphelins
|
||||
$opt_duplicates = isset($_REQUEST['duplicates']) ? ($_REQUEST['duplicates']==='1') : true; // doublons exacts
|
||||
|
||||
// ---- Cutoff (on évite INTERVAL ? DAY ; on calcule en PHP)
|
||||
$cutoff = date('Y-m-d H:i:s', time() - $days*86400);
|
||||
|
||||
// ---- Sanity: table & colonnes
|
||||
$tbl = 'rendez_vous';
|
||||
$hasTable = (bool)$pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'rendez_vous'")->fetchColumn();
|
||||
if (!$hasTable) {
|
||||
$tpl = (string)($translations['clean_table_missing'] ?? 'Table <code>{T}</code> introuvable.');
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'.str_replace('{T}', $e('rendez_vous'), $tpl).'</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Détection tables référentielles (souples : pluriel/singulier)
|
||||
$guessTable = static function(PDO $pdo, array $candidates): ?string {
|
||||
foreach ($candidates as $t) {
|
||||
$st = $pdo->prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?");
|
||||
$st->execute([$t]);
|
||||
if ($st->fetchColumn()) return $t;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$tblPraticiens = $guessTable($pdo, ['praticiens','praticien','praticien_profil','praticien_profile']);
|
||||
$tblClients = $guessTable($pdo, ['clients','client']);
|
||||
|
||||
// ---- Collecte candidats par catégorie
|
||||
$cats = []; // ['key'=>['title'=>..., 'ids'=>[], 'rows'=>[...]]]
|
||||
$pushCat = static function(array &$cats, string $key, string $title, array $rows) {
|
||||
$cats[$key] = ['title'=>$title, 'rows'=>$rows, 'ids'=>array_map(fn($r)=>(int)$r['id'],$rows)];
|
||||
};
|
||||
|
||||
// 1) "annulé" avant $cutoff
|
||||
if ($opt_cancelled) {
|
||||
$st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'annulé' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000");
|
||||
$st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tpl = (string)($translations['clean_cat_cancelled_title'] ?? 'RDV annulés avant {D} j');
|
||||
$pushCat($cats, 'cancelled', str_replace('{D}', (string)$days, $tpl), $rows);
|
||||
}
|
||||
|
||||
// 2) "prévu" passé avant $cutoff (no-show)
|
||||
if ($opt_past_prevu) {
|
||||
$st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'prévu' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000");
|
||||
$st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tpl = (string)($translations['clean_cat_past_prevu_title'] ?? 'RDV prévus mais passés (> {D} j)');
|
||||
$pushCat($cats, 'past_prevu', str_replace('{D}', (string)$days, $tpl), $rows);
|
||||
}
|
||||
|
||||
// 3) (option) "terminé" avant $cutoff
|
||||
if ($opt_terminated) {
|
||||
$st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'terminé' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000");
|
||||
$st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tpl = (string)($translations['clean_cat_terminated_title'] ?? 'RDV terminés (> {D} j)');
|
||||
$pushCat($cats, 'terminated', str_replace('{D}', (string)$days, $tpl), $rows);
|
||||
}
|
||||
|
||||
// 4) Orphelins (praticien/client inexistants)
|
||||
if ($opt_orphans && $tblPraticiens && $tblClients) {
|
||||
$sql = "SELECT rv.id, rv.statut, rv.date_rdv
|
||||
FROM `{$tbl}` rv
|
||||
LEFT JOIN `{$tblPraticiens}` p ON p.id = rv.praticien_id
|
||||
LEFT JOIN `{$tblClients}` c ON c.id = rv.client_id
|
||||
WHERE (p.id IS NULL OR c.id IS NULL)
|
||||
ORDER BY rv.date_rdv ASC
|
||||
LIMIT 5000";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$pushCat($cats, 'orphans', (string)($translations['clean_cat_orphans_title'] ?? 'RDV orphelins (praticien/client manquants)'), $rows);
|
||||
} elseif ($opt_orphans) {
|
||||
$rows = [];
|
||||
$pushCat($cats, 'orphans', (string)($translations['clean_cat_orphans_tables_missing'] ?? 'RDV orphelins (tables praticiens/clients introuvables)'), $rows);
|
||||
}
|
||||
|
||||
// 5) Doublons exacts (même praticien_id, client_id, date_rdv). On garde le MIN(id), supprime le reste.
|
||||
if ($opt_duplicates) {
|
||||
// On limite aux RDV plus vieux que cutoff pour rester conservateurs
|
||||
$sql = "
|
||||
SELECT rv2.id, rv2.statut, rv2.date_rdv
|
||||
FROM `{$tbl}` rv2
|
||||
JOIN (
|
||||
SELECT praticien_id, client_id, date_rdv, MIN(id) keep_id, COUNT(*) cnt
|
||||
FROM `{$tbl}`
|
||||
WHERE date_rdv < :cut
|
||||
GROUP BY praticien_id, client_id, date_rdv
|
||||
HAVING cnt > 1
|
||||
) d ON d.praticien_id = rv2.praticien_id
|
||||
AND d.client_id = rv2.client_id
|
||||
AND d.date_rdv = rv2.date_rdv
|
||||
WHERE rv2.id <> d.keep_id
|
||||
AND rv2.statut <> 'confirmé'
|
||||
ORDER BY rv2.date_rdv ASC
|
||||
LIMIT 5000";
|
||||
$st = $pdo->prepare($sql); $st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$pushCat($cats, 'duplicates', (string)($translations['clean_cat_duplicates_title'] ?? 'Doublons exacts (on garde l’ID le plus bas)'), $rows);
|
||||
}
|
||||
|
||||
// ---- Union de tous les IDs à supprimer
|
||||
$allIds = [];
|
||||
foreach ($cats as $k=>$c) { foreach ($c['ids'] as $id) { $allIds[$id] = true; } }
|
||||
$allIds = array_keys($allIds);
|
||||
|
||||
// ---- Exécution LIVE (DELETE) si demandé
|
||||
$deleted = 0; $err = null;
|
||||
if (!$dry && $_SERVER['REQUEST_METHOD']==='POST' && ($_POST['action'] ?? '')==='purge') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
$err = (string)($translations['clean_csrf_invalid'] ?? 'Token CSRF invalide.');
|
||||
} else {
|
||||
if ($allIds) {
|
||||
// Suppression en batch
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Découpage par tranches de 1000
|
||||
$chunks = array_chunk($allIds, 1000);
|
||||
foreach ($chunks as $batch) {
|
||||
$placeholders = implode(',', array_fill(0, count($batch), '?'));
|
||||
$st = $pdo->prepare("DELETE FROM `{$tbl}` WHERE id IN ($placeholders)");
|
||||
$st->execute(array_map('intval', $batch));
|
||||
$deleted += $st->rowCount();
|
||||
}
|
||||
$pdo->commit();
|
||||
AdminAudit::log($pdo, [
|
||||
'admin_id' => (int)($_SESSION['user_id'] ?? 0),
|
||||
'action' => 'rdv_cleanup',
|
||||
'scope' => 'maintenance',
|
||||
'target_table' => $tbl,
|
||||
'meta' => [
|
||||
'deleted' => $deleted,
|
||||
'days' => $days,
|
||||
'cancelled' => $opt_cancelled,
|
||||
'past_prevu' => $opt_past_prevu,
|
||||
'terminated' => $opt_terminated,
|
||||
'orphans' => $opt_orphans,
|
||||
'duplicates' => $opt_duplicates
|
||||
]
|
||||
]);
|
||||
} catch (Throwable $ex) {
|
||||
$pdo->rollBack();
|
||||
$err = (string)($translations['clean_delete_error_prefix'] ?? 'Erreur suppression: ') . $e($ex->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rendu
|
||||
$totalByCat = []; foreach ($cats as $k=>$c){ $totalByCat[$k]=count($c['ids']); }
|
||||
$totalAll = count($allIds);
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['clean_title'] ?? 'Nettoyage des rendez-vous') ?></h3>
|
||||
<span class="badge text-bg-<?= $dry?'secondary':'danger' ?>">
|
||||
<?= $dry ? $e($translations['clean_mode_dry'] ?? 'DRY-RUN (simulation)') : $e($translations['clean_mode_live'] ?? 'LIVE (suppression activée)') ?>
|
||||
</span>
|
||||
<span class="badge text-bg-info">
|
||||
<?php
|
||||
$tpl = (string)($translations['clean_age_badge'] ?? 'Âge ≥ {D} j (référence: <code>{COL}</code>)');
|
||||
echo str_replace(['{D}','{COL}'], [$e((string)(int)$days), $e('date_rdv')], $tpl);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if($err): ?><div class="alert alert-danger"><?= $e($err) ?></div><?php endif; ?>
|
||||
<?php if(!$err && !$dry && $deleted>0): ?>
|
||||
<div class="alert alert-success">
|
||||
<?php
|
||||
$tpl = (string)($translations['clean_deleted_ok'] ?? '{N} rendez-vous supprimés.');
|
||||
echo $e(str_replace('{N}', (string)(int)$deleted, $tpl));
|
||||
?>
|
||||
</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['clean_age_label'] ?? 'Âge minimum') ?></label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="number" min="0" name="days" value="<?= (int)$days ?>">
|
||||
<span class="input-group-text"><?= $e($translations['clean_days_unit'] ?? 'jours') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-9">
|
||||
<label class="form-label"><?= $e($translations['clean_categories_label'] ?? 'Catégories ciblées') ?></label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="cancelled" value="1" <?= $opt_cancelled?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_cancelled'] ?? 'Annulés') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="past_prevu" value="1" <?= $opt_past_prevu?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_past_prevu'] ?? 'Prévu (dépassés)') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="terminated" value="1" <?= $opt_terminated?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_terminated'] ?? 'Terminés') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="orphans" value="1" <?= $opt_orphans?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_orphans'] ?? 'Orphelins') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="duplicates" value="1" <?= $opt_duplicates?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_duplicates'] ?? 'Doublons exacts') ?></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary"
|
||||
name="action" value="scan"
|
||||
formaction="?page=cleanup_rdv&mode=dry">
|
||||
<?= $e($translations['clean_btn_scan'] ?? 'Scanner') ?>
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
name="action" value="purge"
|
||||
formaction="?page=cleanup_rdv&mode=live"
|
||||
onclick="return confirm('<?= $e($translations['clean_confirm_purge'] ?? 'Confirmer la suppression des RDV listés ?') ?>')">
|
||||
<?= $e($translations['clean_btn_purge'] ?? 'Purger') ?>
|
||||
</button>
|
||||
<div class="ms-auto d-flex align-items-center small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['clean_table_label'] ?? 'Table:');
|
||||
echo $e($tpl);
|
||||
?>
|
||||
<code class="ms-1"><?= $e($tbl) ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['clean_summary_title'] ?? '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['clean_sum_total'] ?? 'Total candidats') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)$totalAll ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_cancelled'] ?? 'Annulés') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['cancelled'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_past_prevu'] ?? 'Prévu (dépassés)') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['past_prevu'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_terminated'] ?? 'Terminés') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['terminated'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_orphans'] ?? 'Orphelins') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['orphans'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_duplicates'] ?? 'Doublons') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['duplicates'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_mode'] ?? 'Mode') ?>
|
||||
<span class="badge text-bg-<?= $dry?'secondary':'danger' ?>"><?= $e($dry ? ($translations['clean_mode_short_dry'] ?? 'DRY-RUN') : ($translations['clean_mode_short_live'] ?? 'LIVE')) ?></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['clean_preview_title'] ?? 'Aperçu des candidats (max 150 par catégorie)') ?></div>
|
||||
<div class="card-body p-0">
|
||||
<?php foreach ($cats as $key=>$cat):
|
||||
$rows = array_slice($cat['rows'], 0, 150);
|
||||
?>
|
||||
<div class="p-2 border-bottom fw-semibold"><?= $e($cat['title']) ?> — <?= count($cat['rows']) ?> <?= $e($translations['clean_found'] ?? 'trouvés') ?></div>
|
||||
<?php if (!$rows): ?>
|
||||
<div class="p-3 small text-muted"><?= $e($translations['clean_none_for_criteria'] ?? 'Aucun avec ces critères.') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr>
|
||||
<th><?= $e($translations['clean_th_id'] ?? 'ID') ?></th>
|
||||
<th><?= $e($translations['clean_th_status'] ?? 'Statut') ?></th>
|
||||
<th><?= $e($translations['clean_th_date_rdv'] ?? 'Date RDV') ?></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<tr>
|
||||
<td><?= (int)$r['id'] ?></td>
|
||||
<td><span class="badge text-bg-secondary"><?= $e($r['statut'] ?? $r['s'] ?? '') ?></span></td>
|
||||
<td><?= $e($r['date_rdv'] ?? $r['d'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
// admin/tools/connecter_en_admin.php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'.htmlspecialchars(($translations['access_denied'] ?? 'Доступ запрещён.'), ENT_QUOTES, 'UTF-8').'</div></div>';
|
||||
exit;
|
||||
}
|
||||
// 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 (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
// Helpers DB : test existence de la table de log
|
||||
function tableExists(PDO $pdo, string $table): bool {
|
||||
$sql = "SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([$table]);
|
||||
return (bool)$st->fetchColumn();
|
||||
}
|
||||
|
||||
// ------------ Traitement POST ------------
|
||||
$flash = null;
|
||||
|
||||
if (($_POST['do'] ?? '') === 'impersonate') {
|
||||
try {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
throw new Exception('CSRF');
|
||||
}
|
||||
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
if ($email === '') {
|
||||
throw new Exception('NO_EMAIL');
|
||||
}
|
||||
|
||||
// On cherche dans la table compte (à adapter si le nom diffère)
|
||||
$sql = "SELECT id, email, role, reference_id
|
||||
FROM compte
|
||||
WHERE email = ?
|
||||
LIMIT 1";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([$email]);
|
||||
$user = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
throw new Exception('NOT_FOUND');
|
||||
}
|
||||
|
||||
$role = (string)($user['role'] ?? '');
|
||||
|
||||
// On autorise seulement certains rôles à être "impersonnés"
|
||||
$allowedRoles = ['praticien', 'client'];
|
||||
if (!in_array($role, $allowedRoles, true)) {
|
||||
throw new Exception('ROLE_FORBIDDEN');
|
||||
}
|
||||
|
||||
// Sauvegarder l’admin actuel si ce n’est pas déjà fait
|
||||
if (empty($_SESSION['admin_impersonate']) || !is_array($_SESSION['admin_impersonate'])) {
|
||||
$_SESSION['admin_impersonate'] = [
|
||||
'admin_user_id' => $_SESSION['user_id'] ?? null,
|
||||
'admin_reference_id' => $_SESSION['reference_id'] ?? null,
|
||||
'admin_role' => $_SESSION['role'] ?? null,
|
||||
'started_at' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
// Log de l’action si la table existe
|
||||
if (tableExists($pdo, 'admin_impersonation_log')) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? null;
|
||||
$adminUid = (int)($_SESSION['user_id'] ?? 0);
|
||||
$adminRef = (int)($_SESSION['reference_id'] ?? 0);
|
||||
$targetUid = (int)($user['id'] ?? 0);
|
||||
$targetRef = (int)($user['reference_id'] ?? 0);
|
||||
$emailLog = (string)($user['email'] ?? '');
|
||||
|
||||
$logSql = "INSERT INTO admin_impersonation_log
|
||||
(admin_user_id, admin_reference_id, target_user_id, target_reference_id, target_role, email, ip, user_agent)
|
||||
VALUES (?,?,?,?,?,?,?,?)";
|
||||
$pdo->prepare($logSql)->execute([
|
||||
$adminUid,
|
||||
$adminRef,
|
||||
$targetUid,
|
||||
$targetRef,
|
||||
$role,
|
||||
$emailLog,
|
||||
$ip,
|
||||
$ua,
|
||||
]);
|
||||
}
|
||||
|
||||
// Basculer la session sur l’utilisateur cible
|
||||
$_SESSION['user_id'] = (int)$user['id'];
|
||||
$_SESSION['role'] = $role;
|
||||
$_SESSION['reference_id'] = (int)($user['reference_id'] ?? 0);
|
||||
|
||||
// Redirection selon le rôle
|
||||
if ($role === 'praticien') {
|
||||
header('Location: /public/espace_praticien/index.php');
|
||||
} elseif ($role === 'client') {
|
||||
header('Location: /public/espace_client/index.php');
|
||||
} else {
|
||||
// fallback
|
||||
header('Location: /');
|
||||
}
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$code = $e->getMessage();
|
||||
$msg = ($translations['admin_impersonate_err_default'] ?? 'Ошибка при подключении.');
|
||||
|
||||
if ($code === 'CSRF') $msg = ($translations['admin_impersonate_err_csrf'] ?? 'Ошибка безопасности. Обновите страницу.');
|
||||
if ($code === 'NO_EMAIL') $msg = ($translations['admin_impersonate_err_no_email'] ?? 'Укажите e-mail практикующего или клиента.');
|
||||
if ($code === 'NOT_FOUND') $msg = ($translations['admin_impersonate_err_not_found'] ?? 'Пользователь с таким e-mail не найден.');
|
||||
if ($code === 'ROLE_FORBIDDEN') $msg = ($translations['admin_impersonate_err_role_forbidden'] ?? 'Невозможно подключиться под этим типом пользователя.');
|
||||
|
||||
$flash = ['type'=>'danger','msg'=>$msg];
|
||||
}
|
||||
}
|
||||
|
||||
// Indicateur si on est déjà en mode impersonation (admin_impersonate présent)
|
||||
$isImpersonating = !empty($_SESSION['admin_impersonate']) && is_array($_SESSION['admin_impersonate']);
|
||||
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3"><?= e($translations['admin_impersonate_title'] ?? 'Подключиться как пользователь') ?></h2>
|
||||
|
||||
<?php if ($isImpersonating): ?>
|
||||
<div class="alert alert-warning">
|
||||
<?= e($translations['admin_impersonate_warn_active'] ?? 'Внимание: сейчас вы авторизованы как другой пользователь.') ?>
|
||||
<a href="/admin/index.php?page=retour_admin" class="btn btn-sm btn-outline-danger ms-2" style="border-radius:999px;">
|
||||
<?= e($translations['admin_impersonate_btn_back_admin'] ?? 'Вернуться в режим администратора') ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type'] ?? 'info') ?>"><?= e($flash['msg'] ?? '') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="mb-4">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="do" value="impersonate">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= e($translations['admin_impersonate_label_email'] ?? 'E-mail практикующего или клиента') ?></label>
|
||||
<input type="email" name="email" class="form-control" required
|
||||
placeholder="<?= e($translations['admin_impersonate_ph_email'] ?? 'example@domaine.fr') ?>">
|
||||
<div class="form-text">
|
||||
<?= e($translations['admin_impersonate_help'] ?? 'Администратор будет авторизован под этим пользователем без ввода пароля.') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger" style="border-radius:999px;">
|
||||
<?= e($translations['admin_impersonate_btn_submit'] ?? 'Подключиться под этим пользователем') ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<p class="text-muted mb-0">
|
||||
<?= e($translations['admin_impersonate_footer'] ?? 'Все действия должны соответствовать политике конфиденциальности. Доступ к этой функции есть только у супер-администратора.') ?>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
// ⚠️ FICHIER DE DEBUG — ne pas laisser accessible en prod
|
||||
|
||||
$file = __DIR__ . '/../../public/lang/ru.php'; // adapte si besoin
|
||||
|
||||
$code = file_get_contents($file);
|
||||
if ($code === false) {
|
||||
die("Impossible de lire le fichier");
|
||||
}
|
||||
|
||||
// Match 'key' => ou "key" =>
|
||||
preg_match_all(
|
||||
'/[\'"]([^\'"]+)[\'"]\s*=>/u',
|
||||
$code,
|
||||
$matches
|
||||
);
|
||||
|
||||
$keys = $matches[1] ?? [];
|
||||
|
||||
$total = count($keys);
|
||||
$counts = array_count_values($keys);
|
||||
$duplicates = array_filter($counts, fn($c) => $c > 1);
|
||||
|
||||
echo "<pre>";
|
||||
echo "Total clés trouvées : $total\n\n";
|
||||
|
||||
if (!$duplicates) {
|
||||
echo "✅ Aucun doublon détecté\n";
|
||||
} else {
|
||||
echo "❌ Doublons détectés :\n\n";
|
||||
foreach ($duplicates as $k => $c) {
|
||||
echo "- $k : $c occurrences\n";
|
||||
}
|
||||
}
|
||||
echo "</pre>";
|
||||
@@ -0,0 +1,368 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
// /admin/gerer_praticiens.php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Session sûre
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
|
||||
|
||||
if ($pdo && method_exists($pdo, 'setAttribute')) {
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
|
||||
// ===== Accès admin praticien strict =====
|
||||
const ADMIN_PRACTICIEN_ID = 88888888; // <-- adapte à ton ID
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['admin_access_denied'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====MAILER PHP =====
|
||||
require_once __DIR__ . '/../../includes/mailer.php';
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
// CSRF robuste
|
||||
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token']) || strlen($_SESSION['csrf_token']) < 32) {
|
||||
try { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
|
||||
catch (Throwable $e) { $_SESSION['csrf_token'] = bin2hex(openssl_random_pseudo_bytes(32)); }
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// ==================== Paramètres ====================
|
||||
$DATE_FMT_TABLE = $translations['date_format_table'] ?? 'd.m.Y';
|
||||
$DATE_FMT_MAIL = $translations['date_format_mail'] ?? 'd.m.Y';
|
||||
$RENEW_URL = 'https://uslugi.fr/public/praticien/resiliation.php'; // <- adapte si besoin
|
||||
|
||||
// ==================== Helpers ====================
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
function latestPaymentFor(PDO $pdo, int $praticienId): ?array {
|
||||
$sql = "
|
||||
SELECT p.id, p.statut, p.methode, p.date_paiement
|
||||
FROM paiement p
|
||||
WHERE p.praticien_id = :pid
|
||||
ORDER BY p.date_paiement DESC, p.id DESC
|
||||
LIMIT 1
|
||||
";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([':pid'=>$praticienId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
function computeAboState(?string $dateExpiration, ?string $lastPayStatus, int $warningDays = 10): string {
|
||||
if (!$dateExpiration) return 'expired';
|
||||
|
||||
try {
|
||||
$today = new DateTimeImmutable('today');
|
||||
$exp = new DateTimeImmutable($dateExpiration);
|
||||
} catch (Throwable $e) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if ($exp < $today) return 'expired';
|
||||
|
||||
$diff = $today->diff($exp);
|
||||
$daysLeft = (int)$diff->days;
|
||||
|
||||
if ($daysLeft <= $warningDays) return 'expiring';
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function buildMailHtml(array $t, string $headlineKey, string $leadKey, ?string $dateFinFmt, string $renewUrl, string $btnLabel): string {
|
||||
$headline = htmlspecialchars($t[$headlineKey] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$leadRaw = $t[$leadKey] ?? '';
|
||||
if ($dateFinFmt) $leadRaw = str_replace('{DATE}', $dateFinFmt, $leadRaw);
|
||||
$lead = nl2br(htmlspecialchars($leadRaw, ENT_QUOTES, 'UTF-8'));
|
||||
|
||||
$ctaLabel = htmlspecialchars($btnLabel, ENT_QUOTES, 'UTF-8');
|
||||
$help = nl2br(htmlspecialchars($t['mail_footer_help'] ?? '', ENT_QUOTES, 'UTF-8'));
|
||||
|
||||
$labelAbo = htmlspecialchars($t['mail_label_abo'] ?? 'Абонемент', ENT_QUOTES, 'UTF-8');
|
||||
$labelDate = htmlspecialchars($t['mail_label_date_fin'] ?? 'Дата окончания', ENT_QUOTES, 'UTF-8');
|
||||
$labelState = htmlspecialchars($t['mail_label_statut'] ?? 'Статус', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$valueAbo = htmlspecialchars($t['mail_value_abo'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$valueDate = htmlspecialchars($dateFinFmt ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$valueState = htmlspecialchars($t['mail_value_statut'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brandBas = 'УСЛУГИ.ФР';
|
||||
$merciLine = htmlspecialchars($t['merci_simple'] ?? 'Спасибо', ENT_QUOTES, 'UTF-8');
|
||||
$renewUrl = htmlspecialchars($renewUrl, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$rowsHtml = '';
|
||||
if ($valueAbo !== '') {
|
||||
$rowsHtml .= '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.$labelAbo.'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.$valueAbo.'</td></tr>';
|
||||
}
|
||||
if ($valueDate !== '') {
|
||||
$rowsHtml .= '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;'.($valueState !== '' ? 'border-bottom:1px solid #e5e7eb;' : '').'">'.$labelDate.'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.$valueDate.'</td></tr>';
|
||||
}
|
||||
if ($valueState !== '') {
|
||||
$rowsHtml .= '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.$labelState.'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.$valueState.'</td></tr>';
|
||||
}
|
||||
|
||||
$cardHtml = ($rowsHtml !== '')
|
||||
? '<tr><td style="padding:16px 24px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">'.$rowsHtml.'</table></td></tr>'
|
||||
: '';
|
||||
|
||||
return '<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>'.$headline.'</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#38378B;color:#fff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 24px 8px 24px;color:#111;font-size:18px;font-weight:600;line-height:1.4;">'.$headline.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px;color:#111;font-size:16px;line-height:1.6;">'.$lead.'</td>
|
||||
</tr>
|
||||
'.$cardHtml.'
|
||||
<tr>
|
||||
<td align="center" style="padding:8px 24px 24px 24px;">
|
||||
<a href="'.$renewUrl.'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">'.$ctaLabel.'</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 24px 24px;color:#111;font-size:14px;line-height:1.6;">'.$help.'</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="margin-top:12px;">
|
||||
<tr>
|
||||
<td style="text-align:center;color:#9ca3af;font-size:12px;padding:8px 0;">© '.date('Y').' '.$brandBas.' — '.$merciLine.'</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
function sendReminderEmailPhpMailer(string $toEmail, string $toName, string $subject, string $html, string $text = ''): bool {
|
||||
global $MAILCFG;
|
||||
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) return false;
|
||||
if (empty($MAILCFG['smtp']['host'])) return false;
|
||||
|
||||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $MAILCFG['smtp']['host'];
|
||||
$mail->Port = (int)($MAILCFG['smtp']['port'] ?? 587);
|
||||
$mail->SMTPAuth = (bool)($MAILCFG['smtp']['auth'] ?? true);
|
||||
|
||||
$sec = $MAILCFG['smtp']['secure'] ?? 'tls';
|
||||
if ($sec === 'ssl') $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS;
|
||||
else $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
|
||||
$mail->Username = $MAILCFG['smtp']['username'] ?? '';
|
||||
$mail->Password = $MAILCFG['smtp']['password'] ?? '';
|
||||
|
||||
$mail->Timeout = 10;
|
||||
$mail->SMTPKeepAlive = false;
|
||||
$mail->SMTPAutoTLS = true;
|
||||
$mail->SMTPDebug = 0;
|
||||
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->XMailer = '';
|
||||
|
||||
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
|
||||
|
||||
$from = $MAILCFG['from'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
|
||||
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
|
||||
|
||||
$mail->setFrom($from[0], $from[1] ?? '');
|
||||
if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? '');
|
||||
|
||||
$mail->addAddress($toEmail, $toName ?: '');
|
||||
|
||||
if (!empty($MAILCFG['suppress_auto'])) {
|
||||
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
|
||||
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
|
||||
}
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html;
|
||||
|
||||
if (trim($text) === '') {
|
||||
$text = trim(strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)));
|
||||
}
|
||||
$mail->AltBody = $text;
|
||||
|
||||
return $mail->send();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error_log('PHPMailer reminder send exception: ' . $e->getMessage());
|
||||
if (!empty($mail->ErrorInfo)) error_log('PHPMailer reminder ErrorInfo: ' . $mail->ErrorInfo);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Traitement POST: envoi mail ====================
|
||||
$flash = null;
|
||||
if (($_POST['do'] ?? '') === 'send_mail') {
|
||||
try {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) { throw new Exception('CSRF'); }
|
||||
$pid = (int)($_POST['id'] ?? 0);
|
||||
if ($pid <= 0) throw new Exception('ID');
|
||||
|
||||
$st = $pdo->prepare("
|
||||
SELECT pr.id, pr.nom, pr.prenom, pr.email, pr.date_expiration, ab.nom AS abo_nom
|
||||
FROM praticien pr
|
||||
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
|
||||
WHERE pr.id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$st->execute([$pid]);
|
||||
$pr = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$pr) throw new Exception('Not found');
|
||||
|
||||
$last = latestPaymentFor($pdo, $pid);
|
||||
$state = computeAboState($pr['date_expiration'] ?? null, $last['statut'] ?? null);
|
||||
|
||||
if ($state === 'active') {
|
||||
$flash = ['type'=>'warning','msg'=> e($translations['flash_pas_necessaire'] ?? 'Pas nécessaire.')];
|
||||
} else {
|
||||
if ($state === 'expired') {
|
||||
$subjectKey='mail_subject_expired'; $headlineKey='mail_headline_expired'; $leadKey='mail_lead_expired';
|
||||
} else {
|
||||
$subjectKey='mail_subject_expiring'; $headlineKey='mail_headline_expiring'; $leadKey='mail_lead_expiring';
|
||||
}
|
||||
|
||||
$subject = $translations[$subjectKey] ?? ($translations['mail_subject_default'] ?? 'Abonnement');
|
||||
$btn = $translations['mail_btn_renew'] ?? 'Renouveler';
|
||||
|
||||
$dateFinFmt = '';
|
||||
if (!empty($pr['date_expiration'])) {
|
||||
try { $dateFinFmt = (new DateTimeImmutable($pr['date_expiration']))->format($DATE_FMT_MAIL); } catch(Throwable $e){}
|
||||
}
|
||||
|
||||
$html = buildMailHtml($translations, $headlineKey, $leadKey, $dateFinFmt ?: null, $RENEW_URL, $btn);
|
||||
|
||||
$destName = trim(($pr['prenom'] ?? '').' '.($pr['nom'] ?? ''));
|
||||
if ($destName === '') $destName = ($translations['mail_default_name'] ?? 'Collègue');
|
||||
|
||||
$toEmail = (string)$pr['email'];
|
||||
$toName = $destName;
|
||||
|
||||
if (sendReminderEmailPhpMailer($toEmail, $toName, $subject, $html)) {
|
||||
$msg = ($translations['flash_envoi_ok'] ?? 'Envoyé: {EMAIL}');
|
||||
$msg = str_replace('{EMAIL}', e($pr['email']), $msg);
|
||||
$flash = ['type'=>'success','msg'=>$msg];
|
||||
} else {
|
||||
$flash = ['type'=>'danger','msg'=> e($translations['flash_envoi_echec'] ?? 'Échec envoi.')];
|
||||
}
|
||||
}
|
||||
} catch(Throwable $e) {
|
||||
$flash = ['type'=>'danger','msg'=> e($translations['flash_erreur'] ?? 'Erreur')];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Récupération liste praticiens ====================
|
||||
$sql = "
|
||||
SELECT
|
||||
pr.id, pr.titre, pr.nom, pr.prenom, pr.email, pr.photo,
|
||||
pr.date_expiration, ab.nom AS abo_nom,
|
||||
(
|
||||
SELECT p.statut FROM paiement p
|
||||
WHERE p.praticien_id = pr.id
|
||||
ORDER BY p.date_paiement DESC, p.id DESC
|
||||
LIMIT 1
|
||||
) AS pay_statut
|
||||
FROM praticien pr
|
||||
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
|
||||
ORDER BY pr.nom ASC, pr.prenom ASC
|
||||
";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ==================== Vue ====================
|
||||
?>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= e($translations['titre_page_gerer_praticiens'] ?? 'Gestion praticiens') ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
.searchbar { display:flex; gap:.5rem; align-items:center; margin-bottom:1rem; }
|
||||
.badge { display:inline-block; padding:.25rem .5rem; border-radius:9999px; font-size:.85rem; border:1px solid transparent; }
|
||||
.badge-active { background:#198754; border-color:#27ae60; color: #2e07a3ff;}
|
||||
.badge-expiring{ background:#fff8e6; border-color:#f39c12; color: #2e07a3ff; }
|
||||
.badge-expired { background:#fdecea; border-color:#c0392b; color: #2e07a3ff; }
|
||||
.minw-120{ min-width:120px; }
|
||||
.photo { width:40px; height:40px; object-fit:cover; border-radius:8px; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3 text-center"><?= e($translations['h2_gerer_abos'] ?? '👩⚕️ Rappels d’abonnement') ?></h2>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= $flash['msg'] ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="filter"
|
||||
placeholder="<?= e($translations['ph_recherche'] ?? 'Recherche (nom, prénom, e-mail, abonnement)') ?>">
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle mobile-cards" id="tab">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:56px;"></th>
|
||||
<th><?= e($translations['th_nom'] ?? 'Nom') ?></th>
|
||||
<th><?= e($translations['th_prenom'] ?? 'Prénom') ?></th>
|
||||
<th><?= e($translations['th_email'] ?? 'E-mail') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_abonnement'] ?? 'Tarif') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_statut_paiement'] ?? 'Statut paiement') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_date_fin'] ?? 'Fin') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_statut'] ?? 'Статус') ?></th>
|
||||
<th class="text-center" style="width:160px;"><?= e($translations['th_action'] ?? 'Action') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r):
|
||||
$name = trim(($r['titre'] ? $r['titre'].' ' : '').$r['prenom'].' '.$r['nom']);
|
||||
$photo = !empty($r['photo']) ? '/public/uploads/'.e($r['photo']) : '/public/uploads/img/user_placeholder.png';
|
||||
$state = computeAboState($r['date_expiration'] ?? null, $r['pay_statut'] ?? null);
|
||||
|
||||
$labelAboState = [
|
||||
'active' => $translations['statut_active'] ?? 'Active',
|
||||
'expiring' => $translations['statut_expiring'] ?? 'Bientôt',
|
||||
'expired' => $translations['statut_expired'] ?? 'Expirée',
|
||||
][$state] ?? ($translations['statut_unknown'] ?? '—');
|
||||
|
||||
$badgePay = (function($st,$t){
|
||||
if ($st === 'payé') return '<span class="badge badge-active">'.e($t['pay_ok'] ?? 'Payé').'</span>';
|
||||
if ($st === 'en_attente') return '<span class="badge badge-expiring">'.e($t['pay_wait']?? 'En attente').'</span>';
|
||||
if ($st === 'échoué') return '<span class="badge badge-expired">'.e($t['pay_ko'] ?? 'Échoué').'</span>';
|
||||
return '<span class="badge">'.e((string)$st).'</span>';
|
||||
})($r['pay_statut'] ?? '', $translations);
|
||||
|
||||
$dateFinFmt = '';
|
||||
if (!empty($r['date_expiration'])) {
|
||||
try { $dateFinFmt = (new DateTimeImmutable($r['date_expiration']))->format($DATE_FMT_TABLE); } catch(Throwable $e){}
|
||||
}
|
||||
|
||||
$hay = mb_strtolower($name.' '.($r['email']??'').' '.($r['abo_nom']??''), 'UTF-8');
|
||||
?>
|
||||
<tr data-hay="<?= e($hay) ?>">
|
||||
<td><img src="<?= $photo ?>" alt="" class="photo"></td>
|
||||
<td><?= e($r['nom']) ?></td>
|
||||
<td><?= e($r['prenom']) ?></td>
|
||||
<td><?= e($r['email']) ?></td>
|
||||
<td><?php $k=trim((string)($r['abo_nom']??'')); echo e($translations[$k] ?? $k); ?></td>
|
||||
|
||||
<td><?= $badgePay ?></td>
|
||||
<td><?= e($dateFinFmt) ?></td>
|
||||
<td>
|
||||
<?php if ($state === 'active'): ?>
|
||||
<span class="badge badge-active"><?= e($labelAboState) ?></span>
|
||||
<?php elseif ($state === 'expiring'): ?>
|
||||
<span class="badge badge-expiring"><?= e($labelAboState) ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-expired"><?= e($labelAboState) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||
<button name="do" value="send_mail" class="btn btn-sm btn-outline-primary"
|
||||
onclick="return confirm('<?= e($translations['confirm_envoyer_rappel'] ?? 'Envoyer un e-mail de rappel ?') ?>')">
|
||||
<?= e($translations['btn_envoyer_rappel'] ?? 'Envoyer rappel') ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="9" class="text-muted"><?= e($translations['aucun_praticien'] ?? 'Aucun praticien.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Filtre client simple
|
||||
document.getElementById('filter').addEventListener('input', function(){
|
||||
const q = (this.value || '').trim().toLowerCase();
|
||||
document.querySelectorAll('#tab tbody tr').forEach(tr=>{
|
||||
const hay = (tr.dataset.hay || '').toLowerCase();
|
||||
tr.style.display = !q || hay.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* /admin/tools/logs.php
|
||||
* Viewer de logs "admin-friendly" :
|
||||
* - Affiche /logs/php_errors.log (et /logs/app.log si présent)
|
||||
* - Tail (dernieres N lignes), Download, Clear (truncate)
|
||||
* - CSRF + garde-fous; pas de fonctions globales (closures only)
|
||||
* - S'affiche uniquement sur ?page=logs
|
||||
*/
|
||||
|
||||
if (($_GET['page'] ?? '') !== 'logs') { return; }
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
// Lang (pour $translations)
|
||||
require_once __DIR__ . '/../../public/includes/lang.php';
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['purge_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; }
|
||||
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
$logsDir = $siteRoot . '/logs';
|
||||
@is_dir($logsDir) || @mkdir($logsDir, 0755, true);
|
||||
|
||||
// Fichiers autorisés
|
||||
$files = [
|
||||
'php_errors' => $logsDir . '/php_errors.log',
|
||||
'app' => $logsDir . '/app.log',
|
||||
];
|
||||
// Sélection
|
||||
$key = $_GET['file'] ?? 'php_errors';
|
||||
if (!isset($files[$key])) { $key = 'php_errors'; }
|
||||
$path = $files[$key];
|
||||
|
||||
// Actions pré-dispatch
|
||||
$op = $_GET['op'] ?? '';
|
||||
if ($op === 'download' && hash_equals($CSRF, $_GET['csrf'] ?? '')) {
|
||||
if (is_file($path) && is_readable($path)) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="'.$key.'.log"');
|
||||
readfile($path);
|
||||
} else {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo (string)($translations['purge_logs_file_unavailable_plain'] ?? 'Fichier indisponible.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'truncate') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. $e($translations['purge_csrf_invalid'] ?? 'Token CSRF invalide.')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
if (is_file($path) && is_writable($path)) {
|
||||
$fp = @fopen($path, 'w'); if ($fp) { fclose($fp); }
|
||||
} else if (!file_exists($path)) {
|
||||
// crée le fichier si absent
|
||||
@touch($path);
|
||||
}
|
||||
// redirection douce (évite le repost)
|
||||
$qs = http_build_query(['page'=>'logs','file'=>$key]);
|
||||
header('Location: ?'.$qs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
$humanSize = static function(int $bytes): string {
|
||||
$units = ['B','KB','MB','GB','TB']; $i=0;
|
||||
while ($bytes >= 1024 && $i < count($units)-1) { $bytes/=1024; $i++; }
|
||||
return number_format($bytes, ($i?1:0)).' '.$units[$i];
|
||||
};
|
||||
$tail = static function(string $file, int $lines=300, int $maxBytes=1048576): string {
|
||||
if (!is_file($file) || !is_readable($file)) return '';
|
||||
$size = filesize($file);
|
||||
if ($size === 0) return '';
|
||||
$bytesToRead = min($size, $maxBytes);
|
||||
$fh = fopen($file, 'rb');
|
||||
if (!$fh) return '';
|
||||
// positionne le pointeur en fin-moins-bytesToRead
|
||||
fseek($fh, -$bytesToRead, SEEK_END);
|
||||
$data = fread($fh, $bytesToRead);
|
||||
fclose($fh);
|
||||
// Normalise fin de fichier + découpe
|
||||
$data = ltrim($data); // évite premier morceau tronqué
|
||||
$linesArr = preg_split('/\R/', $data);
|
||||
$linesArr = array_slice($linesArr, -$lines);
|
||||
return implode("\n", $linesArr);
|
||||
};
|
||||
|
||||
$exists = is_file($path);
|
||||
$readable = $exists && is_readable($path);
|
||||
$writable = $exists ? is_writable($path) : is_writable(dirname($path));
|
||||
$size = $exists ? filesize($path) : 0;
|
||||
$mtime = $exists ? date('Y-m-d H:i:s', filemtime($path)) : '—';
|
||||
|
||||
$content = $readable ? $tail($path, (int)($_GET['n'] ?? 300)) : '';
|
||||
?>
|
||||
<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_logs_title'] ?? 'Journaux & erreurs') ?></h3>
|
||||
<span class="badge text-bg-<?= $readable ? 'success':'secondary' ?>">
|
||||
<?= $readable ? $e($translations['purge_logs_readable'] ?? 'Lisible') : $e($translations['purge_logs_unavailable'] ?? 'Indisponible') ?>
|
||||
</span>
|
||||
<span class="badge text-bg-info">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_file_badge'] ?? 'Fichier: {KEY}');
|
||||
echo $e(str_replace('{KEY}', (string)$key, $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<input type="hidden" name="page" value="logs">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label"><?= $e($translations['purge_logs_select_label'] ?? 'Sélection du log') ?></label>
|
||||
<select class="form-select" name="file" onchange="this.form.submit()">
|
||||
<?php foreach ($files as $k=>$p): ?>
|
||||
<option value="<?= $e($k) ?>" <?= $k===$key?'selected':'' ?>>
|
||||
<?= $e($k) ?> (
|
||||
<?= is_file($p)
|
||||
? $e($humanSize(filesize($p)))
|
||||
: $e($translations['purge_logs_absent'] ?? 'absent')
|
||||
?>
|
||||
)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label"><?= $e($translations['purge_logs_last_lines_label'] ?? 'Dernières lignes') ?></label>
|
||||
<input type="number" min="50" max="5000" step="50" name="n" class="form-control" value="<?= (int)($_GET['n'] ?? 300) ?>">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex align-items-end gap-2">
|
||||
<a class="btn btn-outline-primary"
|
||||
href="/admin/tools/logs_download.php?file=<?= $e($key) ?>&csrf=<?= $e($CSRF) ?>">
|
||||
<?= $e($translations['purge_logs_btn_download_short'] ?? 'Téléchar.') ?>
|
||||
</a>
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
<?= $e($translations['purge_logs_btn_refresh'] ?? 'Actualiser') ?>
|
||||
</button>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
<button formaction="?page=logs&file=<?= $e($key) ?>" name="action" value="truncate"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('<?= $e($translations['purge_logs_confirm_truncate'] ?? 'Vider le contenu de ce log ?') ?>');"
|
||||
<?= $writable ? '' : 'disabled' ?>>
|
||||
<?= $e($translations['purge_logs_btn_truncate'] ?? 'Vider') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_footer_path'] ?? 'Emplacement: {PATH}');
|
||||
echo $e(str_replace('{PATH}', (string)$path, $tpl));
|
||||
?>
|
||||
—
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_footer_size'] ?? 'Taille: {SIZE}');
|
||||
echo $e(str_replace('{SIZE}', (string)$humanSize($size), $tpl));
|
||||
?>
|
||||
—
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_footer_mtime'] ?? 'Dernière modif: {TIME}');
|
||||
echo $e(str_replace('{TIME}', (string)$mtime, $tpl));
|
||||
?>
|
||||
<?php if (!$exists): ?>
|
||||
— <em><?= $e($translations['purge_logs_footer_missing_note'] ?? 'Le fichier n’existe pas (il sera créé au premier log).') ?></em>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><?= $e($translations['purge_logs_preview_header'] ?? 'Aperçu (tail)') ?></div>
|
||||
<div class="card-body">
|
||||
<?php if (!$readable): ?>
|
||||
<div class="text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_no_content_rights_hint'] ?? 'Aucun contenu disponible. Vérifie les droits sur <code>{DIR}</code> (0755) et le fichier (0644).');
|
||||
echo str_replace('{DIR}', $e($logsDir), $tpl);
|
||||
?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<pre class="small mb-0" style="white-space:pre-wrap;"><?= $e($content ?: ($translations['purge_logs_empty'] ?? '(vide)')) ?></pre>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// /admin/tools/logs_download.php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
// 1) Sécurité: admin requis
|
||||
if (empty($_SESSION['role']) || !in_array('admin', (array)$_SESSION['role'], true)) {
|
||||
http_response_code(403);
|
||||
echo 'Forbidden';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2) CSRF GET
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_GET['csrf'] ?? '')) {
|
||||
http_response_code(400);
|
||||
echo 'CSRF invalid';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3) Map fichiers autorisés
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
$logsDir = $siteRoot . '/logs';
|
||||
$map = [
|
||||
'php_errors' => $logsDir . '/php_errors.log',
|
||||
'app' => $logsDir . '/app.log',
|
||||
];
|
||||
$key = $_GET['file'] ?? 'php_errors';
|
||||
$path = $map[$key] ?? $map['php_errors'];
|
||||
|
||||
// 4) S'assurer que le dossier/fichier existe
|
||||
if (!is_dir($logsDir)) { @mkdir($logsDir, 0755, true); }
|
||||
if (!file_exists($path)) { @touch($path); } // crée un .log vide si absent
|
||||
|
||||
// 5) Lecture sûre
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
http_response_code(404);
|
||||
echo 'Log file not found or unreadable';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6) Purge des buffers et envoi
|
||||
while (ob_get_level()) { ob_end_clean(); }
|
||||
|
||||
$size = filesize($path) ?: 0;
|
||||
$filename = $key.'_'.date('Ymd_His').'.log';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="'.$filename.'"');
|
||||
header('Content-Length: '.$size);
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$fp = fopen($path, 'rb');
|
||||
if ($fp) { fpassthru($fp); fclose($fp); } else { readfile($path); }
|
||||
exit;
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
exit;
|
||||
}
|
||||
$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; }
|
||||
|
||||
|
||||
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
// --------- Template HTML du mail ---------
|
||||
function buildAdminMailHtml(string $subject, string $bodyHtml): string {
|
||||
global $translations;
|
||||
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brandBas = $translations['mailing_praticiens_brand_bas'] ?? 'УСЛУГИ.ФР';
|
||||
$ctaUrl = 'https://uslugi.fr/';
|
||||
$ctaLabel = $translations['mailing_praticiens_cta_open_site'] ?? 'Открыть сайт';
|
||||
$thanks = $translations['mailing_praticiens_footer_thanks'] ?? 'Спасибо';
|
||||
|
||||
return '<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>'.e($subject).'</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#38378B;color:#fff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 24px 8px 24px;color:#111;font-size:16px;line-height:1.6;">'.$bodyHtml.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:8px 24px 24px 24px;">
|
||||
<a href="'.e($ctaUrl).'" target="_blank"
|
||||
style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">
|
||||
'.e($ctaLabel).'
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="margin-top:12px;">
|
||||
<tr>
|
||||
<td style="text-align:center;color:#9ca3af;font-size:12px;padding:8px 0;">© '.date('Y').' '.$brandBas.' — '.e($thanks).'</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
function sendPraticienMail(string $to, string $subject, string $html): bool {
|
||||
// Optionnel: définir From/Reply-To ici, sinon mail_config.php fait foi
|
||||
[$ok, $err] = uslugi_mail($to, $subject, $html, null, [
|
||||
// 'from' => ['no-reply@uslugi.fr', 'USLUGI.FR'],
|
||||
// 'reply_to' => ['support@uslugi.fr', 'USLUGI.FR'],
|
||||
// 'headers' => ['X-USLUGI-Source' => 'admin_mailing_praticiens'],
|
||||
]);
|
||||
|
||||
if (!$ok && $err) {
|
||||
error_log('mailing_praticiens send error to '.$to.' : '.$err);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// --------- Traitement POST ---------
|
||||
$flash = null;
|
||||
|
||||
if (($_POST['do'] ?? '') === 'send_mailing') {
|
||||
try {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
throw new Exception('CSRF');
|
||||
}
|
||||
|
||||
$subject = trim($_POST['subject'] ?? '');
|
||||
$body = trim($_POST['body'] ?? '');
|
||||
$ids = $_POST['recipients'] ?? [];
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
throw new Exception('EMPTY');
|
||||
}
|
||||
|
||||
if (!is_array($ids) || count($ids) === 0) {
|
||||
throw new Exception('NO_RECIPIENTS');
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0)));
|
||||
if (!$ids) {
|
||||
throw new Exception('NO_RECIPIENTS');
|
||||
}
|
||||
|
||||
// Récupérer les emails
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$sql = "SELECT id, prenom, nom, email FROM praticien WHERE id IN ($in) AND email <> ''";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute($ids);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$rows) {
|
||||
throw new Exception('NO_EMAILS');
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$total = count($rows);
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$email = trim($r['email'] ?? '');
|
||||
if ($email === '') continue;
|
||||
|
||||
// On laisse TinyMCE produire le HTML, on l’utilise tel quel
|
||||
$html = buildAdminMailHtml($subject, $body);
|
||||
if (sendPraticienMail($email, $subject, $html)) {
|
||||
$sent++;
|
||||
}
|
||||
// usleep(100000); // 0.1s
|
||||
}
|
||||
|
||||
$flash = [
|
||||
'type' => 'success',
|
||||
'msg' => ($translations['mailing_praticiens_sent_count'] ?? 'Отправлено писем: %d / %d')
|
||||
? sprintf((string)($translations['mailing_praticiens_sent_count'] ?? 'Отправлено писем: %d / %d'), $sent, $total)
|
||||
: ('Отправлено писем: '.$sent.' / '.$total)
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$code = $e->getMessage();
|
||||
$msg = $translations['mailing_praticiens_err_send'] ?? 'Ошибка при отправке.';
|
||||
|
||||
if ($code === 'EMPTY') $msg = $translations['mailing_praticiens_err_empty'] ?? 'Тема и текст письма обязательны.';
|
||||
if ($code === 'NO_RECIPIENTS') $msg = $translations['mailing_praticiens_err_no_recipients'] ?? 'Выберите хотя бы одного практикующего.';
|
||||
if ($code === 'CSRF') $msg = $translations['mailing_praticiens_err_csrf'] ?? 'Ошибка безопасности. Обновите страницу.';
|
||||
|
||||
$flash = ['type'=>'danger','msg'=>$msg];
|
||||
}
|
||||
}
|
||||
|
||||
// --------- Récupération des praticiens ---------
|
||||
$sql = "
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.nom,
|
||||
pr.prenom,
|
||||
pr.email,
|
||||
pr.abonnement_id,
|
||||
pr.date_expiration,
|
||||
ab.nom AS abo_nom
|
||||
FROM praticien pr
|
||||
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
|
||||
ORDER BY pr.nom ASC, pr.prenom ASC
|
||||
";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Préparer options de filtre par abonnement
|
||||
$aboOptions = [];
|
||||
foreach ($rows as $r) {
|
||||
if (!empty($r['abonnement_id'])) {
|
||||
$aid = (int)$r['abonnement_id'];
|
||||
$aboOptions[$aid] = $r['abo_nom'] ?: (($translations['mailing_praticiens_tarif_id'] ?? 'ID %d') ? sprintf((string)($translations['mailing_praticiens_tarif_id'] ?? 'ID %d'), $aid) : ('ID '.$aid));
|
||||
}
|
||||
}
|
||||
ksort($aboOptions);
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3"><?= e($translations['mailing_praticiens_title'] ?? 'Массовая рассылка практикующим') ?></h2>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="mailingForm">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="do" value="send_mailing">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_subject_label'] ?? 'Тема письма') ?></label>
|
||||
<input type="text" name="subject" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_body_label'] ?? 'Текст письма') ?></label>
|
||||
<textarea id="mailBody" name="body" class="form-control" rows="8"></textarea>
|
||||
<div class="form-text">
|
||||
<?= e($translations['mailing_praticiens_body_help'] ?? 'Текст будет отправлен как HTML-письмо с шапкой USLUGI.FR и красной кнопкой «Открыть сайт».') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h4 class="mb-3"><?= e($translations['mailing_praticiens_list_title'] ?? 'Список практикующих') ?></h4>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_filter_status'] ?? 'Фильтр по статусу') ?></label>
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="all"><?= e($translations['mailing_praticiens_status_all'] ?? 'Все практикующие') ?></option>
|
||||
<option value="active"><?= e($translations['mailing_praticiens_status_active'] ?? 'С активной подпиской') ?></option>
|
||||
<option value="expired"><?= e($translations['mailing_praticiens_status_expired'] ?? 'С истекшей подпиской') ?></option>
|
||||
<option value="noabo"><?= e($translations['mailing_praticiens_status_noabo'] ?? 'Без подписки') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_filter_tarif'] ?? 'Фильтр по тарифу') ?></label>
|
||||
<select id="filter_abo" class="form-select">
|
||||
<option value=""><?= e($translations['mailing_praticiens_tarif_all'] ?? 'Все тарифы') ?></option>
|
||||
<?php foreach ($aboOptions as $aid => $name): ?>
|
||||
<option value="<?= (int)$aid ?>">
|
||||
<?= e($translations[$name] ?? $name) ?>
|
||||
(<?= e(sprintf(
|
||||
$translations['mailing_praticiens_tarif_id_label'] ?? 'ID %d',
|
||||
(int)$aid
|
||||
)) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_search_label'] ?? 'Поиск') ?></label>
|
||||
<input type="text" id="filter_search" class="form-control" placeholder="<?= e($translations['mailing_praticiens_search_ph'] ?? 'Имя, фамилия, email...') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="chk_all" class="form-check-input">
|
||||
<label for="chk_all" class="form-check-label"><?= e($translations['mailing_praticiens_select_all_visible'] ?? 'Выбрать всех в текущем списке') ?></label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger" style="border-radius:999px;">
|
||||
<?= e($translations['mailing_praticiens_send_btn'] ?? 'Отправить письмо выбранным') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle" id="tabPraticiens">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_prenom'] ?? 'Имя') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_nom'] ?? 'Фамилия') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_email'] ?? 'Email') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_tarif'] ?? 'Тариф') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_exp'] ?? 'Окончание') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r):
|
||||
$expRaw = $r['date_expiration'] ?? null;
|
||||
$status = 'noabo';
|
||||
$dateFmt = '';
|
||||
|
||||
if ($expRaw) {
|
||||
try {
|
||||
$exp = new DateTimeImmutable($expRaw);
|
||||
$dateFmt = $exp->format('Y-m-d H:i');
|
||||
$status = ($exp < $today) ? 'expired' : 'active';
|
||||
} catch (Throwable $e) {
|
||||
$status = 'noabo';
|
||||
}
|
||||
}
|
||||
|
||||
$hay = mb_strtolower(
|
||||
trim(($r['prenom'] ?? '').' '.($r['nom'] ?? '').' '.($r['email'] ?? '').' '.($r['abo_nom'] ?? '')),
|
||||
'UTF-8'
|
||||
);
|
||||
$aboId = (int)($r['abonnement_id'] ?? 0);
|
||||
?>
|
||||
<tr data-status="<?= e($status) ?>" data-abo="<?= $aboId ?: '' ?>" data-hay="<?= e($hay) ?>">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
class="form-check-input row-chk"
|
||||
name="recipients[]"
|
||||
value="<?= (int)$r['id'] ?>">
|
||||
</td>
|
||||
<td><?= e($r['prenom']) ?></td>
|
||||
<td><?= e($r['nom']) ?></td>
|
||||
<td><?= e($r['email']) ?></td>
|
||||
<td><?php $k=trim((string)($r['abo_nom']??'')); echo e($translations[$k] ?? $k); ?></td>
|
||||
<td><?= e($dateFmt) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="6" class="text-muted text-center"><?= e($translations['mailing_praticiens_none'] ?? 'Нет практикующих.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- TinyMCE -->
|
||||
<script src="https://cdn.tiny.cloud/1/l9t0aozxqrwrd6cv3gbopqgt7bi299cjtwwxvyhylwa1kjey/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
|
||||
<script>
|
||||
tinymce.init({
|
||||
selector: '#mailBody',
|
||||
menubar: false,
|
||||
height: 260,
|
||||
language: 'ru',
|
||||
plugins: 'link lists',
|
||||
toolbar: 'undo redo | bold italic underline | bullist numlist | link removeformat',
|
||||
branding: false
|
||||
});
|
||||
|
||||
// Filtres JS
|
||||
(function(){
|
||||
const statusSel = document.getElementById('filter_status');
|
||||
const aboSel = document.getElementById('filter_abo');
|
||||
const searchInp = document.getElementById('filter_search');
|
||||
const rows = Array.from(document.querySelectorAll('#tabPraticiens tbody tr'));
|
||||
const chkAll = document.getElementById('chk_all');
|
||||
|
||||
function applyFilters(){
|
||||
const st = statusSel.value;
|
||||
const abo = aboSel.value;
|
||||
const q = (searchInp.value || '').trim().toLowerCase();
|
||||
|
||||
rows.forEach(tr => {
|
||||
let ok = true;
|
||||
const trStatus = tr.dataset.status || '';
|
||||
const trAbo = tr.dataset.abo || '';
|
||||
const hay = (tr.dataset.hay || '').toLowerCase();
|
||||
|
||||
if (st !== 'all' && trStatus !== st) ok = false;
|
||||
if (abo && trAbo !== abo) ok = false;
|
||||
if (q && !hay.includes(q)) ok = false;
|
||||
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
statusSel.addEventListener('change', applyFilters);
|
||||
aboSel.addEventListener('change', applyFilters);
|
||||
searchInp.addEventListener('input', applyFilters);
|
||||
|
||||
// Sélectionner tous les visibles
|
||||
if (chkAll) {
|
||||
chkAll.addEventListener('change', function(){
|
||||
const checked = this.checked;
|
||||
rows.forEach(tr => {
|
||||
if (tr.style.display === 'none') return;
|
||||
const cb = tr.querySelector('.row-chk');
|
||||
if (cb) cb.checked = checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
if (($_GET['page'] ?? '') !== 'maintenance') return;
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'.htmlspecialchars(($translations['non_autorise'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8').'</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
|
||||
$statDir = function($path){
|
||||
$n=0; $s=0;
|
||||
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS));
|
||||
foreach($it as $f){
|
||||
if($f->isFile()){ $n++; $s += $f->getSize(); }
|
||||
}
|
||||
return [$n,$s];
|
||||
};
|
||||
|
||||
$uploads = $siteRoot.'/messages/uploads';
|
||||
$backups = $siteRoot.'/backups/db';
|
||||
$logsDir = $siteRoot.'/logs';
|
||||
@is_dir($backups) || @mkdir($backups,0755,true);
|
||||
|
||||
$uploadsStat = is_dir($uploads) ? $statDir($uploads) : [0,0];
|
||||
$backupsList = array_map('basename', glob($backups.'/db_*.sql*') ?: []);
|
||||
usort($backupsList, fn($a,$b)=>strcmp($b,$a));
|
||||
$lastBackup = $backupsList[0] ?? ($translations['maint_none'] ?? '(aucun)');
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<h3 class="mb-3"><?= $e($translations['maintenance'] ?? 'Centre de maintenance') ?></h3>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['backup_db'] ?? 'Sauvegardes DB') ?></h5>
|
||||
<p class="mb-1">
|
||||
<strong><?= $e($translations['maint_last_backup'] ?? 'Dernier backup:') ?></strong>
|
||||
<?= $e($lastBackup) ?>
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
<?= $e($translations['maint_folder'] ?? 'Dossier:') ?>
|
||||
<code><?= $e($backups) ?></code>
|
||||
</p>
|
||||
<a class="btn btn-dark" href="?page=backup_db">
|
||||
<?= $e($translations['maint_manage_backups'] ?? 'Gérer les sauvegardes') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['maint_uploads_messaging'] ?? 'Uploads messagerie') ?></h5>
|
||||
<p class="mb-1">
|
||||
<strong><?= $e($translations['maint_files'] ?? 'Fichiers:') ?></strong>
|
||||
<?= (int)$uploadsStat[0] ?>
|
||||
— <strong><?= $e($translations['maint_size'] ?? 'Taille:') ?></strong>
|
||||
<?= number_format($uploadsStat[1]/1024/1024, 2) ?> MB
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
<?= $e($translations['maint_folder'] ?? 'Dossier:') ?>
|
||||
<code><?= $e($uploads) ?></code>
|
||||
</p>
|
||||
<a class="btn btn-outline-dark" href="?page=purger_mess_upload">
|
||||
<?= $e($translations['purger_mess_upload'] ?? 'Purger orphelins') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['purger_messages'] ?? 'Conversations supprimées') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_purge_conversations_help'] ?? 'Supprimer définitivement les conversations retirées des deux côtés.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=purge_conversations">
|
||||
<?= $e($translations['maint_open'] ?? 'Ouvrir') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['afficher_logs'] ?? 'Journaux & erreurs') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_logs_help'] ?? 'Consulter, télécharger, vider, appliquer la rotation.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=logs">
|
||||
<?= $e($translations['maint_view_logs'] ?? 'Voir les logs') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['cleanup_rdv'] ?? 'Nettoyage RDV') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_cleanup_rdv_help'] ?? 'Lister & purger les rendez-vous non désirés.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=cleanup_rdv">
|
||||
<?= $e($translations['maint_open'] ?? 'Ouvrir') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['audit'] ?? 'Journal d’audit') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_audit_help'] ?? 'Historique des actions administrateur.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=audit">
|
||||
<?= $e($translations['maint_consult'] ?? 'Consulter') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,552 @@
|
||||
<?php
|
||||
// admin/tools/new_clients.php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// ID "super admin" (как в add_video.php)
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
$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
|
||||
// DB
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// helpers
|
||||
function e($s) {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// ---- Параметры фильтрации ----
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
$role = $_GET['role'] ?? 'all'; // all|client|praticien|secretaire
|
||||
$period = $_GET['period'] ?? 'today'; // today|week|month|30days|custom
|
||||
$dateFrom = trim($_GET['date_from'] ?? '');
|
||||
$dateTo = trim($_GET['date_to'] ?? '');
|
||||
|
||||
// ❗ Pagination: utiliser "p" pour НЕ конфликтовать с ?page=new_inscrits (router)
|
||||
$pageNum = max(1, (int)($_GET['p'] ?? 1));
|
||||
$perPage = 25;
|
||||
|
||||
$validRoles = ['client', 'praticien', 'secretaire'];
|
||||
|
||||
// Период: если указаны даты "С/По", считаем custom
|
||||
if ($dateFrom !== '' || $dateTo !== '') {
|
||||
$period = 'custom';
|
||||
}
|
||||
|
||||
// Диапазон дат по cree_le
|
||||
$now = new DateTimeImmutable('now');
|
||||
|
||||
switch ($period) {
|
||||
case 'today':
|
||||
default:
|
||||
$start = (new DateTimeImmutable('today'))->setTime(0,0,0);
|
||||
$end = $start->modify('+1 day');
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
$start = (new DateTimeImmutable('monday this week'))->setTime(0,0,0);
|
||||
$end = $start->modify('+1 week');
|
||||
break;
|
||||
|
||||
case 'month':
|
||||
$start = (new DateTimeImmutable('first day of this month'))->setTime(0,0,0);
|
||||
$end = $start->modify('first day of next month');
|
||||
break;
|
||||
|
||||
case '30days':
|
||||
$end = $now->setTime(23,59,59);
|
||||
$start = $end->modify('-30 days')->setTime(0,0,0);
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
if ($dateFrom !== '') {
|
||||
$tmp = DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom) ?: new DateTimeImmutable('today');
|
||||
$start = $tmp->setTime(0,0,0);
|
||||
} else {
|
||||
$start = (new DateTimeImmutable('2000-01-01'))->setTime(0,0,0);
|
||||
}
|
||||
if ($dateTo !== '') {
|
||||
$tmp = DateTimeImmutable::createFromFormat('Y-m-d', $dateTo) ?: $now;
|
||||
$end = $tmp->modify('+1 day')->setTime(0,0,0);
|
||||
} else {
|
||||
$end = $now->modify('+1 day')->setTime(0,0,0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$dtFrom = $start->format('Y-m-d H:i:s');
|
||||
$dtTo = $end->format('Y-m-d H:i:s');
|
||||
|
||||
// ---- Сортировка ----
|
||||
$allowedSort = [
|
||||
'nom' => 'nom',
|
||||
'prenom' => 'prenom',
|
||||
'email' => 'email',
|
||||
'telephone' => 'telephone',
|
||||
'role' => 'role',
|
||||
'cree_le' => 'cree_le',
|
||||
];
|
||||
|
||||
$sort = $_GET['sort'] ?? 'cree_le';
|
||||
$dir = strtolower($_GET['dir'] ?? 'desc');
|
||||
|
||||
if (!isset($allowedSort[$sort])) {
|
||||
$sort = 'cree_le';
|
||||
}
|
||||
if (!in_array($dir, ['asc','desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$orderBySql = $allowedSort[$sort] . ' ' . strtoupper($dir);
|
||||
|
||||
// ---- WHERE + параметры ----
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// только client, praticien, secretaire
|
||||
$where[] = "c.role IN ('client','praticien','secretaire')";
|
||||
|
||||
if (in_array($role, $validRoles, true)) {
|
||||
$where[] = "c.role = :role";
|
||||
$params[':role'] = $role;
|
||||
}
|
||||
|
||||
// поиск по строке
|
||||
if ($q !== '') {
|
||||
$where[] = "(
|
||||
COALESCE(p.nom, cl.nom, '') LIKE :q_nom
|
||||
OR COALESCE(p.prenom, cl.prenom, '') LIKE :q_prenom
|
||||
OR c.email LIKE :q_email
|
||||
OR COALESCE(p.telephone, cl.telephone, '') LIKE :q_tel
|
||||
)";
|
||||
|
||||
$like = '%' . $q . '%';
|
||||
$params[':q_nom'] = $like;
|
||||
$params[':q_prenom'] = $like;
|
||||
$params[':q_email'] = $like;
|
||||
$params[':q_tel'] = $like;
|
||||
|
||||
// 👉 Опция: когда ищем по строке, НЕ ограничиваем по дате
|
||||
} else {
|
||||
// фильтр по периоду только если нет q
|
||||
$where[] = "c.cree_le >= :from AND c.cree_le < :to";
|
||||
$params[':from'] = $dtFrom;
|
||||
$params[':to'] = $dtTo;
|
||||
}
|
||||
|
||||
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
||||
|
||||
// ---- Подсчет общего количества ----
|
||||
$sqlCount = "
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM compte c
|
||||
LEFT JOIN praticien p ON (c.role IN ('praticien','secretaire') AND c.reference_id = p.id)
|
||||
LEFT JOIN client cl ON (c.role = 'client' AND c.reference_id = cl.id)
|
||||
$whereSql
|
||||
";
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare($sqlCount);
|
||||
foreach ($params as $k=>$v) {
|
||||
$st->bindValue($k, $v);
|
||||
}
|
||||
$st->execute();
|
||||
$totalRows = (int)$st->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
echo '<div class="alert alert-danger">'.e($translations['admin_new_clients_sql_err_count'] ?? 'Ошибка SQL (count): ').e($e->getMessage()).'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$totalPages = max(1, (int)ceil($totalRows / $perPage));
|
||||
if ($pageNum > $totalPages) {
|
||||
$pageNum = $totalPages;
|
||||
}
|
||||
$offset = ($pageNum - 1) * $perPage;
|
||||
|
||||
// ---- Основной SELECT ----
|
||||
$sql = "
|
||||
SELECT
|
||||
c.id AS compte_id,
|
||||
c.email,
|
||||
c.role,
|
||||
c.cree_le,
|
||||
c.email_valide,
|
||||
c.token_validation,
|
||||
COALESCE(p.nom, cl.nom) AS nom,
|
||||
COALESCE(p.prenom, cl.prenom) AS prenom,
|
||||
COALESCE(p.telephone, cl.telephone) AS telephone,
|
||||
CASE
|
||||
WHEN c.role='praticien' THEN p.accepted_cgu
|
||||
WHEN c.role='client' THEN cl.accepted_cgu
|
||||
ELSE NULL
|
||||
END AS accepted_cgu
|
||||
FROM compte c
|
||||
LEFT JOIN praticien p ON (c.role IN ('praticien','secretaire') AND c.reference_id = p.id)
|
||||
LEFT JOIN client cl ON (c.role = 'client' AND c.reference_id = cl.id)
|
||||
$whereSql
|
||||
ORDER BY $orderBySql
|
||||
LIMIT :limit OFFSET :offset
|
||||
";
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare($sql);
|
||||
foreach ($params as $k=>$v) {
|
||||
$st->bindValue($k, $v);
|
||||
}
|
||||
$st->bindValue(':limit', (int)$perPage, PDO::PARAM_INT);
|
||||
$st->bindValue(':offset', (int)$offset, PDO::PARAM_INT);
|
||||
$st->execute();
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) {
|
||||
echo '<div class="alert alert-danger">'.e($translations['admin_new_clients_sql_err_select'] ?? 'Ошибка SQL (select): ').e($e->getMessage()).'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// параметры для ссылок сортировки/пагинации (без sort/dir/p)
|
||||
$baseParams = [
|
||||
'q' => $q,
|
||||
'role' => $role,
|
||||
'period' => $period,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
];
|
||||
|
||||
// helper для построения URL сортировки
|
||||
function sortLink($field, $label, $currentSort, $currentDir, $extraParams) {
|
||||
$params = $extraParams;
|
||||
$params['sort'] = $field;
|
||||
if ($currentSort === $field) {
|
||||
$params['dir'] = ($currentDir === 'asc') ? 'desc' : 'asc';
|
||||
} else {
|
||||
$params['dir'] = 'asc';
|
||||
}
|
||||
|
||||
$url = admin_url('new_inscrits', $params);
|
||||
|
||||
$arrow = '';
|
||||
if ($currentSort === $field) {
|
||||
$arrow = $currentDir === 'asc' ? ' ▲' : ' ▼';
|
||||
}
|
||||
|
||||
return '<a href="'.e($url).'">'.e($label.$arrow).'</a>';
|
||||
}
|
||||
?>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3"><?= e($translations['admin_new_clients_title'] ?? 'Новые регистрации') ?></h2>
|
||||
|
||||
<form class="row g-3 mb-3" method="get" action="index.php">
|
||||
<input type="hidden" name="page" value="new_inscrits">
|
||||
<input type="hidden" name="period" value="<?= e($period) ?>">
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_search_label'] ?? 'Поиск') ?></label>
|
||||
<input type="text" name="q" class="form-control"
|
||||
placeholder="<?= e($translations['admin_new_clients_search_ph'] ?? 'Имя, фамилия, e-mail, телефон…') ?>"
|
||||
value="<?= e($q) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_role_label'] ?? 'Роль') ?></label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="all" <?= $role==='all' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_all'] ?? 'Все') ?></option>
|
||||
<option value="client" <?= $role==='client' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_clients'] ?? 'Клиенты') ?></option>
|
||||
<option value="praticien" <?= $role==='praticien' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_praticiens'] ?? 'Специалисты') ?></option>
|
||||
<option value="secretaire" <?= $role==='secretaire' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_secretaires'] ?? 'Секретари') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<br><br>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label d-block"><?= e($translations['admin_new_clients_period_label'] ?? 'Период') ?></label>
|
||||
<div class="btn-group" role="group">
|
||||
<?php
|
||||
$periods = [
|
||||
'today' => ($translations['admin_new_clients_period_today'] ?? 'Сегодня'),
|
||||
'week' => ($translations['admin_new_clients_period_week'] ?? 'Неделя'),
|
||||
'month' => ($translations['admin_new_clients_period_month'] ?? 'Месяц'),
|
||||
'30days' => ($translations['admin_new_clients_period_30days'] ?? '30 дней'),
|
||||
];
|
||||
foreach ($periods as $key=>$lbl):
|
||||
$btnParams = $baseParams;
|
||||
$btnParams['period'] = $key;
|
||||
$btnParams['p'] = 1;
|
||||
$url = admin_url('new_inscrits', $btnParams);
|
||||
?>
|
||||
<a href="<?= e($url) ?>"
|
||||
class="btn btn-sm <?= $period === $key ? 'btn-danger' : 'btn-outline-danger' ?>">
|
||||
<?= e($lbl) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_date_from'] ?? 'С даты') ?></label>
|
||||
<input type="date" name="date_from" class="form-control"
|
||||
value="<?= e($dateFrom) ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_date_to'] ?? 'По дату') ?></label>
|
||||
<input type="date" name="date_to" class="form-control"
|
||||
value="<?= e($dateTo) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-danger me-2" style="border-radius:999px;" type="submit">
|
||||
<?= e($translations['admin_new_clients_apply'] ?? 'Применить') ?>
|
||||
</button>
|
||||
<?php
|
||||
$resetUrl = admin_url('new_inscrits', [
|
||||
'period' => 'today',
|
||||
'role' => 'all',
|
||||
'p' => 1,
|
||||
]);
|
||||
?>
|
||||
<a href="<?= e($resetUrl) ?>" class="btn btn-outline-secondary" style="border-radius:999px;">
|
||||
<?= e($translations['admin_new_clients_reset'] ?? 'Сброс') ?>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-2 text-muted">
|
||||
<?= e($translations['admin_new_clients_found'] ?? 'Найдено записей:') ?>
|
||||
<strong><?= (int)$totalRows ?></strong>.
|
||||
<?= e($translations['admin_new_clients_page'] ?? 'Страница') ?>
|
||||
<?= (int)$pageNum ?>
|
||||
<?= e($translations['admin_new_clients_page_of'] ?? 'из') ?>
|
||||
<?= (int)$totalPages ?>.
|
||||
</div>
|
||||
|
||||
<style>
|
||||
table.table thead th a {
|
||||
color: #38378B;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
table.table td a {
|
||||
color: #38378B;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
table.table thead th a:hover {
|
||||
color: #E03C3F;
|
||||
}
|
||||
table.table td a:hover {
|
||||
color: #E03C3F;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><?= sortLink('nom', ($translations['admin_new_clients_th_nom'] ?? 'Фамилия'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('prenom', ($translations['admin_new_clients_th_prenom'] ?? 'Имя'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('email', ($translations['admin_new_clients_th_email'] ?? 'E-mail'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('telephone', ($translations['admin_new_clients_th_telephone'] ?? 'Телефон'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('role', ($translations['admin_new_clients_th_role'] ?? 'Роль'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('cree_le', ($translations['admin_new_clients_th_cree_le'] ?? 'Дата регистрации'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= e($translations['admin_new_clients_th_email_status'] ?? 'E-mail') ?></th>
|
||||
<th><?= e($translations['admin_new_clients_th_cgu'] ?? 'CGU') ?></th>
|
||||
<th style="width:120px;"><?= e($translations['admin_new_clients_th_actions'] ?? 'Действия') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted py-4">
|
||||
<?= e($translations['admin_new_clients_empty'] ?? 'Нет регистраций за выбранный период.') ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<?php
|
||||
if ($r['role'] === 'client') $roleLabel = ($translations['admin_new_clients_role_client_one'] ?? 'Клиент');
|
||||
elseif ($r['role'] === 'praticien')$roleLabel = ($translations['admin_new_clients_role_praticien_one'] ?? 'Специалист');
|
||||
elseif ($r['role'] === 'secretaire') $roleLabel = ($translations['admin_new_clients_role_secretaire_one'] ?? 'Секретарь');
|
||||
else $roleLabel = $r['role'];
|
||||
|
||||
$creeLe = $r['cree_le'] ? (new DateTime($r['cree_le']))->format('d.m.Y H:i') : '';
|
||||
?>
|
||||
<tr>
|
||||
<td><?= e($r['nom']) ?></td>
|
||||
<td><?= e($r['prenom']) ?></td>
|
||||
<td><a href="mailto:<?= e($r['email']) ?>"><?= e($r['email']) ?></a></td>
|
||||
<td><?= e($r['telephone']) ?></td>
|
||||
<td><?= e($roleLabel) ?></td>
|
||||
<td><?= e($creeLe) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$r['email_valide'] === 1): ?>
|
||||
<button class="btn btn-sm btn-outline-success" disabled><?= e($translations['admin_new_clients_email_ok'] ?? 'Подтверждён') ?></button>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-danger" disabled><?= e($translations['admin_new_clients_email_ko'] ?? 'KO') ?></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($r['accepted_cgu'] === null): ?>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled><?= e($translations['admin_new_clients_dash'] ?? '—') ?></button>
|
||||
<?php elseif ((int)$r['accepted_cgu'] === 1): ?>
|
||||
<button class="btn btn-sm btn-outline-success" disabled><?= e($translations['admin_new_clients_cgu_yes'] ?? 'Приняты') ?></button>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-danger" disabled><?= e($translations['admin_new_clients_cgu_no'] ?? 'Не приняты') ?></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger js-show-details"
|
||||
data-id="<?= (int)$r['compte_id'] ?>">
|
||||
<?= e($translations['admin_new_clients_details'] ?? 'Подробно') ?>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<nav aria-label="<?= e($translations['admin_new_clients_pages_aria'] ?? 'Страницы') ?>">
|
||||
<ul class="pagination justify-content-center mt-3">
|
||||
<?php
|
||||
$prevPage = max(1, $pageNum - 1);
|
||||
$nextPage = min($totalPages, $pageNum + 1);
|
||||
|
||||
$prevParams = $baseParams + ['p'=>$prevPage, 'sort'=>$sort, 'dir'=>$dir];
|
||||
$nextParams = $baseParams + ['p'=>$nextPage, 'sort'=>$sort, 'dir'=>$dir];
|
||||
|
||||
$prevUrl = admin_url('new_inscrits', $prevParams);
|
||||
$nextUrl = admin_url('new_inscrits', $nextParams);
|
||||
?>
|
||||
<li class="page-item <?= $pageNum <= 1 ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="<?= e($prevUrl) ?>"><?= e($translations['admin_new_clients_prev'] ?? '« Предыдущая') ?></a>
|
||||
</li>
|
||||
|
||||
<?php
|
||||
$startPage = max(1, $pageNum - 2);
|
||||
$endPage = min($totalPages, $pageNum + 2);
|
||||
for ($i = $startPage; $i <= $endPage; $i++):
|
||||
$pParams = $baseParams + ['p'=>$i, 'sort'=>$sort, 'dir'=>$dir];
|
||||
$pUrl = admin_url('new_inscrits', $pParams);
|
||||
?>
|
||||
<li class="page-item <?= $i === $pageNum ? 'active' : '' ?>">
|
||||
<a class="page-link" href="<?= e($pUrl) ?>"><?= (int)$i ?></a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
|
||||
<li class="page-item <?= $pageNum >= $totalPages ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="<?= e($nextUrl) ?>"><?= e($translations['admin_new_clients_next'] ?? 'Следующая »') ?></a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const NEWCLIENTS_CSRF = <?= json_encode($CSRF, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
function postForm(url, data) {
|
||||
const body = new URLSearchParams(data);
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const btnSend = e.target.closest('.js-send-validation');
|
||||
if (btnSend) {
|
||||
const id = btnSend.dataset.id;
|
||||
if (!id) return;
|
||||
if (!confirm(<?= json_encode($translations['admin_new_clients_confirm_send_link'] ?? 'Отправить ссылку для подтверждения регистрации на этот e-mail?', JSON_UNESCAPED_UNICODE) ?>)) return;
|
||||
|
||||
postForm('tools/ajax_send_validation_link.php', {
|
||||
csrf: NEWCLIENTS_CSRF,
|
||||
id: id
|
||||
}).then(data => {
|
||||
if (data && data.ok) {
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'success', title: <?= json_encode($translations['admin_new_clients_done'] ?? 'Готово', JSON_UNESCAPED_UNICODE) ?>, text: data.message || <?= json_encode($translations['admin_new_clients_link_sent'] ?? 'Ссылка отправлена.', JSON_UNESCAPED_UNICODE) ?> });
|
||||
} else {
|
||||
alert(data.message || <?= json_encode($translations['admin_new_clients_link_sent'] ?? 'Ссылка отправлена.', JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
} else {
|
||||
const msg = (data && data.error) ? data.error : <?= json_encode($translations['admin_new_clients_err_send_link'] ?? 'Ошибка при отправке ссылки.', JSON_UNESCAPED_UNICODE) ?>;
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: msg });
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: <?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?> });
|
||||
} else {
|
||||
alert(<?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const btnDetails = e.target.closest('.js-show-details');
|
||||
if (btnDetails) {
|
||||
const id = btnDetails.dataset.id;
|
||||
if (!id) return;
|
||||
|
||||
postForm('tools/ajax_fiche_user.php', {
|
||||
csrf: NEWCLIENTS_CSRF,
|
||||
id: id
|
||||
}).then(data => {
|
||||
if (data && data.ok && data.html) {
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
title: <?= json_encode($translations['admin_new_clients_user_card_title'] ?? 'Карточка пользователя', JSON_UNESCAPED_UNICODE) ?>,
|
||||
html: data.html,
|
||||
width: '600px',
|
||||
confirmButtonText: <?= json_encode($translations['admin_new_clients_close'] ?? 'Закрыть', JSON_UNESCAPED_UNICODE) ?>
|
||||
});
|
||||
} else {
|
||||
const w = window.open('', '_blank', 'width=600,height=600');
|
||||
if (w) { w.document.write(data.html); w.document.close(); }
|
||||
else { alert(<?= json_encode($translations['admin_new_clients_err_popup'] ?? 'Не удалось открыть окно с подробностями.', JSON_UNESCAPED_UNICODE) ?>); }
|
||||
}
|
||||
} else {
|
||||
const msg = (data && data.error) ? data.error : <?= json_encode($translations['admin_new_clients_err_load'] ?? 'Не удалось загрузить данные.', JSON_UNESCAPED_UNICODE) ?>;
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: msg });
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: <?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?> });
|
||||
} else {
|
||||
alert(<?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
Usage supprimer les conversations.
|
||||
|
||||
Test (dry-run) : /messages/tools/purge_deleted.php
|
||||
|
||||
Purge réelle : /messages/tools/purge_deleted.php?dry=0
|
||||
|
||||
Avec rétention 30 jours : /messages/tools/purge_deleted.php?dry=0&days=30
|
||||
|
||||
Si tu as mis une FK messages.conversation_id ON DELETE CASCADE, tu peux supprimer directement dans conversations et la base effacera messages automatiquement. Le script ci-dessus est “safe” même sans cascade.
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
if (($_GET['page'] ?? '') !== 'reauth') return;
|
||||
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Accès refusé.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {}
|
||||
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
$msg = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'reauth') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
$msg = $translations['reauth_csrf_invalid'] ?? 'Token CSRF invalide.';
|
||||
} else {
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$uid = (int)($_SESSION['compte_id'] ?? $_SESSION['user_id'] ?? 0);
|
||||
|
||||
// fallback email
|
||||
if ($uid <= 0 && !empty($_SESSION['email'])) {
|
||||
$st = $pdo->prepare("SELECT id FROM compte WHERE email = ? LIMIT 1");
|
||||
$st->execute([(string)$_SESSION['email']]);
|
||||
$uid = (int)$st->fetchColumn();
|
||||
}
|
||||
|
||||
$ok = false;
|
||||
if ($uid > 0) {
|
||||
$st = $pdo->prepare("SELECT mot_de_passe FROM compte WHERE id = ? AND role = 'admin' LIMIT 1");
|
||||
$st->execute([$uid]);
|
||||
$hash = $st->fetchColumn();
|
||||
|
||||
if ($hash && password_verify($password, $hash)) {
|
||||
$ok = true;
|
||||
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$new = password_hash($password, PASSWORD_DEFAULT);
|
||||
$up = $pdo->prepare("UPDATE compte SET mot_de_passe = ? WHERE id = ?");
|
||||
$up->execute([$new, $uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$_SESSION['admin_reauth_at'] = time();
|
||||
$redir = $_GET['redir'] ?? 'index.php?page=maintenance';
|
||||
header('Location: '.$redir);
|
||||
exit;
|
||||
} else {
|
||||
$msg = $translations['reauth_invalid_password'] ?? 'Неверный пароль.';
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-5" style="max-width:560px">
|
||||
<h3><?= $e($translations['reauth_title'] ?? 'Подтвердите свою личность') ?></h3>
|
||||
<p class="text-muted"><?= $e($translations['reauth_intro'] ?? 'Чтобы продолжить выполнение чувствительного действия, пожалуйста, введите ваш пароль.') ?></p>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-danger"><?= $e($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="card card-body">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $e($translations['reauth_password_label'] ?? 'Пароль') ?></label>
|
||||
<input type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-danger"
|
||||
style="border-radius:999px"
|
||||
name="action"
|
||||
value="reauth">
|
||||
<?= $e($translations['reauth_confirm'] ?? 'Подтвердить') ?>
|
||||
</button>
|
||||
|
||||
<a class="btn btn-outline-secondary"
|
||||
style="border-radius:999px"
|
||||
href="index.php?page=maintenance">
|
||||
<?= $e($translations['btn_annuler'] ?? 'Отмена') ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mt-3">
|
||||
<?= $e($translations['reauth_footer_notice'] ?? 'Внимание! Усиленная безопасность, ведётся аудит.') ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// admin/tools/retour_admin.php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
// On ne met pas require_login_or_redirect ici, car on peut être sous un compte client/praticien
|
||||
// On se contente de vérifier qu'on a bien une info d’admin d’origine.
|
||||
if (!empty($_SESSION['admin_impersonate']) && is_array($_SESSION['admin_impersonate'])) {
|
||||
$orig = $_SESSION['admin_impersonate'];
|
||||
|
||||
if (isset($orig['admin_user_id'])) {
|
||||
$_SESSION['user_id'] = $orig['admin_user_id'];
|
||||
}
|
||||
if (isset($orig['admin_reference_id'])) {
|
||||
$_SESSION['reference_id'] = $orig['admin_reference_id'];
|
||||
}
|
||||
if (isset($orig['admin_role'])) {
|
||||
$_SESSION['role'] = $orig['admin_role'];
|
||||
}
|
||||
|
||||
unset($_SESSION['admin_impersonate']);
|
||||
}
|
||||
|
||||
// Retour à l’admin
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
Reference in new issue
Block a user