From ca5983075d2df89dd4c76481df32d4866a7f2779 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 4 May 2026 17:34:26 +0200 Subject: [PATCH] feat: admin audit logging across all admin actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- TODO.md | 24 ++ app/public/admin/acces.php | 5 +- app/public/admin/actions/acces-etudiante.php | 27 +- app/public/admin/actions/access-request.php | 9 +- app/public/admin/actions/apropos.php | 2 + app/public/admin/actions/delete.php | 6 + app/public/admin/actions/edit.php | 3 + app/public/admin/actions/export-csv.php | 3 + app/public/admin/actions/export-db.php | 3 + app/public/admin/actions/form-help.php | 2 + app/public/admin/actions/formulaire.php | 7 +- app/public/admin/actions/maintenance.php | 4 + app/public/admin/actions/page.php | 2 + app/public/admin/actions/publish.php | 5 + app/public/admin/actions/settings.php | 17 +- app/public/admin/actions/smtp-test.php | 5 + app/public/admin/actions/tag.php | 7 +- app/public/admin/actions/visibility.php | 5 + app/src/AdminLogger.php | 279 +++++++++++++++++++ app/src/ShareLink.php | 45 ++- app/storage/logs/form-submissions.log | 4 + app/storage/schema.sql | 24 ++ app/templates/admin/acces.php | 58 +++- scripts/setup-server.sh | 8 + 24 files changed, 521 insertions(+), 33 deletions(-) create mode 100644 app/src/AdminLogger.php diff --git a/TODO.md b/TODO.md index ed16cea..4c9253a 100644 --- a/TODO.md +++ b/TODO.md @@ -12,6 +12,30 @@ - [x] `admin.css` — `.toast--warning` style + link colour - [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 - [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 diff --git a/app/public/admin/acces.php b/app/public/admin/acces.php index ecb32fd..cd16dbc 100644 --- a/app/public/admin/acces.php +++ b/app/public/admin/acces.php @@ -6,8 +6,9 @@ require_once __DIR__ . '/../../src/ShareLink.php'; App::adminGuard(); // ── Liens d'accès étudiant·e ────────────────────────────────────────────────── -$shareLink = ShareLink::make(); -$links = $shareLink->listAll(); +$shareLink = ShareLink::make(); +$links = $shareLink->listActive(); +$archivedLinks = $shareLink->listArchived(); $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); diff --git a/app/public/admin/actions/acces-etudiante.php b/app/public/admin/actions/acces-etudiante.php index 69fc747..58d3eea 100644 --- a/app/public/admin/actions/acces-etudiante.php +++ b/app/public/admin/actions/acces-etudiante.php @@ -1,10 +1,11 @@ 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.'); } diff --git a/app/public/admin/actions/access-request.php b/app/public/admin/actions/access-request.php index 353e19c..b9d3ba6 100644 --- a/app/public/admin/actions/access-request.php +++ b/app/public/admin/actions/access-request.php @@ -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."); } diff --git a/app/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php index 10ba380..61c9c92 100644 --- a/app/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -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()); diff --git a/app/public/admin/actions/delete.php b/app/public/admin/actions/delete.php index eda2a87..a6070cc 100644 --- a/app/public/admin/actions/delete.php +++ b/app/public/admin/actions/delete.php @@ -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.'); } diff --git a/app/public/admin/actions/edit.php b/app/public/admin/actions/edit.php index 3a9c6db..a29c85f 100644 --- a/app/public/admin/actions/edit.php +++ b/app/public/admin/actions/edit.php @@ -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(); diff --git a/app/public/admin/actions/export-csv.php b/app/public/admin/actions/export-csv.php index 25add09..11708c3 100644 --- a/app/public/admin/actions/export-csv.php +++ b/app/public/admin/actions/export-csv.php @@ -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'); diff --git a/app/public/admin/actions/export-db.php b/app/public/admin/actions/export-db.php index b3abdfa..caeae90 100644 --- a/app/public/admin/actions/export-db.php +++ b/app/public/admin/actions/export-db.php @@ -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; diff --git a/app/public/admin/actions/form-help.php b/app/public/admin/actions/form-help.php index 391edf8..7073474 100644 --- a/app/public/admin/actions/form-help.php +++ b/app/public/admin/actions/form-help.php @@ -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()); diff --git a/app/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php index 3e356ab..6f404a4 100644 --- a/app/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -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']); diff --git a/app/public/admin/actions/maintenance.php b/app/public/admin/actions/maintenance.php index 5c0b94f..e592122 100644 --- a/app/public/admin/actions/maintenance.php +++ b/app/public/admin/actions/maintenance.php @@ -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."); diff --git a/app/public/admin/actions/page.php b/app/public/admin/actions/page.php index 804d27d..e9ded20 100644 --- a/app/public/admin/actions/page.php +++ b/app/public/admin/actions/page.php @@ -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()); diff --git a/app/public/admin/actions/publish.php b/app/public/admin/actions/publish.php index b076584..9ec32c2 100644 --- a/app/public/admin/actions/publish.php +++ b/app/public/admin/actions/publish.php @@ -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.'); } diff --git a/app/public/admin/actions/settings.php b/app/public/admin/actions/settings.php index ebc44e8..92e095b 100644 --- a/app/public/admin/actions/settings.php +++ b/app/public/admin/actions/settings.php @@ -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 { diff --git a/app/public/admin/actions/smtp-test.php b/app/public/admin/actions/smtp-test.php index 078a924..2ff5ebd 100644 --- a/app/public/admin/actions/smtp-test.php +++ b/app/public/admin/actions/smtp-test.php @@ -52,17 +52,22 @@ $body = << 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); } diff --git a/app/public/admin/actions/tag.php b/app/public/admin/actions/tag.php index 66a752f..70b33a1 100644 --- a/app/public/admin/actions/tag.php +++ b/app/public/admin/actions/tag.php @@ -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: diff --git a/app/public/admin/actions/visibility.php b/app/public/admin/actions/visibility.php index 1cc37ec..8dfc700 100644 --- a/app/public/admin/actions/visibility.php +++ b/app/public/admin/actions/visibility.php @@ -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) { diff --git a/app/src/AdminLogger.php b/app/src/AdminLogger.php new file mode 100644 index 0000000..046ea76 --- /dev/null +++ b/app/src/AdminLogger.php @@ -0,0 +1,279 @@ +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()); + } + } +} diff --git a/app/src/ShareLink.php b/app/src/ShareLink.php index ae7a8eb..3fe6ca0 100644 --- a/app/src/ShareLink.php +++ b/app/src/ShareLink.php @@ -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 + * @deprecated Use listActive() / listArchived() instead. */ public function listAll(): array { @@ -112,12 +135,17 @@ class ShareLink /** * 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 = ?' )->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( - 'DELETE FROM share_links WHERE id = ?' + 'UPDATE share_links SET is_archived = 1, is_active = 0 WHERE id = ?' )->execute([$id]); } @@ -176,6 +205,10 @@ class ShareLink return ['valid' => false, 'reason' => 'not_found']; } + if (!empty($link['is_archived'])) { + return ['valid' => false, 'reason' => 'archived', 'link' => $link]; + } + if (!$link['is_active']) { return ['valid' => false, 'reason' => 'disabled', 'link' => $link]; } diff --git a/app/storage/logs/form-submissions.log b/app/storage/logs/form-submissions.log index a63417e..4a1b264 100644 --- a/app/storage/logs/form-submissions.log +++ b/app/storage/logs/form-submissions.log @@ -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: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: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"} diff --git a/app/storage/schema.sql b/app/storage/schema.sql index 92fadc1..7af0da0 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -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 password_hash TEXT, -- bcrypt hash; NULL = no password required 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 created_by INTEGER NOT NULL, -- admin user ID 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_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 diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index cf8de0a..510d916 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -9,13 +9,13 @@

Accès étudiant·e

-

Aucun lien d'accès créé. Cliquez sur « Créer un lien » pour générer un lien partageable.

+

Aucun lien d'accès créé. Cliquez sur « Créer un lien » pour générer un lien partageable.

@@ -92,12 +92,12 @@ 🔑 + onsubmit="return confirm('Archiver ce lien ? Il ne sera plus accessible, mais les statistiques seront conservées.')"> - + - @@ -107,6 +107,48 @@
+ + +
+ + Liens archivés () + + + + + + + + + + + + + + + + + + + + + + + +
LienObjetUtilisationsExpirationCréé le
+ + + + + + Tous + +
+
+