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
/**
* ThesisEditController
*
@@ -68,12 +69,12 @@ class ThesisEditController
public function load(int $thesisId): array
{
if ($thesisId <= 0) {
throw new InvalidArgumentException("ID invalide");
throw new InvalidArgumentException('ID invalide');
}
$thesis = $this->db->getThesis($thesisId);
if (!$thesis) {
throw new RuntimeException("TFE non trouvé");
throw new RuntimeException('TFE non trouvé');
}
$currentLanguages = $this->db->getThesisLanguageIds($thesisId);
@@ -157,7 +158,7 @@ class ThesisEditController
public function save(int $thesisId, array $post, array $files): void
{
if ($thesisId <= 0) {
throw new InvalidArgumentException("ID de TFE invalide.");
throw new InvalidArgumentException('ID de TFE invalide.');
}
$this->db->beginTransaction();
@@ -253,7 +254,9 @@ class ThesisEditController
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) @unlink($abs);
if (file_exists($abs)) {
@unlink($abs);
}
}
break;
}
@@ -267,11 +270,15 @@ class ThesisEditController
? array_map('intval', $post['delete_files'])
: [];
foreach ($deleteIds as $fileId) {
if ($fileId <= 0) continue;
if ($fileId <= 0) {
continue;
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) @unlink($abs);
if (file_exists($abs)) {
@unlink($abs);
}
}
}
@@ -284,7 +291,9 @@ class ThesisEditController
if (!empty($post['file_label']) && is_array($post['file_label'])) {
foreach ($post['file_label'] as $fileId => $label) {
$fileId = (int)$fileId;
if ($fileId <= 0) continue;
if ($fileId <= 0) {
continue;
}
$this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null);
}
}
@@ -360,7 +369,9 @@ class ThesisEditController
$count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) {
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
continue;
@@ -409,8 +420,12 @@ class ThesisEditController
$relPath = "theses/{$year}/{$folderName}/" . $candidate;
$this->db->insertThesisFile(
$thesisId, $fileType, $relPath,
basename($originalName), $uploads['size'][$i], $mimeType,
$thesisId,
$fileType,
$relPath,
basename($originalName),
$uploads['size'][$i],
$mimeType,
$label !== '' ? $label : null,
$sortOrder
);
@@ -423,11 +438,21 @@ class ThesisEditController
*/
private function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') return 'caption';
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) return 'audio';
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) return 'video';
if ($mimeType === 'application/pdf' || $ext === 'pdf') return 'main';
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) return 'image';
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 'audio';
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 'video';
}
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 'main';
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 'image';
}
return 'other';
}
@@ -437,8 +462,8 @@ class ThesisEditController
{
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $authorName) : $authorName;
$accents = [
'à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c',
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
];
$n = strtr($n, $accents);
$slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_'));
@@ -451,11 +476,13 @@ class ThesisEditController
$name = pathinfo($filename, PATHINFO_FILENAME);
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name;
$accents = [
'à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c',
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
];
$n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_');
if ($n === '') $n = 'file';
if ($n === '') {
$n = 'file';
}
return $ext !== '' ? $n . '.' . strtolower($ext) : $n;
}
@@ -480,10 +507,18 @@ class ThesisEditController
*/
public static function autofocusFieldForError(string $message): ?string
{
if (str_contains($message, 'titre') || str_contains($message, 'Titre')) return 'titre';
if (str_contains($message, 'année') || str_contains($message, 'Année')) return 'année';
if (str_contains($message, 'synopsis') || str_contains($message, 'Synopsis')) return 'synopsis';
if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) return 'auteurice';
if (str_contains($message, 'titre') || str_contains($message, 'Titre')) {
return 'titre';
}
if (str_contains($message, 'année') || str_contains($message, 'Année')) {
return 'année';
}
if (str_contains($message, 'synopsis') || str_contains($message, 'Synopsis')) {
return 'synopsis';
}
if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) {
return 'auteurice';
}
return null;
}