mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
feat: admin audit logging across all admin actions
- AdminLogger: JSON-lines → /var/log/xamxam.log (prod) / storage/logs/admin.log (dev) + best-effort DB mirror to admin_audit_log table - DB: admin_audit_log table, share_links.is_archived column - ShareLink: archive() replaces delete(), toggleActive() returns new state, listActive()/listArchived() split, validateLink blocks archived slugs - All action handlers wired: publish, unpublish, visibility, delete, csv/db export, tfe add/edit, tags, pages, apropos, form-help, access-request, maintenance, settings (formulaire toggles, objet types, smtp update), smtp-test - acces.php: archive button replaces delete; collapsible archived links section - setup-server.sh: provision /var/log/xamxam.log (www-data:xamxam 640)
This commit is contained in:
24
TODO.md
24
TODO.md
@@ -12,6 +12,30 @@
|
|||||||
- [x] `admin.css` — `.toast--warning` style + link colour
|
- [x] `admin.css` — `.toast--warning` style + link colour
|
||||||
- [x] `form.css` — `.flash-warning` style (partage form)
|
- [x] `form.css` — `.flash-warning` style (partage form)
|
||||||
|
|
||||||
|
## Admin audit logging
|
||||||
|
- [x] `AdminLogger` class — JSON-lines to `/var/log/xamxam.log` (prod) or `storage/logs/admin.log` (dev), mirrors to `admin_audit_log` DB table
|
||||||
|
- [x] `admin_audit_log` DB table — created in schema + migrated
|
||||||
|
- [x] `share_links.is_archived` column — archive replaces delete; stats preserved
|
||||||
|
- [x] `ShareLink::archive()` — new method; `toggleActive` returns new state; `listActive()` / `listArchived()` split; `validateLink` blocks archived slugs
|
||||||
|
- [x] `actions/acces-etudiante.php` — delete→archive, all actions logged (create, toggle, set_password, archive)
|
||||||
|
- [x] `actions/publish.php` — publish/unpublish logged
|
||||||
|
- [x] `actions/delete.php` — delete / bulk-delete / delete-all logged
|
||||||
|
- [x] `actions/visibility.php` — visibility changes logged
|
||||||
|
- [x] `actions/export-csv.php` — CSV export logged
|
||||||
|
- [x] `actions/export-db.php` — DB export logged
|
||||||
|
- [x] `actions/edit.php` — TFE edit logged
|
||||||
|
- [x] `actions/formulaire.php` — TFE add from admin logged
|
||||||
|
- [x] `actions/tag.php` — rename/merge/delete logged
|
||||||
|
- [x] `actions/page.php` — static page edits logged
|
||||||
|
- [x] `actions/apropos.php` — à-propos edits logged
|
||||||
|
- [x] `actions/form-help.php` — form structure edits logged
|
||||||
|
- [x] `actions/access-request.php` — approve/reject logged
|
||||||
|
- [x] `actions/maintenance.php` — maintenance on/off logged
|
||||||
|
- [x] `actions/settings.php` — formulaire toggles, objet types, SMTP update logged
|
||||||
|
- [x] `actions/smtp-test.php` — SMTP test logged
|
||||||
|
- [x] `templates/admin/acces.php` — archive button, archived links collapsible section
|
||||||
|
- [x] `scripts/setup-server.sh` — provision `/var/log/xamxam.log` with correct ownership
|
||||||
|
|
||||||
## Duplicate warning display fixes
|
## Duplicate warning display fixes
|
||||||
- [x] `toast-fragment.php` — 204 guard now also checks `warning`; warning was silently discarded before
|
- [x] `toast-fragment.php` — 204 guard now also checks `warning`; warning was silently discarded before
|
||||||
- [x] `partage/index.php` — warning stored as plain text (no pre-escaping); `htmlspecialchars()` applied once at render; was double-encoded before
|
- [x] `partage/index.php` — warning stored as plain text (no pre-escaping); `htmlspecialchars()` applied once at render; was double-encoded before
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ require_once __DIR__ . '/../../src/ShareLink.php';
|
|||||||
App::adminGuard();
|
App::adminGuard();
|
||||||
|
|
||||||
// ── Liens d'accès étudiant·e ──────────────────────────────────────────────────
|
// ── Liens d'accès étudiant·e ──────────────────────────────────────────────────
|
||||||
$shareLink = ShareLink::make();
|
$shareLink = ShareLink::make();
|
||||||
$links = $shareLink->listAll();
|
$links = $shareLink->listActive();
|
||||||
|
$archivedLinks = $shareLink->listArchived();
|
||||||
|
|
||||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
$baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
$baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Student-access link actions (create, toggle, set_password, delete).
|
* Student-access link actions (create, toggle, set_password, archive).
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/../../../bootstrap.php';
|
require_once __DIR__ . '/../../../bootstrap.php';
|
||||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||||
require_once __DIR__ . '/../../../src/ShareLink.php';
|
require_once __DIR__ . '/../../../src/ShareLink.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
|
||||||
App::adminGuard();
|
App::adminGuard();
|
||||||
|
|
||||||
@@ -15,9 +16,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
|||||||
exit('CSRF token invalide.');
|
exit('CSRF token invalide.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||||
$shareLink = ShareLink::make();
|
$shareLink = ShareLink::make();
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
case 'create':
|
case 'create':
|
||||||
@@ -32,13 +34,20 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
$objetRaw = $_POST['objet_restriction'] ?? '';
|
$objetRaw = $_POST['objet_restriction'] ?? '';
|
||||||
$objetRestriction = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : null;
|
$objetRestriction = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : null;
|
||||||
$shareLink->create(1, $password, $expiresAt, $objetRestriction);
|
$link = $shareLink->create(1, $password, $expiresAt, $objetRestriction);
|
||||||
|
$logger->logLinkCreate(
|
||||||
|
$link['slug'] ?? '',
|
||||||
|
$password !== null,
|
||||||
|
$expiresAt,
|
||||||
|
$objetRestriction
|
||||||
|
);
|
||||||
App::redirect('/admin/acces.php', success: 'Lien d\'accès créé.');
|
App::redirect('/admin/acces.php', success: 'Lien d\'accès créé.');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle':
|
case 'toggle':
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$shareLink->toggleActive($id);
|
$nowActive = $shareLink->toggleActive($id);
|
||||||
|
$logger->logLinkToggle($id, $nowActive);
|
||||||
App::redirect('/admin/acces.php', success: 'Statut du lien modifié.');
|
App::redirect('/admin/acces.php', success: 'Statut du lien modifié.');
|
||||||
} else {
|
} else {
|
||||||
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
||||||
@@ -49,16 +58,18 @@ switch ($action) {
|
|||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$password = isset($_POST['password']) && $_POST['password'] !== '' ? trim($_POST['password']) : null;
|
$password = isset($_POST['password']) && $_POST['password'] !== '' ? trim($_POST['password']) : null;
|
||||||
$shareLink->setPassword($id, $password);
|
$shareLink->setPassword($id, $password);
|
||||||
|
$logger->logLinkPasswordChange($id, $password === null);
|
||||||
App::redirect('/admin/acces.php', success: 'Mot de passe mis à jour.');
|
App::redirect('/admin/acces.php', success: 'Mot de passe mis à jour.');
|
||||||
} else {
|
} else {
|
||||||
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'delete':
|
case 'archive':
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$shareLink->delete($id);
|
$shareLink->archive($id);
|
||||||
App::redirect('/admin/acces.php', success: 'Lien supprimé.');
|
$logger->logLinkArchive($id);
|
||||||
|
App::redirect('/admin/acces.php', success: 'Lien archivé.');
|
||||||
} else {
|
} else {
|
||||||
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
|||||||
|
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
require_once APP_ROOT . '/src/SmtpRelay.php';
|
require_once APP_ROOT . '/src/SmtpRelay.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
|
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
$requestId = isset($_POST['request_id']) ? (int)$_POST['request_id'] : 0;
|
$requestId = isset($_POST['request_id']) ? (int)$_POST['request_id'] : 0;
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
@@ -54,9 +56,11 @@ try {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
SmtpRelay::send($db, $request['email'], $subject, $body, $plain);
|
SmtpRelay::send($db, $request['email'], $subject, $body, $plain);
|
||||||
|
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
|
||||||
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
|
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
|
||||||
} catch (SmtpSendException $e) {
|
} catch (SmtpSendException $e) {
|
||||||
error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage());
|
error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage());
|
||||||
|
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
|
||||||
$smtpMsg = $e->isRecipientRejected()
|
$smtpMsg = $e->isRecipientRejected()
|
||||||
? "Demande approuvée, mais l'email n'a pas pu être délivré : adresse inconnue ({$request['email']})."
|
? "Demande approuvée, mais l'email n'a pas pu être délivré : adresse inconnue ({$request['email']})."
|
||||||
: "Demande approuvée, mais l'envoi de l'email a échoué (erreur SMTP). L'utilisateur devra relancer une demande.";
|
: "Demande approuvée, mais l'envoi de l'email a échoué (erreur SMTP). L'utilisateur devra relancer une demande.";
|
||||||
@@ -65,8 +69,7 @@ try {
|
|||||||
|
|
||||||
} elseif ($action === 'reject') {
|
} elseif ($action === 'reject') {
|
||||||
$db->rejectAccessRequest($requestId, $notes);
|
$db->rejectAccessRequest($requestId, $notes);
|
||||||
|
$logger->logAccessRequest($requestId, 'reject', $request['email'], $request['title']);
|
||||||
// Optionally send rejection email (not implemented for now)
|
|
||||||
|
|
||||||
App::flash('success', "Demande rejetée.");
|
App::flash('success', "Demande rejetée.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ if (!in_array($aproposKey, $allowedKeys)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../src/Database.php';
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
@@ -66,6 +67,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db->saveAproposContent($aproposKey, $cleaned);
|
$db->saveAproposContent($aproposKey, $cleaned);
|
||||||
|
AdminLogger::make()->logAproposEdit($aproposKey);
|
||||||
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
|
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Apropos save error: " . $e->getMessage());
|
error_log("Apropos save error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
|
|||||||
AdminAuth::requireLogin();
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../src/Database.php';
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
|
||||||
// CSRF validation
|
// CSRF validation
|
||||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||||
@@ -20,8 +21,11 @@ $isDeleteAll = !empty($_POST['delete_all']);
|
|||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
|
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
if ($isDeleteAll) {
|
if ($isDeleteAll) {
|
||||||
$count = $db->deleteAllTheses();
|
$count = $db->deleteAllTheses();
|
||||||
|
$logger->logDeleteAllTheses($count);
|
||||||
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
|
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
|
||||||
|
|
||||||
} elseif ($isBulk) {
|
} elseif ($isBulk) {
|
||||||
@@ -34,6 +38,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db->bulkDeleteTheses($ids);
|
$db->bulkDeleteTheses($ids);
|
||||||
|
$logger->logDelete(array_values($ids));
|
||||||
$count = count($ids);
|
$count = count($ids);
|
||||||
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
|
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
|
||||||
|
|
||||||
@@ -47,6 +52,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db->deleteThesis($thesisId);
|
$db->deleteThesis($thesisId);
|
||||||
|
$logger->logDelete([$thesisId]);
|
||||||
App::flash('success', 'TFE supprimé avec succès.');
|
App::flash('success', 'TFE supprimé avec succès.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ if ($thesisId <= 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
|
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ctrl = ThesisEditController::create();
|
$ctrl = ThesisEditController::create();
|
||||||
@@ -33,6 +34,8 @@ try {
|
|||||||
// Regenerate CSRF token after successful save
|
// Regenerate CSRF token after successful save
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? '');
|
||||||
|
|
||||||
App::flash('success', "TFE mis à jour avec succès!");
|
App::flash('success', "TFE mis à jour avec succès!");
|
||||||
header('Location: ../edit.php?id=' . $thesisId);
|
header('Location: ../edit.php?id=' . $thesisId);
|
||||||
exit();
|
exit();
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
|
|||||||
AdminAuth::requireLogin();
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
$controller = ExportController::create();
|
$controller = ExportController::create();
|
||||||
|
|
||||||
|
AdminLogger::make()->logCsvExport();
|
||||||
|
|
||||||
$filename = 'xamxam-export-' . date('Y-m-d') . '.csv';
|
$filename = 'xamxam-export-' . date('Y-m-d') . '.csv';
|
||||||
|
|
||||||
header('Content-Type: text/csv; charset=UTF-8');
|
header('Content-Type: text/csv; charset=UTF-8');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
|
|||||||
AdminAuth::requireLogin();
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
$controller = ExportController::create();
|
$controller = ExportController::create();
|
||||||
|
|
||||||
$dbPath = $controller->getDatabasePath();
|
$dbPath = $controller->getDatabasePath();
|
||||||
@@ -25,5 +26,7 @@ header('Content-Disposition: attachment; filename="' . $filename . '"');
|
|||||||
header('Content-Length: ' . filesize($dbPath));
|
header('Content-Length: ' . filesize($dbPath));
|
||||||
header('Cache-Control: no-cache, must-revalidate');
|
header('Cache-Control: no-cache, must-revalidate');
|
||||||
|
|
||||||
|
AdminLogger::make()->logDbExport();
|
||||||
|
|
||||||
readfile($dbPath);
|
readfile($dbPath);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ $key = $_POST['form_help_key'] ?? '';
|
|||||||
$content = $_POST['content'] ?? '';
|
$content = $_POST['content'] ?? '';
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
|
|
||||||
if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
|
if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
|
||||||
@@ -28,6 +29,7 @@ if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$db->setFormHelpBlock($key, $content);
|
$db->setFormHelpBlock($key, $content);
|
||||||
|
AdminLogger::make()->logFormStructureEdit($key);
|
||||||
App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.');
|
App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('form-help save error: ' . $e->getMessage());
|
error_log('form-help save error: ' . $e->getMessage());
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ error_log('FILES array: ' . print_r($_FILES, true));
|
|||||||
|
|
||||||
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
||||||
require_once APP_ROOT . '/src/AppLogger.php';
|
require_once APP_ROOT . '/src/AppLogger.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||||
|
|
||||||
$logger = new AppLogger();
|
$logger = new AppLogger();
|
||||||
$authorName = $_POST['auteurice'] ?? 'unknown';
|
$adminLogger = AdminLogger::make();
|
||||||
|
$authorName = $_POST['auteurice'] ?? 'unknown';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ctrl = ThesisCreateController::make();
|
$ctrl = ThesisCreateController::make();
|
||||||
@@ -35,6 +37,7 @@ try {
|
|||||||
|
|
||||||
$identifier = $ctrl->getIdentifier($thesisId);
|
$identifier = $ctrl->getIdentifier($thesisId);
|
||||||
$logger->logSubmission('admin', $thesisId, $identifier, $authorName);
|
$logger->logSubmission('admin', $thesisId, $identifier, $authorName);
|
||||||
|
$adminLogger->logAdd($thesisId, $identifier, $authorName);
|
||||||
|
|
||||||
unset($_SESSION['csrf_token']);
|
unset($_SESSION['csrf_token']);
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
|||||||
die("Accès refusé.");
|
die("Accès refusé.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
|
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
if ($action === 'enable_maintenance') {
|
if ($action === 'enable_maintenance') {
|
||||||
file_put_contents(MAINTENANCE_FLAG, date('c'));
|
file_put_contents(MAINTENANCE_FLAG, date('c'));
|
||||||
|
AdminLogger::make()->logMaintenance(true);
|
||||||
App::flash('success', "Mode maintenance activé.");
|
App::flash('success', "Mode maintenance activé.");
|
||||||
} elseif ($action === 'disable_maintenance') {
|
} elseif ($action === 'disable_maintenance') {
|
||||||
if (file_exists(MAINTENANCE_FLAG)) {
|
if (file_exists(MAINTENANCE_FLAG)) {
|
||||||
unlink(MAINTENANCE_FLAG);
|
unlink(MAINTENANCE_FLAG);
|
||||||
}
|
}
|
||||||
|
AdminLogger::make()->logMaintenance(false);
|
||||||
App::flash('success', "Mode maintenance désactivé.");
|
App::flash('success', "Mode maintenance désactivé.");
|
||||||
} else {
|
} else {
|
||||||
App::flash('error', "Action inconnue.");
|
App::flash('error', "Action inconnue.");
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ if (!in_array($slug, $allowedSlugs, true)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db->savePage($slug, $content);
|
$db->savePage($slug, $content);
|
||||||
|
AdminLogger::make()->logPageEdit($slug);
|
||||||
App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.');
|
App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('page save error: ' . $e->getMessage());
|
error_log('page save error: ' . $e->getMessage());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
|
|||||||
AdminAuth::requireLogin();
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../src/Database.php';
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
|
||||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||||
@@ -27,6 +28,8 @@ $published = ($action === 'publish');
|
|||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
|
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
if ($isBulk) {
|
if ($isBulk) {
|
||||||
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
|
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db->bulkSetPublished($ids, $published);
|
$db->bulkSetPublished($ids, $published);
|
||||||
|
$logger->logPublish($published, array_values($ids));
|
||||||
$count = count($ids);
|
$count = count($ids);
|
||||||
App::flash('success', $published
|
App::flash('success', $published
|
||||||
? "$count TFE(s) publié(s) avec succès."
|
? "$count TFE(s) publié(s) avec succès."
|
||||||
@@ -52,6 +56,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db->setPublished($thesisId, $published);
|
$db->setPublished($thesisId, $published);
|
||||||
|
$logger->logPublish($published, [$thesisId]);
|
||||||
App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.');
|
App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
|||||||
|
|
||||||
require_once APP_ROOT . '/src/Database.php';
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
require_once APP_ROOT . '/src/SmtpRelay.php';
|
require_once APP_ROOT . '/src/SmtpRelay.php';
|
||||||
$db = new Database();
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
|
$db = new Database();
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
$section = $_POST['section'] ?? '';
|
$section = $_POST['section'] ?? '';
|
||||||
|
|
||||||
@@ -23,14 +25,22 @@ if ($section === 'formulaire') {
|
|||||||
'access_type_interdit_enabled',
|
'access_type_interdit_enabled',
|
||||||
'restricted_files_enabled'
|
'restricted_files_enabled'
|
||||||
];
|
];
|
||||||
|
$newValues = [];
|
||||||
foreach ($allowed as $key) {
|
foreach ($allowed as $key) {
|
||||||
$value = isset($_POST[$key]) ? '1' : '0';
|
$value = isset($_POST[$key]) ? '1' : '0';
|
||||||
$db->setSetting($key, $value);
|
$db->setSetting($key, $value);
|
||||||
|
$newValues[$key] = $value;
|
||||||
}
|
}
|
||||||
|
$logger->logFormSettingsUpdate($newValues);
|
||||||
App::flash('success', "Paramètres du formulaire mis à jour.");
|
App::flash('success', "Paramètres du formulaire mis à jour.");
|
||||||
} elseif ($section === 'objet_types') {
|
} elseif ($section === 'objet_types') {
|
||||||
$db->setSetting('objet_these_enabled', isset($_POST['objet_these_enabled']) ? '1' : '0');
|
$newValues = [
|
||||||
$db->setSetting('objet_frart_enabled', isset($_POST['objet_frart_enabled']) ? '1' : '0');
|
'objet_these_enabled' => isset($_POST['objet_these_enabled']) ? '1' : '0',
|
||||||
|
'objet_frart_enabled' => isset($_POST['objet_frart_enabled']) ? '1' : '0',
|
||||||
|
];
|
||||||
|
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
|
||||||
|
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
|
||||||
|
$logger->logObjetTypesUpdate($newValues);
|
||||||
App::flash('success', "Types de travaux mis à jour.");
|
App::flash('success', "Types de travaux mis à jour.");
|
||||||
} elseif ($section === 'smtp') {
|
} elseif ($section === 'smtp') {
|
||||||
$smtpData = [
|
$smtpData = [
|
||||||
@@ -51,6 +61,7 @@ if ($section === 'formulaire') {
|
|||||||
|
|
||||||
// Immediately probe the server to validate credentials
|
// Immediately probe the server to validate credentials
|
||||||
$test = SmtpRelay::test($db);
|
$test = SmtpRelay::test($db);
|
||||||
|
$logger->logSmtpUpdate($test['ok']);
|
||||||
if ($test['ok']) {
|
if ($test['ok']) {
|
||||||
App::flash('success', "Paramètres SMTP mis à jour — connexion validée ✓");
|
App::flash('success', "Paramètres SMTP mis à jour — connexion validée ✓");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -52,17 +52,22 @@ $body = <<<HTML
|
|||||||
</html>
|
</html>
|
||||||
HTML;
|
HTML;
|
||||||
|
|
||||||
|
require_once APP_ROOT . '/src/AdminLogger.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ok = SmtpRelay::send($db, $to, $subject, $body);
|
$ok = SmtpRelay::send($db, $to, $subject, $body);
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
|
AdminLogger::make()->logSmtpTest($to, true);
|
||||||
App::flash('success', "E-mail de test envoyé à « {$to} ».");
|
App::flash('success', "E-mail de test envoyé à « {$to} ».");
|
||||||
} else {
|
} else {
|
||||||
|
AdminLogger::make()->logSmtpTest($to, false, 'Send returned false');
|
||||||
App::flash('error', "Échec de l'envoi. Vérifiez la configuration SMTP et les logs serveur.");
|
App::flash('error', "Échec de l'envoi. Vérifiez la configuration SMTP et les logs serveur.");
|
||||||
}
|
}
|
||||||
} catch (SmtpSendException $e) {
|
} catch (SmtpSendException $e) {
|
||||||
$detail = $e->isRecipientRejected()
|
$detail = $e->isRecipientRejected()
|
||||||
? "Adresse rejetée par le serveur ({$to}) : " . $e->smtpResponse
|
? "Adresse rejetée par le serveur ({$to}) : " . $e->smtpResponse
|
||||||
: "Erreur SMTP : " . $e->smtpResponse;
|
: "Erreur SMTP : " . $e->smtpResponse;
|
||||||
|
AdminLogger::make()->logSmtpTest($to, false, $detail);
|
||||||
App::flash('error', $detail);
|
App::flash('error', $detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../src/Database.php';
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
|
$logger = AdminLogger::make();
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
@@ -22,6 +24,7 @@ try {
|
|||||||
$newName = trim($_POST['new_name'] ?? '');
|
$newName = trim($_POST['new_name'] ?? '');
|
||||||
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
|
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
|
||||||
$db->renameTag($id, $newName);
|
$db->renameTag($id, $newName);
|
||||||
|
$logger->logTagAction('rename', ['tag_id' => $id, 'new_name' => $newName]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'merge':
|
case 'merge':
|
||||||
@@ -29,12 +32,14 @@ try {
|
|||||||
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
|
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
|
||||||
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
|
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
|
||||||
$db->mergeTag($sourceId, $targetId);
|
$db->mergeTag($sourceId, $targetId);
|
||||||
|
$logger->logTagAction('merge', ['source_id' => $sourceId, 'target_id' => $targetId]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
|
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
|
||||||
if (!$id) throw new Exception("ID invalide.");
|
if (!$id) throw new Exception("ID invalide.");
|
||||||
$db->deleteTag($id);
|
$db->deleteTag($id);
|
||||||
|
$logger->logTagAction('delete', ['tag_id' => $id]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../src/Database.php';
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
|
||||||
$action = $_POST['action'] ?? ''; // 'set_visibility'
|
$action = $_POST['action'] ?? ''; // 'set_visibility'
|
||||||
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||||
@@ -26,6 +27,8 @@ if (!in_array($accessTypeId, $validAccess, true)) {
|
|||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
|
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
|
||||||
if ($isBulk) {
|
if ($isBulk) {
|
||||||
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
|
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
|
||||||
if (empty($ids)) {
|
if (empty($ids)) {
|
||||||
@@ -34,6 +37,7 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$db->bulkSetVisibility($ids, $accessTypeId);
|
$db->bulkSetVisibility($ids, $accessTypeId);
|
||||||
|
$logger->logVisibility($accessTypeId, array_values($ids));
|
||||||
App::flash('success', count($ids) . " TFE(s) mis à jour.");
|
App::flash('success', count($ids) . " TFE(s) mis à jour.");
|
||||||
} else {
|
} else {
|
||||||
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
|
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
|
||||||
@@ -43,6 +47,7 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$db->setVisibility($thesisId, $accessTypeId);
|
$db->setVisibility($thesisId, $accessTypeId);
|
||||||
|
$logger->logVisibility($accessTypeId, [$thesisId]);
|
||||||
App::flash('success', "Visibilité mise à jour.");
|
App::flash('success', "Visibilité mise à jour.");
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|||||||
279
app/src/AdminLogger.php
Normal file
279
app/src/AdminLogger.php
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin audit logger.
|
||||||
|
*
|
||||||
|
* Writes JSON-lines to /var/log/xamxam.log (production) or
|
||||||
|
* storage/logs/admin.log (dev / cli-server).
|
||||||
|
*
|
||||||
|
* Each entry: timestamp, actor (admin IP/UA), action, resource, status, context.
|
||||||
|
*
|
||||||
|
* DB mirroring: if the admin_audit_log table exists, every entry is also
|
||||||
|
* inserted there — giving the admin panel instant queryable history without
|
||||||
|
* depending on filesystem log parsing.
|
||||||
|
*/
|
||||||
|
class AdminLogger
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
private ?Database $db;
|
||||||
|
|
||||||
|
public function __construct(?Database $db = null)
|
||||||
|
{
|
||||||
|
if (php_sapi_name() === 'cli-server') {
|
||||||
|
$dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs';
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
$this->logFile = $dir . '/admin.log';
|
||||||
|
} else {
|
||||||
|
$this->logFile = '/var/log/xamxam.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convenience factory ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static function make(): self
|
||||||
|
{
|
||||||
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
|
return new self(new Database());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── High-level log methods ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** TFE list: CSV export */
|
||||||
|
public function logCsvExport(): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', 'csv_export', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TFE list: CSV import */
|
||||||
|
public function logCsvImport(int $imported, int $skipped): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', 'csv_import', 'success', [
|
||||||
|
'imported' => $imported,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TFE: publish / unpublish (single or bulk) */
|
||||||
|
public function logPublish(bool $published, array $thesisIds): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', $published ? 'publish' : 'unpublish', 'success', [
|
||||||
|
'count' => count($thesisIds),
|
||||||
|
'ids' => $thesisIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TFE: visibility change */
|
||||||
|
public function logVisibility(?int $accessTypeId, array $thesisIds): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', 'set_visibility', 'success', [
|
||||||
|
'access_type_id' => $accessTypeId,
|
||||||
|
'count' => count($thesisIds),
|
||||||
|
'ids' => $thesisIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TFE: edit */
|
||||||
|
public function logEdit(int $thesisId, string $title = ''): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', 'edit', 'success', [
|
||||||
|
'thesis_id' => $thesisId,
|
||||||
|
'title' => $title,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TFE: add (new form submission from admin) */
|
||||||
|
public function logAdd(int $thesisId, string $identifier, string $author): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', 'add', 'success', [
|
||||||
|
'thesis_id' => $thesisId,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'author' => $author,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TFE: delete (single or bulk) */
|
||||||
|
public function logDelete(array $thesisIds, bool $deleteAll = false): void
|
||||||
|
{
|
||||||
|
$this->write('thesis', 'delete', 'success', [
|
||||||
|
'delete_all' => $deleteAll,
|
||||||
|
'count' => count($thesisIds),
|
||||||
|
'ids' => $thesisIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags: rename, merge, delete */
|
||||||
|
public function logTagAction(string $action, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->write('tag', $action, 'success', $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static pages / contenus */
|
||||||
|
public function logPageEdit(string $slug): void
|
||||||
|
{
|
||||||
|
$this->write('page', 'edit', 'success', ['slug' => $slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À propos content */
|
||||||
|
public function logAproposEdit(string $key): void
|
||||||
|
{
|
||||||
|
$this->write('apropos', 'edit', 'success', ['key' => $key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Form structure (formulaire section in contenus) */
|
||||||
|
public function logFormStructureEdit(string $section): void
|
||||||
|
{
|
||||||
|
$this->write('form_structure', 'edit', 'success', ['section' => $section]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accès étudiant·e links: create */
|
||||||
|
public function logLinkCreate(string $slug, bool $hasPassword, ?string $expiresAt, ?string $objetRestriction): void
|
||||||
|
{
|
||||||
|
$this->write('share_link', 'create', 'success', [
|
||||||
|
'slug' => $slug,
|
||||||
|
'has_password' => $hasPassword,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'objet_restriction' => $objetRestriction,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accès étudiant·e links: toggle active/inactive */
|
||||||
|
public function logLinkToggle(int $id, bool $nowActive): void
|
||||||
|
{
|
||||||
|
$this->write('share_link', $nowActive ? 'activate' : 'deactivate', 'success', [
|
||||||
|
'link_id' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accès étudiant·e links: password change */
|
||||||
|
public function logLinkPasswordChange(int $id, bool $removed): void
|
||||||
|
{
|
||||||
|
$this->write('share_link', 'set_password', 'success', [
|
||||||
|
'link_id' => $id,
|
||||||
|
'removed' => $removed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accès étudiant·e links: archive (replaces delete) */
|
||||||
|
public function logLinkArchive(int $id): void
|
||||||
|
{
|
||||||
|
$this->write('share_link', 'archive', 'success', ['link_id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File access requests: approve / reject */
|
||||||
|
public function logAccessRequest(int $requestId, string $action, string $email, string $thesisTitle): void
|
||||||
|
{
|
||||||
|
$this->write('file_access_request', $action, 'success', [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'email' => $email,
|
||||||
|
'thesis_title' => $thesisTitle,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: maintenance toggle */
|
||||||
|
public function logMaintenance(bool $enabled): void
|
||||||
|
{
|
||||||
|
$this->write('system', $enabled ? 'maintenance_on' : 'maintenance_off', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: DB export */
|
||||||
|
public function logDbExport(): void
|
||||||
|
{
|
||||||
|
$this->write('system', 'db_export', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: delete all TFEs */
|
||||||
|
public function logDeleteAllTheses(int $count): void
|
||||||
|
{
|
||||||
|
$this->write('system', 'delete_all_theses', 'success', ['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: formulaire section toggles */
|
||||||
|
public function logFormSettingsUpdate(array $newValues): void
|
||||||
|
{
|
||||||
|
$this->write('settings', 'formulaire_update', 'success', ['values' => $newValues]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: objet types toggles */
|
||||||
|
public function logObjetTypesUpdate(array $newValues): void
|
||||||
|
{
|
||||||
|
$this->write('settings', 'objet_types_update', 'success', ['values' => $newValues]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: access restriction settings */
|
||||||
|
public function logAccessRestrictionUpdate(array $newValues): void
|
||||||
|
{
|
||||||
|
$this->write('settings', 'access_restriction_update', 'success', ['values' => $newValues]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: SMTP credentials update */
|
||||||
|
public function logSmtpUpdate(bool $connectionOk): void
|
||||||
|
{
|
||||||
|
$this->write('settings', 'smtp_update', 'success', ['connection_ok' => $connectionOk]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametres: SMTP test */
|
||||||
|
public function logSmtpTest(string $toEmail, bool $success, string $error = ''): void
|
||||||
|
{
|
||||||
|
$this->write('settings', 'smtp_test', $success ? 'success' : 'error', [
|
||||||
|
'to' => $toEmail,
|
||||||
|
'error' => $error ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic error entry (for catch blocks) */
|
||||||
|
public function logError(string $resource, string $action, string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->write($resource, $action, 'error', array_merge($context, ['error' => $message]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core write ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function write(string $resource, string $action, string $status, array $context = []): void
|
||||||
|
{
|
||||||
|
$entry = [
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
||||||
|
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
'resource' => $resource,
|
||||||
|
'action' => $action,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($context)) {
|
||||||
|
$entry['context'] = $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
error_log($line, 3, $this->logFile);
|
||||||
|
|
||||||
|
if ($this->db !== null) {
|
||||||
|
$this->insertDb($resource, $action, $status, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertDb(string $resource, string $action, string $status, array $context): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = $this->db->getConnection();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO admin_audit_log (ip, user_agent, resource, action, status, context)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$_SERVER['REMOTE_ADDR'] ?? 'cli',
|
||||||
|
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
$resource,
|
||||||
|
$action,
|
||||||
|
$status,
|
||||||
|
!empty($context) ? json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// DB logging is best-effort — never crash the app over it.
|
||||||
|
error_log('[AdminLogger] DB insert failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,9 +98,32 @@ class ShareLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all share links, ordered by creation date descending.
|
* List active (non-archived) share links, ordered by creation date descending.
|
||||||
|
*/
|
||||||
|
public function listActive(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->getConnection()->query(
|
||||||
|
'SELECT * FROM share_links WHERE is_archived = 0 ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List archived share links, ordered by creation date descending.
|
||||||
|
*/
|
||||||
|
public function listArchived(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->getConnection()->query(
|
||||||
|
'SELECT * FROM share_links WHERE is_archived = 1 ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all share links (active + archived), ordered by creation date descending.
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
|
* @deprecated Use listActive() / listArchived() instead.
|
||||||
*/
|
*/
|
||||||
public function listAll(): array
|
public function listAll(): array
|
||||||
{
|
{
|
||||||
@@ -112,12 +135,17 @@ class ShareLink
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the active status of a share link.
|
* Toggle the active status of a share link.
|
||||||
|
* Returns the new is_active value.
|
||||||
*/
|
*/
|
||||||
public function toggleActive(int $id): void
|
public function toggleActive(int $id): bool
|
||||||
{
|
{
|
||||||
$this->db->getConnection()->prepare(
|
$pdo = $this->db->getConnection();
|
||||||
|
$pdo->prepare(
|
||||||
'UPDATE share_links SET is_active = NOT is_active WHERE id = ?'
|
'UPDATE share_links SET is_active = NOT is_active WHERE id = ?'
|
||||||
)->execute([$id]);
|
)->execute([$id]);
|
||||||
|
$row = $pdo->prepare('SELECT is_active FROM share_links WHERE id = ?');
|
||||||
|
$row->execute([$id]);
|
||||||
|
return (bool)($row->fetchColumn() ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,12 +162,13 @@ class ShareLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a share link.
|
* Archive a share link: marks it inaccessible while preserving usage stats.
|
||||||
|
* Sets is_archived = 1 and is_active = 0 so the form rejects the slug.
|
||||||
*/
|
*/
|
||||||
public function delete(int $id): void
|
public function archive(int $id): void
|
||||||
{
|
{
|
||||||
$this->db->getConnection()->prepare(
|
$this->db->getConnection()->prepare(
|
||||||
'DELETE FROM share_links WHERE id = ?'
|
'UPDATE share_links SET is_archived = 1, is_active = 0 WHERE id = ?'
|
||||||
)->execute([$id]);
|
)->execute([$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +205,10 @@ class ShareLink
|
|||||||
return ['valid' => false, 'reason' => 'not_found'];
|
return ['valid' => false, 'reason' => 'not_found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($link['is_archived'])) {
|
||||||
|
return ['valid' => false, 'reason' => 'archived', 'link' => $link];
|
||||||
|
}
|
||||||
|
|
||||||
if (!$link['is_active']) {
|
if (!$link['is_active']) {
|
||||||
return ['valid' => false, 'reason' => 'disabled', 'link' => $link];
|
return ['valid' => false, 'reason' => 'disabled', 'link' => $link];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,7 @@
|
|||||||
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:01:08+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:01:08+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:05:04+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:05:04+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
{"source":"admin","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","timestamp":"2026-05-04T15:05:31+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
{"source":"admin","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","timestamp":"2026-05-04T15:05:31+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
|
{"source":"admin","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","timestamp":"2026-05-04T15:11:12+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
|
{"source":"admin","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","timestamp":"2026-05-04T15:11:26+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
|
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:11:36+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
|
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:11:43+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
objet_restriction TEXT CHECK (objet_restriction IN ('tfe', 'thèse', 'frart')), -- NULL = no restriction
|
objet_restriction TEXT CHECK (objet_restriction IN ('tfe', 'thèse', 'frart')), -- NULL = no restriction
|
||||||
password_hash TEXT, -- bcrypt hash; NULL = no password required
|
password_hash TEXT, -- bcrypt hash; NULL = no password required
|
||||||
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = disabled
|
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = disabled
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0, -- 1 = archived (link inaccessible, stats preserved)
|
||||||
usage_count INTEGER NOT NULL DEFAULT 0, -- Number of successful submissions via this link
|
usage_count INTEGER NOT NULL DEFAULT 0, -- Number of successful submissions via this link
|
||||||
created_by INTEGER NOT NULL, -- admin user ID
|
created_by INTEGER NOT NULL, -- admin user ID
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -345,6 +346,29 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_share_links_slug ON share_links(slug);
|
CREATE INDEX IF NOT EXISTS idx_share_links_slug ON share_links(slug);
|
||||||
CREATE INDEX IF NOT EXISTS idx_share_links_active ON share_links(is_active);
|
CREATE INDEX IF NOT EXISTS idx_share_links_active ON share_links(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_share_links_archived ON share_links(is_archived);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ADMIN AUDIT LOG
|
||||||
|
-- ============================================================================
|
||||||
|
-- Mirrors every admin action logged to /var/log/xamxam.log.
|
||||||
|
-- Best-effort: application never fails if this table is absent.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
resource TEXT NOT NULL, -- e.g. thesis, tag, share_link, settings, system
|
||||||
|
action TEXT NOT NULL, -- e.g. publish, delete, smtp_update
|
||||||
|
status TEXT NOT NULL, -- success | error
|
||||||
|
context TEXT -- JSON blob with extra fields
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_resource ON admin_audit_log(resource);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- SMTP SETTINGS
|
-- SMTP SETTINGS
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
<h2 id="acces-liens-title">Accès étudiant·e</h2>
|
<h2 id="acces-liens-title">Accès étudiant·e</h2>
|
||||||
<div class="admin-list-toolbar__right">
|
<div class="admin-list-toolbar__right">
|
||||||
<button type="button" class="admin-btn admin-btn--sm" id="open-create-dialog">
|
<button type="button" class="admin-btn admin-btn--sm" id="open-create-dialog">
|
||||||
+ Créer un lien
|
+ Créer un lien
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($links)): ?>
|
<?php if (empty($links)): ?>
|
||||||
<p class="admin-empty">Aucun lien d'accès créé. Cliquez sur « Créer un lien » pour générer un lien partageable.</p>
|
<p class="admin-empty">Aucun lien d'accès créé. Cliquez sur « Créer un lien » pour générer un lien partageable.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -92,12 +92,12 @@
|
|||||||
🔑
|
🔑
|
||||||
</button>
|
</button>
|
||||||
<form method="post" action="actions/acces-etudiante.php" class="publish-form"
|
<form method="post" action="actions/acces-etudiante.php" class="publish-form"
|
||||||
onsubmit="return confirm('Supprimer ce lien ? Les soumissions via ce lien seront bloquées.')">
|
onsubmit="return confirm('Archiver ce lien ? Il ne sera plus accessible, mais les statistiques seront conservées.')">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="action" value="delete">
|
<input type="hidden" name="action" value="archive">
|
||||||
<input type="hidden" name="id" value="<?= $link['id'] ?>">
|
<input type="hidden" name="id" value="<?= $link['id'] ?>">
|
||||||
<button type="submit" class="admin-btn-sm admin-btn-delete" title="Supprimer">
|
<button type="submit" class="admin-btn-sm admin-btn-delete" title="Archiver">
|
||||||
🗑
|
🗄
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +107,48 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($archivedLinks)): ?>
|
||||||
|
<details style="margin-top:var(--space-m);">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;color:var(--text-secondary);font-size:var(--step--1);">
|
||||||
|
Liens archivés (<?= count($archivedLinks) ?>)
|
||||||
|
</summary>
|
||||||
|
<table style="margin-top:var(--space-s);opacity:0.75;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Lien</th>
|
||||||
|
<th scope="col">Objet</th>
|
||||||
|
<th scope="col">Utilisations</th>
|
||||||
|
<th scope="col">Expiration</th>
|
||||||
|
<th scope="col">Créé le</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($archivedLinks as $link): ?>
|
||||||
|
<?php
|
||||||
|
$created = date('d/m/Y H:i', strtotime($link['created_at']));
|
||||||
|
$expires = $link['expires_at'] ? date('d/m/Y', strtotime($link['expires_at'])) : '—';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code style="font-size:var(--step--2);color:var(--text-secondary);text-decoration:line-through;"><?= htmlspecialchars($link['slug']) ?></code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ($link['objet_restriction']): ?>
|
||||||
|
<span class="status-badge"><?= htmlspecialchars($link['objet_restriction']) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="color:var(--text-secondary);font-size:var(--step--2);">Tous</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;"><?= intval($link['usage_count']) ?></td>
|
||||||
|
<td><?= $expires ?></td>
|
||||||
|
<td><?= $created ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
@@ -158,7 +200,7 @@
|
|||||||
par <?= htmlspecialchars($req['authors']) ?>
|
par <?= htmlspecialchars($req['authors']) ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($req['year'])): ?>
|
<?php if (!empty($req['year'])): ?>
|
||||||
— <?= htmlspecialchars($req['year']) ?>
|
- <?= htmlspecialchars($req['year']) ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +306,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="create-objet">Type d'objet (optionnel)</label>
|
<label for="create-objet">Type d'objet (optionnel)</label>
|
||||||
<select id="create-objet" name="objet_restriction">
|
<select id="create-objet" name="objet_restriction">
|
||||||
<option value="">— Tous les types —</option>
|
<option value="">- Tous les types -</option>
|
||||||
<option value="tfe">TFE</option>
|
<option value="tfe">TFE</option>
|
||||||
<option value="thèse">Thèse</option>
|
<option value="thèse">Thèse</option>
|
||||||
<option value="frart">Frart</option>
|
<option value="frart">Frart</option>
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ chown -R "$WEB_USER:$APP_GROUP" "$APP_DIR/storage/cache"
|
|||||||
chmod -R 2775 "$APP_DIR/storage/cache"
|
chmod -R 2775 "$APP_DIR/storage/cache"
|
||||||
ok "Cache dirs: created and owned by $WEB_USER:$APP_GROUP"
|
ok "Cache dirs: created and owned by $WEB_USER:$APP_GROUP"
|
||||||
|
|
||||||
|
# ── 8. Provision /var/log/xamxam.log ─────────────────────────────────────────
|
||||||
|
if [ ! -f /var/log/xamxam.log ]; then
|
||||||
|
touch /var/log/xamxam.log
|
||||||
|
fi
|
||||||
|
chown "$WEB_USER:$APP_GROUP" /var/log/xamxam.log
|
||||||
|
chmod 640 /var/log/xamxam.log
|
||||||
|
ok "/var/log/xamxam.log: owned by $WEB_USER:$APP_GROUP (640)"
|
||||||
|
|
||||||
printf "\n"
|
printf "\n"
|
||||||
ok "Setup complete."
|
ok "Setup complete."
|
||||||
printf "\nNext steps:\n"
|
printf "\nNext steps:\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user