Files
xamxam/app/src/Controllers/SystemController.php
Pontoporeia a2cba6d3c0 feat: prevent duplicate TFE submissions with logging and user feedback
- Add DuplicateThesisException (typed, carries existing thesis metadata)
- Add Database::findDuplicateThesis(): matches on year + author + normalised
  title (exact, prefix, Levenshtein ≤10% of longer string)
- ThesisCreateController::submit() runs duplicate check before any DB write
  and throws DuplicateThesisException on match
- AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines
  log for audit purposes
- App::flash/consumeFlash extended to support 'warning' flash type
- admin/actions/formulaire.php: catches DuplicateThesisException, logs it,
  flashes an HTML warning toast with a clickable link to the existing thesis,
  and repopulates the form fields
- partage/index.php: same catch block; surfaces a plain-text flash-warning
  banner on the student form with identifier, title, and year of the match;
  form is repopulated via session
- toast.php: renders toast--warning variant
- admin.css: .toast--warning + link colour rules
- form.css: .flash-warning style for the partage form
2026-05-05 11:04:52 +02:00

488 lines
17 KiB
PHP

<?php
/**
* SystemController
*
* Centralises all data-fetching for the admin system page and its
* fetch()-based tab-panel fragment endpoint.
*
* Responsibilities:
* - System status checks (nginx, php-fpm, HTTP ping, database, storage,
* maintenance mode) with SystemCache TTL caching
* - PHP environment info (1-hour TTL)
* - Disk usage info (5-minute TTL)
* - Log file reading (tail, meta)
* - Nginx config file reading
* - Log/nginx line classifiers used by both system.php and system-fragment.php
*
* Both system.php (full page) and system-fragment.php (AJAX panel) delegate
* here so helpers are never duplicated.
*/
class SystemController
{
// ── Constants ─────────────────────────────────────────────────────────────
public const LOG_FILES = [
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log'],
'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/xamxam_error.log'],
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'],
];
public const ALLOWED_LINES = [50, 100, 200, 500];
/** Live deployed nginx config path. */
public const NGINX_CONFIG_LIVE = '/etc/nginx/sites-available/xamxam';
/** Local reference copy used as fallback in dev. */
public const NGINX_CONFIG_LOCAL = APP_ROOT . '/nginx/xamxam.conf';
// ── TTLs ──────────────────────────────────────────────────────────────────
private const TTL_STATUS = 120; // 2 minutes
private const TTL_PHP = 3600; // 1 hour
private const TTL_DISK = 300; // 5 minutes
private Database $db;
private SystemCache $cache;
public function __construct(Database $db, SystemCache $cache)
{
$this->db = $db;
$this->cache = $cache;
}
// ── Cache invalidation ────────────────────────────────────────────────────
/**
* Force-bust all cached sections (called on ?refresh=1).
*/
public function invalidateAll(): void
{
$this->cache->invalidate('system_status');
$this->cache->invalidate('disk_info');
$this->cache->invalidate('php_info');
}
// ── Status data ───────────────────────────────────────────────────────────
/**
* Return system status checks array, from cache when fresh.
*
* @return array{checks: array, cached: bool, cacheAge: ?int}
*/
public function getStatusData(): array
{
$cacheAge = $this->cache->ageSeconds('system_status');
$cached = $this->cache->get('system_status', self::TTL_STATUS);
if ($cached !== null) {
return ['checks' => $cached, 'cached' => true, 'cacheAge' => $cacheAge];
}
$checks = $this->runStatusChecks();
$this->cache->set('system_status', $checks);
return ['checks' => $checks, 'cached' => false, 'cacheAge' => 0];
}
/**
* Return PHP environment info, from cache when fresh.
*
* @return array<string, string>
*/
public function getPhpInfo(): array
{
$cached = $this->cache->get('php_info', self::TTL_PHP);
if ($cached !== null) {
return $cached;
}
$info = [
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'memory_limit' => (string) ini_get('memory_limit'),
'upload_max' => (string) ini_get('upload_max_filesize'),
'post_max' => (string) ini_get('post_max_size'),
'max_exec' => ini_get('max_execution_time') . 's',
];
$this->cache->set('php_info', $info);
return $info;
}
/**
* Return disk usage info, from cache when fresh.
*
* @return array{total: int, free: int, used: int, pct: int}
*/
public function getDiskInfo(): array
{
$cached = $this->cache->get('disk_info', self::TTL_DISK);
if ($cached !== null) {
return $cached;
}
$total = (int) disk_total_space(APP_ROOT);
$free = (int) disk_free_space(APP_ROOT);
$used = $total - $free;
$pct = $total > 0 ? (int) round((float) $used / (float) $total * 100.0) : 0;
$info = ['total' => $total, 'free' => $free, 'used' => $used, 'pct' => $pct];
$this->cache->set('disk_info', $info);
return $info;
}
// ── Log tab ───────────────────────────────────────────────────────────────
/**
* Read and return data for a log tab.
*
* @return array{lines: ?array, error: ?string, meta: ?array}
*/
public function getLogData(string $tab, int $n): array
{
$logPath = self::LOG_FILES[$tab]['path'];
$error = null;
$lines = $this->readLogTail($logPath, $n, $error);
$meta = null;
if (file_exists($logPath)) {
$sz = filesize($logPath);
$meta = [
'size' => $sz > 1048576
? number_format($sz / 1048576, 2) . ' MB'
: number_format($sz / 1024, 1) . ' KB',
'mtime' => date('d/m/Y H:i:s', filemtime($logPath)),
'path' => $logPath,
];
}
return ['lines' => $lines, 'error' => $error, 'meta' => $meta];
}
// ── Nginx config tab ──────────────────────────────────────────────────────
/**
* Read and return data for the nginx config tab.
*
* @return array{lines: ?array, source: ?string, meta: ?array, error: ?string}
*/
public function getNginxConfigData(): array
{
$livePath = self::NGINX_CONFIG_LIVE;
$localPath = self::NGINX_CONFIG_LOCAL;
foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) {
if (file_exists($path) && is_readable($path)) {
$raw = file($path, FILE_IGNORE_NEW_LINES);
if ($raw !== false) {
$sz = filesize($path);
$meta = [
'path' => $path,
'size' => $sz > 1048576
? number_format($sz / 1048576, 2) . ' MB'
: number_format($sz / 1024, 1) . ' KB',
'mtime' => date('d/m/Y H:i:s', filemtime($path)),
];
return ['lines' => $raw, 'source' => $src, 'meta' => $meta, 'error' => null];
}
}
}
$error = file_exists($livePath)
? 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($livePath)
: 'Config live introuvable (' . htmlspecialchars($livePath) . ') et config locale introuvable (' . htmlspecialchars($localPath) . ').';
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
}
// ── Line classifiers (used by both system.php and system-fragment.php) ────
/**
* Return the CSS class for a log line.
*/
public static function logLineClass(string $line): string
{
if (preg_match('/\[(crit|emerg|alert)\]/', $line)) {
return 'log-crit';
}
if (preg_match('/\[error\]/', $line)) {
return 'log-error';
}
if (preg_match('/\[warn\]/', $line)) {
return 'log-warn';
}
if (preg_match('/\[notice\]/', $line)) {
return 'log-notice';
}
if (preg_match('/" [45]\d\d /', $line)) {
return 'log-error';
}
if (preg_match('/" 3\d\d /', $line)) {
return 'log-notice';
}
return '';
}
/**
* Return the CSS class for a nginx config line.
*/
public static function nginxLineClass(string $line): string
{
$trimmed = ltrim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
return 'nginx-comment';
}
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) {
return 'nginx-block';
}
return 'nginx-directive';
}
// ── View helpers ──────────────────────────────────────────────────────────
/**
* Human-readable byte string (GB / MB / KB).
*/
public static function humanBytes(int $bytes): string
{
if ($bytes > 1073741824) {
return number_format($bytes / 1073741824, 1) . ' GB';
}
if ($bytes > 1048576) {
return number_format($bytes / 1048576, 1) . ' MB';
}
return number_format($bytes / 1024, 1) . ' KB';
}
/**
* French status label with leading symbol.
*/
public static function statusLabel(string $status): string
{
return match ($status) {
'active' => '● En ligne',
'inactive' => '○ Inactif',
'failed' => '✕ Erreur',
'warn' => '⚠ Attention',
default => '? Inconnu',
};
}
/**
* CSS class for a status value.
*/
public static function statusClass(string $status): string
{
return match ($status) {
'active' => 'status-ok',
'inactive' => 'status-warn',
'warn' => 'status-warn',
'failed' => 'status-err',
default => 'status-unknown',
};
}
/**
* CSS colour string for a disk-usage percentage.
*/
public static function diskColor(int $pct): string
{
if ($pct > 85) {
return '#e05555';
}
if ($pct > 70) {
return '#ffc107';
}
return '#4caf50';
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Execute all six status checks and return the checks array.
*/
private function runStatusChecks(): array
{
$checks = [];
// nginx
$nginxStatus = $this->systemdStatus('nginx');
$nginxVersion = $this->safeExec('nginx -v 2>&1 | head -1');
$checks['nginx'] = [
'label' => 'nginx',
'status' => $nginxStatus,
'detail' => $nginxVersion,
];
// php-fpm — probe running PHP version's unit first, then fall back
$phpFpmStatus = null;
$phpFpmUnit = null;
$phpMajMin = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
$fpmCandidates = array_unique([
'php' . $phpMajMin . '-fpm',
'php8.4-fpm', 'php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php-fpm',
]);
foreach ($fpmCandidates as $unit) {
$s = $this->systemdStatus($unit);
if ($s !== null && $s !== 'unknown') {
$phpFpmStatus = $s;
$phpFpmUnit = $unit;
break;
}
}
$checks['php_fpm'] = [
'label' => 'php-fpm' . ($phpFpmUnit ? " ($phpFpmUnit)" : ''),
'status' => $phpFpmStatus,
'detail' => null,
];
// Site HTTP ping
$siteUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/';
$httpResult = $this->localHttpCheck($siteUrl);
$checks['site_http'] = [
'label' => 'Site HTTP',
'status' => $httpResult !== null ? ($httpResult[0] < 500 ? 'active' : 'failed') : null,
'detail' => $httpResult !== null
? "HTTP {$httpResult[0]}{$httpResult[1]} ms"
: 'curl indisponible',
];
// Database
$dbPath = $this->db->getDatabasePath();
$dbExists = file_exists($dbPath);
$dbWritable = $dbExists && is_writable($dbPath);
$dbSizeBytes = $dbExists ? filesize($dbPath) : null;
$dbSizeHuman = $dbSizeBytes !== null
? ($dbSizeBytes > 1048576
? number_format($dbSizeBytes / 1048576, 1) . ' MB'
: number_format($dbSizeBytes / 1024, 1) . ' KB')
: 'N/A';
$dbRowCount = null;
if ($dbExists) {
try {
$dbRowCount = $this->db->getThesisCount();
} catch (Throwable) {
}
}
$checks['database'] = [
'label' => 'Base de données SQLite',
'status' => $dbExists ? ($dbWritable ? 'active' : 'inactive') : 'failed',
'detail' => $dbExists
? ($dbRowCount !== null ? "$dbRowCount thèses — $dbSizeHuman" : "Lecture impossible — $dbSizeHuman")
: 'Fichier introuvable',
];
// Storage directory
$storageDir = APP_ROOT . '/storage';
$storageWritable = is_dir($storageDir) && is_writable($storageDir);
$bannersDir = $storageDir . '/banners';
$coversDir = $storageDir . '/covers';
$checks['storage'] = [
'label' => 'Répertoire storage',
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
'detail' => $storageWritable
? implode(' · ', array_filter([
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
]))
: 'Non accessible en écriture',
];
// Maintenance mode
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
$checks['maintenance'] = [
'label' => 'Mode maintenance',
'status' => $maintenanceOn ? 'warn' : 'active',
'detail' => $maintenanceOn ? 'Activé — site public inaccessible' : 'Désactivé',
];
return $checks;
}
/**
* Read the tail of a log file, newest-first. Returns null on error.
*/
private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
{
$errorMsg = null;
if (!function_exists('exec')) {
$errorMsg = 'exec() est désactivé sur ce serveur.';
return null;
}
if (!file_exists($logPath)) {
$errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
return null;
}
if (!is_readable($logPath)) {
$errorMsg = 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($logPath);
return null;
}
$output = [];
$rc = 0;
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) {
$errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null;
}
return array_reverse($output); // newest first
}
/**
* Run a shell command safely, returning trimmed stdout or null on failure.
*/
private function safeExec(string $cmd): ?string
{
if (!function_exists('exec')) {
return null;
}
$output = [];
$rc = 0;
exec($cmd . ' 2>/dev/null', $output, $rc);
return $rc === 0 ? trim(implode("\n", $output)) : null;
}
/**
* Query systemd for a unit's active state.
*/
private function systemdStatus(string $unit): ?string
{
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
if ($raw === null) {
return null;
}
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
? $raw : 'unknown';
}
/**
* Perform a lightweight HEAD request to $url and return [httpCode, ms].
* Returns null if curl is unavailable.
*/
private function localHttpCheck(string $url): ?array
{
if (!function_exists('curl_init')) {
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
]);
$start = microtime(true);
curl_exec($ch);
$ms = (int) round((microtime(true) - $start) * 1000.0);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return $code > 0 ? [$code, $ms] : null;
}
}