mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
feat: admin audit logging across all admin actions
- AdminLogger: JSON-lines → /var/log/xamxam.log (prod) / storage/logs/admin.log (dev) + best-effort DB mirror to admin_audit_log table - DB: admin_audit_log table, share_links.is_archived column - ShareLink: archive() replaces delete(), toggleActive() returns new state, listActive()/listArchived() split, validateLink blocks archived slugs - All action handlers wired: publish, unpublish, visibility, delete, csv/db export, tfe add/edit, tags, pages, apropos, form-help, access-request, maintenance, settings (formulaire toggles, objet types, smtp update), smtp-test - acces.php: archive button replaces delete; collapsible archived links section - setup-server.sh: provision /var/log/xamxam.log (www-data:xamxam 640)
This commit is contained in:
24
TODO.md
24
TODO.md
@@ -12,6 +12,30 @@
|
||||
- [x] `admin.css` — `.toast--warning` style + link colour
|
||||
- [x] `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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
279
app/src/AdminLogger.php
Normal file
279
app/src/AdminLogger.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Admin audit logger.
|
||||
*
|
||||
* Writes JSON-lines to /var/log/xamxam.log (production) or
|
||||
* storage/logs/admin.log (dev / cli-server).
|
||||
*
|
||||
* Each entry: timestamp, actor (admin IP/UA), action, resource, status, context.
|
||||
*
|
||||
* DB mirroring: if the admin_audit_log table exists, every entry is also
|
||||
* inserted there — giving the admin panel instant queryable history without
|
||||
* depending on filesystem log parsing.
|
||||
*/
|
||||
class AdminLogger
|
||||
{
|
||||
private string $logFile;
|
||||
private ?Database $db;
|
||||
|
||||
public function __construct(?Database $db = null)
|
||||
{
|
||||
if (php_sapi_name() === 'cli-server') {
|
||||
$dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$this->logFile = $dir . '/admin.log';
|
||||
} else {
|
||||
$this->logFile = '/var/log/xamxam.log';
|
||||
}
|
||||
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Convenience factory ───────────────────────────────────────────────────
|
||||
|
||||
public static function make(): self
|
||||
{
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
return new self(new Database());
|
||||
}
|
||||
|
||||
// ── High-level log methods ────────────────────────────────────────────────
|
||||
|
||||
/** TFE list: CSV export */
|
||||
public function logCsvExport(): void
|
||||
{
|
||||
$this->write('thesis', 'csv_export', 'success');
|
||||
}
|
||||
|
||||
/** TFE list: CSV import */
|
||||
public function logCsvImport(int $imported, int $skipped): void
|
||||
{
|
||||
$this->write('thesis', 'csv_import', 'success', [
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/** TFE: publish / unpublish (single or bulk) */
|
||||
public function logPublish(bool $published, array $thesisIds): void
|
||||
{
|
||||
$this->write('thesis', $published ? 'publish' : 'unpublish', 'success', [
|
||||
'count' => count($thesisIds),
|
||||
'ids' => $thesisIds,
|
||||
]);
|
||||
}
|
||||
|
||||
/** TFE: visibility change */
|
||||
public function logVisibility(?int $accessTypeId, array $thesisIds): void
|
||||
{
|
||||
$this->write('thesis', 'set_visibility', 'success', [
|
||||
'access_type_id' => $accessTypeId,
|
||||
'count' => count($thesisIds),
|
||||
'ids' => $thesisIds,
|
||||
]);
|
||||
}
|
||||
|
||||
/** TFE: edit */
|
||||
public function logEdit(int $thesisId, string $title = ''): void
|
||||
{
|
||||
$this->write('thesis', 'edit', 'success', [
|
||||
'thesis_id' => $thesisId,
|
||||
'title' => $title,
|
||||
]);
|
||||
}
|
||||
|
||||
/** TFE: add (new form submission from admin) */
|
||||
public function logAdd(int $thesisId, string $identifier, string $author): void
|
||||
{
|
||||
$this->write('thesis', 'add', 'success', [
|
||||
'thesis_id' => $thesisId,
|
||||
'identifier' => $identifier,
|
||||
'author' => $author,
|
||||
]);
|
||||
}
|
||||
|
||||
/** TFE: delete (single or bulk) */
|
||||
public function logDelete(array $thesisIds, bool $deleteAll = false): void
|
||||
{
|
||||
$this->write('thesis', 'delete', 'success', [
|
||||
'delete_all' => $deleteAll,
|
||||
'count' => count($thesisIds),
|
||||
'ids' => $thesisIds,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Tags: rename, merge, delete */
|
||||
public function logTagAction(string $action, array $context = []): void
|
||||
{
|
||||
$this->write('tag', $action, 'success', $context);
|
||||
}
|
||||
|
||||
/** Static pages / contenus */
|
||||
public function logPageEdit(string $slug): void
|
||||
{
|
||||
$this->write('page', 'edit', 'success', ['slug' => $slug]);
|
||||
}
|
||||
|
||||
/** À propos content */
|
||||
public function logAproposEdit(string $key): void
|
||||
{
|
||||
$this->write('apropos', 'edit', 'success', ['key' => $key]);
|
||||
}
|
||||
|
||||
/** Form structure (formulaire section in contenus) */
|
||||
public function logFormStructureEdit(string $section): void
|
||||
{
|
||||
$this->write('form_structure', 'edit', 'success', ['section' => $section]);
|
||||
}
|
||||
|
||||
/** Accès étudiant·e links: create */
|
||||
public function logLinkCreate(string $slug, bool $hasPassword, ?string $expiresAt, ?string $objetRestriction): void
|
||||
{
|
||||
$this->write('share_link', 'create', 'success', [
|
||||
'slug' => $slug,
|
||||
'has_password' => $hasPassword,
|
||||
'expires_at' => $expiresAt,
|
||||
'objet_restriction' => $objetRestriction,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Accès étudiant·e links: toggle active/inactive */
|
||||
public function logLinkToggle(int $id, bool $nowActive): void
|
||||
{
|
||||
$this->write('share_link', $nowActive ? 'activate' : 'deactivate', 'success', [
|
||||
'link_id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Accès étudiant·e links: password change */
|
||||
public function logLinkPasswordChange(int $id, bool $removed): void
|
||||
{
|
||||
$this->write('share_link', 'set_password', 'success', [
|
||||
'link_id' => $id,
|
||||
'removed' => $removed,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Accès étudiant·e links: archive (replaces delete) */
|
||||
public function logLinkArchive(int $id): void
|
||||
{
|
||||
$this->write('share_link', 'archive', 'success', ['link_id' => $id]);
|
||||
}
|
||||
|
||||
/** File access requests: approve / reject */
|
||||
public function logAccessRequest(int $requestId, string $action, string $email, string $thesisTitle): void
|
||||
{
|
||||
$this->write('file_access_request', $action, 'success', [
|
||||
'request_id' => $requestId,
|
||||
'email' => $email,
|
||||
'thesis_title' => $thesisTitle,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Parametres: maintenance toggle */
|
||||
public function logMaintenance(bool $enabled): void
|
||||
{
|
||||
$this->write('system', $enabled ? 'maintenance_on' : 'maintenance_off', 'success');
|
||||
}
|
||||
|
||||
/** Parametres: DB export */
|
||||
public function logDbExport(): void
|
||||
{
|
||||
$this->write('system', 'db_export', 'success');
|
||||
}
|
||||
|
||||
/** Parametres: delete all TFEs */
|
||||
public function logDeleteAllTheses(int $count): void
|
||||
{
|
||||
$this->write('system', 'delete_all_theses', 'success', ['count' => $count]);
|
||||
}
|
||||
|
||||
/** Parametres: formulaire section toggles */
|
||||
public function logFormSettingsUpdate(array $newValues): void
|
||||
{
|
||||
$this->write('settings', 'formulaire_update', 'success', ['values' => $newValues]);
|
||||
}
|
||||
|
||||
/** Parametres: objet types toggles */
|
||||
public function logObjetTypesUpdate(array $newValues): void
|
||||
{
|
||||
$this->write('settings', 'objet_types_update', 'success', ['values' => $newValues]);
|
||||
}
|
||||
|
||||
/** Parametres: access restriction settings */
|
||||
public function logAccessRestrictionUpdate(array $newValues): void
|
||||
{
|
||||
$this->write('settings', 'access_restriction_update', 'success', ['values' => $newValues]);
|
||||
}
|
||||
|
||||
/** Parametres: SMTP credentials update */
|
||||
public function logSmtpUpdate(bool $connectionOk): void
|
||||
{
|
||||
$this->write('settings', 'smtp_update', 'success', ['connection_ok' => $connectionOk]);
|
||||
}
|
||||
|
||||
/** Parametres: SMTP test */
|
||||
public function logSmtpTest(string $toEmail, bool $success, string $error = ''): void
|
||||
{
|
||||
$this->write('settings', 'smtp_test', $success ? 'success' : 'error', [
|
||||
'to' => $toEmail,
|
||||
'error' => $error ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Generic error entry (for catch blocks) */
|
||||
public function logError(string $resource, string $action, string $message, array $context = []): void
|
||||
{
|
||||
$this->write($resource, $action, 'error', array_merge($context, ['error' => $message]));
|
||||
}
|
||||
|
||||
// ── Core write ────────────────────────────────────────────────────────────
|
||||
|
||||
private function write(string $resource, string $action, string $status, array $context = []): void
|
||||
{
|
||||
$entry = [
|
||||
'timestamp' => date('c'),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'resource' => $resource,
|
||||
'action' => $action,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if (!empty($context)) {
|
||||
$entry['context'] = $context;
|
||||
}
|
||||
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
error_log($line, 3, $this->logFile);
|
||||
|
||||
if ($this->db !== null) {
|
||||
$this->insertDb($resource, $action, $status, $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function insertDb(string $resource, string $action, string $status, array $context): void
|
||||
{
|
||||
try {
|
||||
$pdo = $this->db->getConnection();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO admin_audit_log (ip, user_agent, resource, action, status, context)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$_SERVER['REMOTE_ADDR'] ?? 'cli',
|
||||
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
$resource,
|
||||
$action,
|
||||
$status,
|
||||
!empty($context) ? json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// DB logging is best-effort — never crash the app over it.
|
||||
error_log('[AdminLogger] DB insert failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,32 @@ class ShareLink
|
||||
}
|
||||
|
||||
/**
|
||||
* List all share links, ordered by creation date descending.
|
||||
* List active (non-archived) share links, ordered by creation date descending.
|
||||
*/
|
||||
public function listActive(): array
|
||||
{
|
||||
$stmt = $this->db->getConnection()->query(
|
||||
'SELECT * FROM share_links WHERE is_archived = 0 ORDER BY created_at DESC'
|
||||
);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* List archived share links, ordered by creation date descending.
|
||||
*/
|
||||
public function listArchived(): array
|
||||
{
|
||||
$stmt = $this->db->getConnection()->query(
|
||||
'SELECT * FROM share_links WHERE is_archived = 1 ORDER BY created_at DESC'
|
||||
);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all share links (active + archived), ordered by creation date descending.
|
||||
*
|
||||
* @return array
|
||||
* @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];
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
<h2 id="acces-liens-title">Accès étudiant·e</h2>
|
||||
<div class="admin-list-toolbar__right">
|
||||
<button type="button" class="admin-btn admin-btn--sm" id="open-create-dialog">
|
||||
+ Créer un lien
|
||||
+ Créer un lien
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?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: ?>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -92,12 +92,12 @@
|
||||
🔑
|
||||
</button>
|
||||
<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="action" value="delete">
|
||||
<input type="hidden" name="action" value="archive">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -107,6 +107,48 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<?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>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
@@ -158,7 +200,7 @@
|
||||
par <?= htmlspecialchars($req['authors']) ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($req['year'])): ?>
|
||||
— <?= htmlspecialchars($req['year']) ?>
|
||||
- <?= htmlspecialchars($req['year']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
@@ -264,7 +306,7 @@
|
||||
<div>
|
||||
<label for="create-objet">Type d'objet (optionnel)</label>
|
||||
<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="thèse">Thèse</option>
|
||||
<option value="frart">Frart</option>
|
||||
|
||||
@@ -88,6 +88,14 @@ chown -R "$WEB_USER:$APP_GROUP" "$APP_DIR/storage/cache"
|
||||
chmod -R 2775 "$APP_DIR/storage/cache"
|
||||
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"
|
||||
ok "Setup complete."
|
||||
printf "\nNext steps:\n"
|
||||
|
||||
Reference in New Issue
Block a user