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

24
TODO.md
View File

@@ -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

View File

@@ -7,7 +7,8 @@ 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');

View File

@@ -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();
@@ -18,6 +19,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST'
$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.');
} }

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/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.");
} }

View File

@@ -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());

View File

@@ -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.');
} }

View File

@@ -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();

View File

@@ -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');

View File

@@ -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;

View File

@@ -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());

View File

@@ -24,9 +24,11 @@ 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();
$adminLogger = AdminLogger::make();
$authorName = $_POST['auteurice'] ?? 'unknown'; $authorName = $_POST['auteurice'] ?? 'unknown';
try { try {
@@ -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']);

View File

@@ -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.");

View File

@@ -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());

View File

@@ -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.');
} }

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/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php'; require_once APP_ROOT . '/src/SmtpRelay.php';
require_once APP_ROOT . '/src/AdminLogger.php';
$db = new Database(); $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 {

View File

@@ -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);
} }

View File

@@ -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:

View File

@@ -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
View 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());
}
}
}

View File

@@ -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];
} }

View File

@@ -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"}

View File

@@ -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

View File

@@ -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>

View File

@@ -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"