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

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