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:
Pontoporeia
2026-05-04 17:34:26 +02:00
parent 5f24dcae7e
commit ca5983075d
24 changed files with 521 additions and 33 deletions

View File

@@ -1,10 +1,11 @@
<?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__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
App::adminGuard();
@@ -15,9 +16,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
exit('CSRF token invalide.');
}
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$action = $_POST['action'] ?? '';
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
$shareLink = ShareLink::make();
$logger = AdminLogger::make();
switch ($action) {
case 'create':
@@ -32,13 +34,20 @@ switch ($action) {
}
$objetRaw = $_POST['objet_restriction'] ?? '';
$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éé.');
break;
case 'toggle':
if ($id > 0) {
$shareLink->toggleActive($id);
$nowActive = $shareLink->toggleActive($id);
$logger->logLinkToggle($id, $nowActive);
App::redirect('/admin/acces.php', success: 'Statut du lien modifié.');
} else {
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
@@ -49,16 +58,18 @@ switch ($action) {
if ($id > 0) {
$password = isset($_POST['password']) && $_POST['password'] !== '' ? trim($_POST['password']) : null;
$shareLink->setPassword($id, $password);
$logger->logLinkPasswordChange($id, $password === null);
App::redirect('/admin/acces.php', success: 'Mot de passe mis à jour.');
} else {
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
}
break;
case 'delete':
case 'archive':
if ($id > 0) {
$shareLink->delete($id);
App::redirect('/admin/acces.php', success: 'Lien supprimé.');
$shareLink->archive($id);
$logger->logLinkArchive($id);
App::redirect('/admin/acces.php', success: 'Lien archivé.');
} else {
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
}

View File

@@ -17,8 +17,10 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
require_once APP_ROOT . '/src/Database.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;
$action = $_POST['action'] ?? '';
@@ -54,9 +56,11 @@ try {
try {
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']}.");
} catch (SmtpSendException $e) {
error_log('[access-request] Email delivery failed after approval: ' . $e->getMessage());
$logger->logAccessRequest($requestId, 'approve', $request['email'], $request['title']);
$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'envoi de l'email a échoué (erreur SMTP). L'utilisateur devra relancer une demande.";
@@ -65,8 +69,7 @@ try {
} elseif ($action === 'reject') {
$db->rejectAccessRequest($requestId, $notes);
// Optionally send rejection email (not implemented for now)
$logger->logAccessRequest($requestId, 'reject', $request['email'], $request['title']);
App::flash('success', "Demande rejetée.");
}

View File

@@ -20,6 +20,7 @@ if (!in_array($aproposKey, $allowedKeys)) {
}
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
try {
$db = new Database();
@@ -66,6 +67,7 @@ try {
}
$db->saveAproposContent($aproposKey, $cleaned);
AdminLogger::make()->logAproposEdit($aproposKey);
App::flash('success', "Contenu « $aproposKey » mis à jour avec succès.");
} catch (Exception $e) {
error_log("Apropos save error: " . $e->getMessage());

View File

@@ -5,6 +5,7 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
// CSRF validation
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
@@ -20,8 +21,11 @@ $isDeleteAll = !empty($_POST['delete_all']);
try {
$db = new Database();
$logger = AdminLogger::make();
if ($isDeleteAll) {
$count = $db->deleteAllTheses();
$logger->logDeleteAllTheses($count);
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} elseif ($isBulk) {
@@ -34,6 +38,7 @@ try {
}
$db->bulkDeleteTheses($ids);
$logger->logDelete(array_values($ids));
$count = count($ids);
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
@@ -47,6 +52,7 @@ try {
}
$db->deleteThesis($thesisId);
$logger->logDelete([$thesisId]);
App::flash('success', 'TFE supprimé avec succès.');
}

View File

@@ -25,6 +25,7 @@ if ($thesisId <= 0) {
}
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
try {
$ctrl = ThesisEditController::create();
@@ -33,6 +34,8 @@ try {
// Regenerate CSRF token after successful save
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? '');
App::flash('success', "TFE mis à jour avec succès!");
header('Location: ../edit.php?id=' . $thesisId);
exit();

View File

@@ -10,8 +10,11 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
$controller = ExportController::create();
AdminLogger::make()->logCsvExport();
$filename = 'xamxam-export-' . date('Y-m-d') . '.csv';
header('Content-Type: text/csv; charset=UTF-8');

View File

@@ -9,6 +9,7 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/ExportController.php';
require_once APP_ROOT . '/src/AdminLogger.php';
$controller = ExportController::create();
$dbPath = $controller->getDatabasePath();
@@ -25,5 +26,7 @@ header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($dbPath));
header('Cache-Control: no-cache, must-revalidate');
AdminLogger::make()->logDbExport();
readfile($dbPath);
exit;

View File

@@ -18,6 +18,7 @@ $key = $_POST['form_help_key'] ?? '';
$content = $_POST['content'] ?? '';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/AdminLogger.php';
$db = new Database();
if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
@@ -28,6 +29,7 @@ if (!in_array($key, Database::FORM_HELP_KEYS, true)) {
try {
$db->setFormHelpBlock($key, $content);
AdminLogger::make()->logFormStructureEdit($key);
App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.');
} catch (Exception $e) {
error_log('form-help save error: ' . $e->getMessage());

View File

@@ -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/AppLogger.php';
require_once APP_ROOT . '/src/AdminLogger.php';
require_once APP_ROOT . '/src/DuplicateThesisException.php';
$logger = new AppLogger();
$authorName = $_POST['auteurice'] ?? 'unknown';
$logger = new AppLogger();
$adminLogger = AdminLogger::make();
$authorName = $_POST['auteurice'] ?? 'unknown';
try {
$ctrl = ThesisCreateController::make();
@@ -35,6 +37,7 @@ try {
$identifier = $ctrl->getIdentifier($thesisId);
$logger->logSubmission('admin', $thesisId, $identifier, $authorName);
$adminLogger->logAdd($thesisId, $identifier, $authorName);
unset($_SESSION['csrf_token']);

View File

@@ -10,15 +10,19 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
die("Accès refusé.");
}
require_once APP_ROOT . '/src/AdminLogger.php';
$action = $_POST['action'] ?? '';
if ($action === 'enable_maintenance') {
file_put_contents(MAINTENANCE_FLAG, date('c'));
AdminLogger::make()->logMaintenance(true);
App::flash('success', "Mode maintenance activé.");
} elseif ($action === 'disable_maintenance') {
if (file_exists(MAINTENANCE_FLAG)) {
unlink(MAINTENANCE_FLAG);
}
AdminLogger::make()->logMaintenance(false);
App::flash('success', "Mode maintenance désactivé.");
} else {
App::flash('error', "Action inconnue.");

View File

@@ -24,10 +24,12 @@ if (!in_array($slug, $allowedSlugs, true)) {
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/AdminLogger.php';
$db = new Database();
try {
$db->savePage($slug, $content);
AdminLogger::make()->logPageEdit($slug);
App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.');
} catch (Exception $e) {
error_log('page save error: ' . $e->getMessage());

View File

@@ -5,6 +5,7 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
@@ -27,6 +28,8 @@ $published = ($action === 'publish');
try {
$db = new Database();
$logger = AdminLogger::make();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
@@ -37,6 +40,7 @@ try {
}
$db->bulkSetPublished($ids, $published);
$logger->logPublish($published, array_values($ids));
$count = count($ids);
App::flash('success', $published
? "$count TFE(s) publié(s) avec succès."
@@ -52,6 +56,7 @@ try {
}
$db->setPublished($thesisId, $published);
$logger->logPublish($published, [$thesisId]);
App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.');
}

View File

@@ -12,7 +12,9 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
require_once APP_ROOT . '/src/Database.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'] ?? '';
@@ -23,14 +25,22 @@ if ($section === 'formulaire') {
'access_type_interdit_enabled',
'restricted_files_enabled'
];
$newValues = [];
foreach ($allowed as $key) {
$value = isset($_POST[$key]) ? '1' : '0';
$db->setSetting($key, $value);
$newValues[$key] = $value;
}
$logger->logFormSettingsUpdate($newValues);
App::flash('success', "Paramètres du formulaire mis à jour.");
} elseif ($section === 'objet_types') {
$db->setSetting('objet_these_enabled', isset($_POST['objet_these_enabled']) ? '1' : '0');
$db->setSetting('objet_frart_enabled', isset($_POST['objet_frart_enabled']) ? '1' : '0');
$newValues = [
'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.");
} elseif ($section === 'smtp') {
$smtpData = [
@@ -51,6 +61,7 @@ if ($section === 'formulaire') {
// Immediately probe the server to validate credentials
$test = SmtpRelay::test($db);
$logger->logSmtpUpdate($test['ok']);
if ($test['ok']) {
App::flash('success', "Paramètres SMTP mis à jour — connexion validée ✓");
} else {

View File

@@ -52,17 +52,22 @@ $body = <<<HTML
</html>
HTML;
require_once APP_ROOT . '/src/AdminLogger.php';
try {
$ok = SmtpRelay::send($db, $to, $subject, $body);
if ($ok) {
AdminLogger::make()->logSmtpTest($to, true);
App::flash('success', "E-mail de test envoyé à « {$to} ».");
} 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.");
}
} catch (SmtpSendException $e) {
$detail = $e->isRecipientRejected()
? "Adresse rejetée par le serveur ({$to}) : " . $e->smtpResponse
: "Erreur SMTP : " . $e->smtpResponse;
AdminLogger::make()->logSmtpTest($to, false, $detail);
App::flash('error', $detail);
}

View File

@@ -11,9 +11,11 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
}
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
try {
$db = new Database();
$db = new Database();
$logger = AdminLogger::make();
$action = $_POST['action'] ?? '';
switch ($action) {
@@ -22,6 +24,7 @@ try {
$newName = trim($_POST['new_name'] ?? '');
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
$db->renameTag($id, $newName);
$logger->logTagAction('rename', ['tag_id' => $id, 'new_name' => $newName]);
break;
case 'merge':
@@ -29,12 +32,14 @@ try {
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
$db->mergeTag($sourceId, $targetId);
$logger->logTagAction('merge', ['source_id' => $sourceId, 'target_id' => $targetId]);
break;
case 'delete':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
if (!$id) throw new Exception("ID invalide.");
$db->deleteTag($id);
$logger->logTagAction('delete', ['tag_id' => $id]);
break;
default:

View File

@@ -11,6 +11,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
}
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
$action = $_POST['action'] ?? ''; // 'set_visibility'
$accessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
@@ -26,6 +27,8 @@ if (!in_array($accessTypeId, $validAccess, true)) {
try {
$db = new Database();
$logger = AdminLogger::make();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
@@ -34,6 +37,7 @@ try {
exit;
}
$db->bulkSetVisibility($ids, $accessTypeId);
$logger->logVisibility($accessTypeId, array_values($ids));
App::flash('success', count($ids) . " TFE(s) mis à jour.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
@@ -43,6 +47,7 @@ try {
exit;
}
$db->setVisibility($thesisId, $accessTypeId);
$logger->logVisibility($accessTypeId, [$thesisId]);
App::flash('success', "Visibilité mise à jour.");
}
} catch (Exception $e) {