Init Git Prod Mars 2026

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

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
<?php
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
require_once __DIR__ . '/../public/includes/init_admin.php';
// FIX: cast pour comparaison stricte
$refId = (int) ($_SESSION['reference_id'] ?? 0);
if ($refId === 88888888):
// CSRF par session
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// FIX: back URL sûr (référer interne uniquement)
$fallbackBack = '/admin/index.php';
if (! empty($_SERVER['HTTP_REFERER'])) {
$ref = parse_url($_SERVER['HTTP_REFERER']);
if (! empty($ref['host']) && $ref['host'] === ($_SERVER['HTTP_HOST'] ?? '')) {
$fallbackBack = $_SERVER['HTTP_REFERER'];
}
}
// Helpers
function h($s)
{return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');}
function admin_url(string $page, array $params = []): string
{
$q = http_build_query(array_merge(['page' => $page], $params));
return '/admin/index.php?' . $q;
}
// Whitelist des vues
$views = [
'maintenance' => __DIR__ . '/tools/maintenance.php',
'add_video' => __DIR__ . '/tools/add_video.php',
'gerer_praticiens' => __DIR__ . '/tools/gerer_praticiens.php',
'cleanup_inscriptions' => __DIR__ . '/tools/cleanup_inscriptions.php',
'purge_conversations' => __DIR__ . '/tools/purge_conversations.php',
'purger_mess_upload' => __DIR__ . '/tools/gc.php',
'logs' => __DIR__ . '/tools/logs.php',
// nouveaux
'audit' => __DIR__ . '/tools/audit.php',
'reauth' => __DIR__ . '/tools/reauth.php',
'backup_db' => __DIR__ . '/tools/backup_db.php',
'cleanup_rdv' => __DIR__ . '/tools/cleanup_rdv.php',
'afficher_messages' => __DIR__ . '/tools/afficher_messages.php',
'new_inscrits' => __DIR__ . '/tools/new_clients.php',
'mail_praticiens' => __DIR__ . '/tools/mailing_praticiens.php',
'connecter_en_admin' => __DIR__ . '/tools/connecter_en_admin.php',
'clean_uploads' => __DIR__ . '/tools/clean_uploads.php',
];
$page = $_GET['page'] ?? null;
$key = (is_string($page) && isset($views[$page])) ? $page : 'maintenance';
?>
<!doctype html>
<html lang="<?php echo h($lang ?? 'ru') ?>">
<head>
<meta charset="utf-8">
<title><?php echo h($translations['admin_title'] ?? 'Admin') ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Police Montserrat -->
<script src="/js/sweetalert2@11.js"></script>
<!-- Bootstrap -->
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/includes/admin_style.css?v=202602">
</head>
<body>
<header class="site-header py-2 border-bottom">
<div class="container d-flex justify-content-between align-items-center">
<div class="header-left">
<div class="brand">
<a href="<?php echo h($fallbackBack) ?>"
id="btnBack"
class="btn btn-light btn-circle me-2"
style="border-radius:5px; box-shadow:0 1px 2px rgba(0,0,0,.08);"
aria-label="<?php echo h($translations['retour'] ?? 'Retour') ?>">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path d="M15 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<a href="/admin/index.php" aria-label="<?php echo h($translations['home'] ?? 'Home') ?>">
<img src="/public/uploads/img/logo_u.png" alt="<?php echo h($translations['site_name'] ?? 'uslugi.fr') ?>" class="logo-mobile">
<img src="/public/uploads/img/logo.png" alt="<?php echo h($translations['site_name'] ?? 'uslugi.fr') ?>" class="logo-desktop">
</a>
</div>
<div class="lang-switch">
<?php require __DIR__ . '/../includes/menu_langue.php'; ?>
</div>
</div>
<nav class="header-actions d-flex align-items-center gap-3 position-relative">
<div class="menu-wrap">
<button type="button" id="btnMenu" class="btn p-0 border-0 bg-transparent" aria-label="<?php echo h($translations['menu_admin'] ?? 'Menu') ?>">
<div class="avatar d-flex align-items-center justify-content-center"
style="width:40px;height:40px;border-radius:50%;background:#38378B;color:white;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M19.14,12.94a7.48,7.48,0,0,0,0-1.88l2.11-1.65a.5.5,0,0,0,.12-.64l-2-3.46a.5.5,0,0,0-.61-.22l-2.49,1a7.28,7.28,0,0,0-1.63-.95l-.38-2.65A.5.5,0,0,0,13.78,3H10.22a.5.5,0,0,0-.49.42L9.35,6.07a7.28,7.28,0,0,0-1.63.95l-2.49-1a.5.5,0,0,0-.61.22l-2,3.46a.5.5,0,0,0,.12.64L4.86,11.06a7.48,7.48,0,0,0,0,1.88L2.75,14.59a.5.5,0,0,0-.12.64l2,3.46a.5.5,0,0,0,.61.22l2.49-1a7.28,7.28,0,0,0,1.63.95l.38,2.65a.5.5,0,0,0,.49.42h3.56a.5.5,0,0,0,.49-.42l.38-2.65a7.28,7.28,0,0,0,1.63-.95l2.49,1a.5.5,0,0,0,.61-.22l2-3.46a.5.5,0,0,0-.12-.64ZM12,15.5A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
</svg>
</div>
</button>
<ul class="dropdown-menu-custom menu-panel" id="menu" style="display:none; min-width:220px;">
<li onclick="window.open('https://staging.uslugi.fr/matomo/index.php','_blank')">
<?php echo h($translations['admin_analytic'] ?? 'Analytics') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('add_video')) ?>'">
<?php echo h($translations['add_video'] ?? 'Ajouter une vidéo') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('new_inscrits')) ?>'">
<?php echo h($translations['les_inscrits'] ?? 'Les Inscrits') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('mail_praticiens')) ?>'">
<?php echo h($translations['mail_praticiens'] ?? 'Рассылка практикующим') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('connecter_en_admin')) ?>'">
<?php echo h($translations['connecter_en_admin'] ?? 'Войти от имени пользователя') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('gerer_praticiens')) ?>'">
<?php echo h($translations['gerer_praticiens'] ?? 'Gérer praticiens') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('cleanup_inscriptions')) ?>'">
<?php echo h($translations['supp_inscr'] ?? 'Supp. inscriptions') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('clean_uploads')) ?>'">
<?php echo h($translations['supp_uploads'] ?? 'Supp. uploads') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('purge_conversations')) ?>'">
<?php echo h($translations['purger_messages'] ?? 'Supp. messages') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('purger_mess_upload')) ?>'">
<?php echo h($translations['purger_mess_upload'] ?? 'Supp. Upload') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('logs')) ?>'">
<?php echo h($translations['afficher_logs'] ?? 'Logs') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('audit')) ?>'">
<?php echo h($translations['audit'] ?? 'Audit') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('backup_db')) ?>'">
<?php echo h($translations['backup_db'] ?? 'Backup DB') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('cleanup_rdv')) ?>'">
<?php echo h($translations['cleanup_rdv'] ?? 'Cleanup RDV') ?>
</li>
<li onclick="location.href='<?php echo h(admin_url('afficher_messages')) ?>'">
<?php echo h($translations['afficher_messages'] ?? 'Afficher Messages') ?>
</li>
<li onclick="location.href='/public/deconnexion.php'" class="text-danger">
<?php echo h($translations['menu_deconnexion'] ?? 'Déconnexion') ?>
</li>
</ul>
</div>
</nav>
</div>
</header>
<div class="main1">
<div class="container my-3">
<?php
$_GET['page'] = $key;
$path = $views[$key] ?? null;
if ($path && is_file($path)) {
require $path;
} else {
echo '<div class="alert alert-warning">' . h($translations['admin_view_missing'] ?? 'Vue manquante :') . ' ' . h($key) . '</div>';
}
?>
</div>
</div>
<script>
function toggleMenu() {
const menu = document.getElementById('menu');
if (!menu) return;
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
}
function closeMenu() {
const menu = document.getElementById('menu');
if (menu) menu.style.display = 'none';
closeSubmenus();
}
document.getElementById('btnMenu')?.addEventListener('click', toggleMenu);
document.addEventListener('click', (e) => {
const menu = document.getElementById('menu');
if (!menu) return;
const toggle = document.getElementById('btnMenu');
if (toggle && !toggle.contains(e.target) && !menu.contains(e.target)) {
closeMenu();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
document.addEventListener('click', function(e) {
const btn = e.target.closest('.submenu-toggle');
if (!btn) return;
const submenuId = btn.getAttribute('aria-controls');
const submenu = document.getElementById(submenuId);
if (!submenu) return;
const isOpen = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!isOpen));
submenu.hidden = isOpen;
e.stopPropagation();
});
function closeSubmenus() {
document
.querySelectorAll('.submenu-toggle[aria-expanded="true"]')
.forEach(btn => {
btn.setAttribute('aria-expanded', 'false');
const sub = document.getElementById(btn.getAttribute('aria-controls'));
if (sub) sub.hidden = true;
});
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<?php ob_end_flush(); ?>
</body>
</html>
<?php endif; ?>
+22
View File
@@ -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),
]);
}
}
}
+321
View File
@@ -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>
+323
View File
@@ -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>
+252
View File
@@ -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);
+101
View File
@@ -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);
+145
View File
@@ -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>
+206
View File
@@ -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>
+14
View File
@@ -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;
+276
View File
@@ -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>
+179
View File
@@ -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>
+370
View File
@@ -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>
+179
View File
@@ -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>
+35
View File
@@ -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>";
+368
View File
@@ -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>
+425
View File
@@ -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>
+205
View File
@@ -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 &amp; 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>
+55
View File
@@ -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;
+364
View File
@@ -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>
+130
View File
@@ -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>
+552
View File
@@ -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>
+354
View File
@@ -0,0 +1,354 @@
<?php
/**
* /admin/tools/purge_conversations.php
* Purge des conversations où deleted_for_praticien = 1 ET deleted_for_client = 1.
* - Affichage Bootstrap
* - DRY-RUN par défaut ; purge LIVE via POST + CSRF
* - AUCUNE fonction globale déclarée (helpers en closures) -> pas de collision avec index.php
*/
require_once __DIR__ . '/../../public/includes/init_admin.php';
if (($_GET['page'] ?? '') !== 'purge_conversations') { return; }
// Sécurité (l'admin/index.php a normalement déjà vérifié ça)
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
// Lang (pour $translations)
$roles = (array)($_SESSION['role'] ?? []);
if (!in_array('admin', $roles, true)) {
echo '<div class="container my-4"><div class="alert alert-danger">'
. htmlspecialchars((string)($translations['admin_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
. '</div></div>';
return;
}
// reauth (10 min)
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
// PDO si absent
if (!isset($pdo)) {
$mb = realpath(__DIR__ . '/../../messages/_bootstrap.php');
if ($mb && is_file($mb)) { require_once $mb; }
}
if (!isset($pdo) || !($pdo instanceof PDO)) {
echo '<div class="container my-4"><div class="alert alert-danger">'
. htmlspecialchars((string)($translations['admin_db_unavailable'] ?? 'Connexion DB indisponible.'), ENT_QUOTES, 'UTF-8')
. '</div></div>';
return;
}
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (\Throwable $e) {}
// CSRF
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
$CSRF = $_SESSION['csrf_token'];
// -------- Helpers en closures (pas de fonctions globales !) ----------
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
$logs = [];
$log = static function(string $m) use (&$logs) { $logs[] = $m; };
$siteRoot = realpath(__DIR__ . '/../../');
$safeWithin = static function(string $path, string $base) {
$rp = realpath($path); $rb = realpath($base);
return $rp && $rb && strpos($rp, $rb) === 0;
};
$tableExists = static function(PDO $pdo, string $table): bool {
$sql = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?";
$st = $pdo->prepare($sql); $st->execute([$table]); return (bool)$st->fetchColumn();
};
$placeholders = static function(int $n): string { return implode(',', array_fill(0,$n,'?')); };
$chunked = static function(array $ids, int $size=500): array {
$out=[]; $chunk=[]; foreach ($ids as $id) { $chunk[]=(int)$id; if (count($chunk)>=$size){$out[]=$chunk;$chunk=[];} }
if ($chunk) $out[]=$chunk; return $out;
};
// -------------------------------------------------------------------
$retentionDays = isset($_REQUEST['days']) ? max(0, (int)$_REQUEST['days']) : 0; // ex 30
$doLive = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'purge') {
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
echo '<div class="container my-4"><div class="alert alert-danger">'
. $e($translations['csrf_invalid'] ?? 'Token CSRF invalide.')
. '</div></div>';
return;
}
$doLive = true;
}
$dry = !$doLive;
$errors = [];
$stats = ['conversations'=>0,'messages'=>0,'attachments'=>0,'counters'=>0,'read_markers'=>0,'duration'=>0.0];
$candidates = []; // [id, updated_at]
$attachList = []; // max 50
$start = microtime(true);
try {
// Tables
foreach (['conversations','messages'] as $t) {
if (!$tableExists($pdo, $t)) {
$tpl = (string)($translations['purge_table_missing'] ?? "Table <code>{TABLE}</code> introuvable.");
$errors[] = str_replace('{TABLE}', $e($t), $tpl);
}
}
$hasCounters = $tableExists($pdo, 'conversation_counters');
$hasReadMk = $tableExists($pdo, 'read_markers');
if (empty($errors)) {
// 1) Candidats
$lastSql = "SELECT id, updated_at FROM conversations WHERE deleted_for_praticien=1 AND deleted_for_client=1";
if ($retentionDays > 0) { $lastSql .= " AND updated_at < (NOW() - INTERVAL {$retentionDays} DAY)"; }
$rows = $pdo->query($lastSql)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) { $candidates[] = [(int)$r['id'], (string)$r['updated_at']]; }
$stats['conversations'] = count($candidates);
if ($stats['conversations'] > 0) {
$ids = array_column($candidates, 0);
// 2) Stats pré-purge
foreach ($chunked($ids, 500) as $batch) {
$ph = $placeholders(count($batch));
// messages
$st = $pdo->prepare("SELECT COUNT(*) FROM messages WHERE conversation_id IN ($ph)");
$st->execute($batch);
$stats['messages'] += (int)$st->fetchColumn();
// attachments (sample)
$st = $pdo->prepare("SELECT attachment_path FROM messages WHERE conversation_id IN ($ph)
AND attachment_path IS NOT NULL AND attachment_path<>''");
$st->execute($batch);
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$p = (string)$row['attachment_path'];
if ($p !== '') {
$stats['attachments']++;
if (count($attachList) < 50) { $attachList[] = $p; }
}
}
if ($hasCounters) {
$st = $pdo->prepare("SELECT COUNT(*) FROM conversation_counters WHERE conversation_id IN ($ph)");
$st->execute($batch);
$stats['counters'] += (int)$st->fetchColumn();
}
if ($hasReadMk) {
$st = $pdo->prepare("SELECT COUNT(*) FROM read_markers WHERE conversation_id IN ($ph)");
$st->execute($batch);
$stats['read_markers'] += (int)$st->fetchColumn();
}
}
// 3) Purge LIVE
if ($doLive) {
$pdo->beginTransaction();
try {
// supprimer fichiers de pièces jointes
foreach ($chunked($ids, 500) as $batch) {
$ph = $placeholders(count($batch));
$st = $pdo->prepare("SELECT attachment_path FROM messages WHERE conversation_id IN ($ph)
AND attachment_path IS NOT NULL AND attachment_path<>''");
$st->execute($batch);
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$rel = (string)$row['attachment_path']; // ex: messages/uploads/xxx
$abs = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $rel);
if ($safeWithin($abs, $siteRoot) && is_file($abs)) {
if (@unlink($abs)) { $log("[OK] ".$e($translations['purge_log_file_deleted'] ?? 'Fichier supprimé').": {$rel}"); }
else { $log("[WARN] ".$e($translations['purge_log_file_delete_failed'] ?? 'Échec suppression fichier').": {$rel}"); }
} else {
$log("[INFO] ".$e($translations['purge_log_file_missing_or_outside'] ?? 'Fichier introuvable/hors périmètre').": {$rel}");
}
}
}
// dépendances
foreach ($chunked($ids, 500) as $batch) {
$ph = $placeholders(count($batch));
$st = $pdo->prepare("DELETE FROM messages WHERE conversation_id IN ($ph)");
$st->execute($batch);
$log("[OK] ".$e($translations['purge_log_messages_deleted_batch'] ?? 'messages supprimés (batch)').": ".$st->rowCount());
if ($hasCounters) {
$st = $pdo->prepare("DELETE FROM conversation_counters WHERE conversation_id IN ($ph)");
$st->execute($batch);
$log("[OK] ".$e($translations['purge_log_counters_deleted_batch'] ?? 'conversation_counters supprimés (batch)').": ".$st->rowCount());
}
if ($hasReadMk) {
$st = $pdo->prepare("DELETE FROM read_markers WHERE conversation_id IN ($ph)");
$st->execute($batch);
$log("[OK] ".$e($translations['purge_log_read_markers_deleted_batch'] ?? 'read_markers supprimés (batch)').": ".$st->rowCount());
}
}
// parent
foreach ($chunked($ids, 500) as $batch) {
$ph = $placeholders(count($batch));
$st = $pdo->prepare("DELETE FROM conversations WHERE id IN ($ph)");
$st->execute($batch);
$log("[OK] ".$e($translations['purge_log_conversations_deleted_batch'] ?? 'conversations supprimées (batch)').": ".$st->rowCount());
}
$pdo->commit();
$log($translations['purge_done_ok'] ?? 'Purge effectuée avec succès.');
} catch (\Throwable $ex) {
$pdo->rollBack();
$tpl = (string)($translations['purge_error'] ?? 'Erreur purge: {ERR}');
$errors[] = str_replace('{ERR}', $e($ex->getMessage()), $tpl);
}
} else {
$log($translations['purge_dry_run_hint'] ?? 'DRY-RUN: aucune suppression; utilisez le bouton Purger en LIVE pour exécuter.');
}
} else {
$log($translations['purge_none'] ?? 'Aucune conversation à purger.');
}
}
} catch (\Throwable $ex) {
$tpl = (string)($translations['purge_exception'] ?? 'Exception: {ERR}');
$errors[] = str_replace('{ERR}', $e($ex->getMessage()), $tpl);
}
$stats['duration'] = microtime(true) - $start;
?>
<div class="shadow p-4 rounded bg-light">
<div class="container my-4">
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
<h3 class="m-0"><?= $e($translations['purge_title'] ?? 'Purger les conversations supprimées des deux côtés') ?></h3>
<span class="badge text-bg-<?= $dry ? 'secondary' : 'danger' ?>">
<?= $dry ? $e($translations['purge_mode_dry'] ?? 'DRY-RUN (simulation)') : $e($translations['purge_mode_live'] ?? 'LIVE (suppression activée)') ?>
</span>
<span class="badge text-bg-info">
<?php
$tpl = (string)($translations['purge_retention_badge'] ?? 'Rétention: {N} jour(s)');
echo $e(str_replace('{N}', (string)((int)$retentionDays), $tpl));
?>
</span>
</div>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger">
<strong><?= $e($translations['errors_title'] ?? 'Erreurs') ?></strong>
<ul class="mb-0">
<?php foreach ($errors as $er): ?><li><?= $er ?></li><?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card mb-3">
<div class="card-body">
<form method="post" class="row g-3">
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
<div class="col-12 col-md-3">
<label class="form-label"><?= $e($translations['purge_retention_label'] ?? 'Rétention (jours) avant purge') ?></label>
<input type="number" class="form-control" name="days" min="0" value="<?= (int)$retentionDays ?>">
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" name="action" value="scan" class="btn btn-outline-secondary">
<?= $e($translations['btn_scan'] ?? 'Scanner') ?>
</button>
<button type="submit" name="action" value="purge" class="btn btn-danger"
onclick="return confirm('<?= $e($translations['confirm_purge_definitive'] ?? 'Confirmer la purge définitive ?') ?>');">
<?= $e($translations['btn_purge'] ?? 'Purger') ?>
</button>
</div>
</form>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header"><?= $e($translations['purge_summary'] ?? 'Résumé') ?></div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['purge_targeted_conversations'] ?? 'Conversations ciblées') ?>
<span class="badge text-bg-secondary"><?= (int)$stats['conversations'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['purge_impacted_messages'] ?? 'Messages impactés') ?>
<span class="badge text-bg-secondary"><?= (int)$stats['messages'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['purge_attachments_files'] ?? 'Pièces jointes (fichiers)') ?>
<span class="badge text-bg-<?= $stats['attachments'] ? 'warning':'secondary' ?>">
<?= (int)$stats['attachments'] ?>
</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['purge_counters_to_delete'] ?? 'Counters à supprimer') ?>
<span class="badge text-bg-secondary"><?= (int)$stats['counters'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['purge_read_markers_to_delete'] ?? 'Read markers à supprimer') ?>
<span class="badge text-bg-secondary"><?= (int)$stats['read_markers'] ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<?= $e($translations['purge_duration'] ?? 'Durée') ?>
<span class="badge text-bg-secondary"><?= number_format($stats['duration'], 3) ?> s</span>
</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header"><?= $e($translations['purge_candidates_preview'] ?? 'Conversations candidates (aperçu)') ?></div>
<div class="card-body p-0">
<?php if (empty($candidates)): ?>
<div class="p-3 text-muted"><?= $e($translations['purge_none'] ?? 'Aucune conversation à purger.') ?></div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th><?= $e($translations['th_id'] ?? 'ID') ?></th>
<th><?= $e($translations['th_last_update'] ?? 'Dernière MAJ') ?></th>
</tr>
</thead>
<tbody>
<?php foreach (array_slice($candidates, 0, 100) as [$cid, $upd]): ?>
<tr>
<td><?= (int)$cid ?></td>
<td><time><?= $e($upd) ?></time></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (count($candidates) > 100): ?>
<div class="p-2 small text-muted">
<?php
$tpl = (string)($translations['purge_more_count'] ?? '(+ {N} de plus...)');
echo $e(str_replace('{N}', (string)(count($candidates)-100), $tpl));
?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><?= $e($translations['purge_attachments_examples'] ?? 'Exemples de pièces jointes (max 50)') ?></div>
<div class="card-body">
<?php if (empty($attachList)): ?>
<div class="text-muted"><?= $e($translations['purge_no_attachments_found'] ?? 'Aucune pièce jointe trouvée pour ces conversations.') ?></div>
<?php else: ?>
<ul class="mb-0">
<?php foreach ($attachList as $p): ?>
<li><code><?= $e($p) ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><?= $e($translations['purge_log_title'] ?? 'Journal') ?></div>
<div class="card-body">
<pre class="small mb-0" style="white-space:pre-wrap;"><?= $e(implode("\n", $logs)) ?></pre>
</div>
</div>
</div>
</div>
+10
View File
@@ -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.
+110
View File
@@ -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>
+29
View File
@@ -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;