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

@@ -1,4 +1,5 @@
<?php
/**
* MediaController
*
@@ -60,7 +61,7 @@ class MediaController
exit;
}
} catch (\Throwable $e) {
error_log("MediaController visibility check error: " . $e->getMessage());
error_log('MediaController visibility check error: ' . $e->getMessage());
}
}
@@ -119,7 +120,9 @@ class MediaController
header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
header('Cache-Control: public, max-age=604800');
if (!$forceDownload) header('Content-Disposition: inline');
if (!$forceDownload) {
header('Content-Disposition: inline');
}
} elseif ($ext === 'pdf') {
header('Cache-Control: public, max-age=86400');
header('Content-Disposition: ' . ($forceDownload ? 'attachment' : 'inline'));
@@ -163,10 +166,15 @@ class MediaController
}
[, $range] = explode('=', $range, 2);
[$start, $end] = array_pad(explode('-', $range, 2), 2, '');
$start = ($start === '') ? 0 : (int)$start;
$end = ($end === '') ? $size - 1 : (int)$end;
if ($end >= $size) $end = $size - 1;
if ($start > $end) { http_response_code(416); exit; }
$start = ($start === '') ? 0 : (int)$start;
$end = ($end === '') ? $size - 1 : (int)$end;
if ($end >= $size) {
$end = $size - 1;
}
if ($start > $end) {
http_response_code(416);
exit;
}
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
@@ -176,12 +184,17 @@ class MediaController
}
$fp = fopen($path, 'rb');
if ($fp === false) { http_response_code(500); exit; }
if ($fp === false) {
http_response_code(500);
exit;
}
fseek($fp, $start);
$remaining = $end - $start + 1;
while ($remaining > 0 && !feof($fp)) {
$chunk = fread($fp, min(8192, $remaining));
if ($chunk === false) break;
if ($chunk === false) {
break;
}
echo $chunk;
$remaining -= strlen($chunk);
}