mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +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:
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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user