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
This commit is contained in:
Pontoporeia
2026-05-04 16:29:31 +02:00
parent 0a05f3911c
commit a2cba6d3c0
35 changed files with 1726 additions and 1302 deletions

View File

@@ -189,8 +189,8 @@ class SystemController
}
$error = file_exists($livePath)
? "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($livePath)
: "Config live introuvable (" . htmlspecialchars($livePath) . ") et config locale introuvable (" . htmlspecialchars($localPath) . ").";
? '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];
}
@@ -202,12 +202,24 @@ class SystemController
*/
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';
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 '';
}
@@ -217,8 +229,12 @@ class SystemController
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';
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';
}
@@ -229,8 +245,12 @@ class SystemController
*/
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';
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';
}
@@ -267,8 +287,12 @@ class SystemController
*/
public static function diskColor(int $pct): string
{
if ($pct > 85) return '#e05555';
if ($pct > 70) return '#ffc107';
if ($pct > 85) {
return '#e05555';
}
if ($pct > 70) {
return '#ffc107';
}
return '#4caf50';
}
@@ -337,7 +361,8 @@ class SystemController
if ($dbExists) {
try {
$dbRowCount = $this->db->getThesisCount();
} catch (Throwable) {}
} catch (Throwable) {
}
}
$checks['database'] = [
'label' => 'Base de données SQLite',
@@ -358,7 +383,7 @@ class SystemController
'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,
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
]))
: 'Non accessible en écriture',
];
@@ -382,15 +407,15 @@ class SystemController
$errorMsg = null;
if (!function_exists('exec')) {
$errorMsg = "exec() est désactivé sur ce serveur.";
$errorMsg = 'exec() est désactivé sur ce serveur.';
return null;
}
if (!file_exists($logPath)) {
$errorMsg = "Fichier introuvable : " . htmlspecialchars($logPath);
$errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
return null;
}
if (!is_readable($logPath)) {
$errorMsg = "Fichier non lisible (permissions insuffisantes) : " . htmlspecialchars($logPath);
$errorMsg = 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($logPath);
return null;
}
@@ -399,7 +424,7 @@ class SystemController
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
if ($rc !== 0) {
$errorMsg = "Erreur lors de la lecture du fichier journal.";
$errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null;
}
@@ -411,7 +436,9 @@ class SystemController
*/
private function safeExec(string $cmd): ?string
{
if (!function_exists('exec')) return null;
if (!function_exists('exec')) {
return null;
}
$output = [];
$rc = 0;
exec($cmd . ' 2>/dev/null', $output, $rc);
@@ -424,7 +451,9 @@ class SystemController
private function systemdStatus(string $unit): ?string
{
$raw = $this->safeExec('systemctl is-active ' . escapeshellarg($unit));
if ($raw === null) return null;
if ($raw === null) {
return null;
}
return in_array($raw, ['active', 'inactive', 'failed', 'activating', 'deactivating'], true)
? $raw : 'unknown';
}
@@ -435,7 +464,9 @@ class SystemController
*/
private function localHttpCheck(string $url): ?array
{
if (!function_exists('curl_init')) return null;
if (!function_exists('curl_init')) {
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,