From ab6e266807797e3bee7d36224aa7926c428cc9c1 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Sun, 10 May 2026 14:06:05 +0200 Subject: [PATCH] fix: add help email, preserve file names on validation error, license fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share link (partage) form does not expose a license field and does not send access_type_id (defaults to 2/Interne). Server-side validation was unconditionally requiring a license for non-admin submissions, causing all share link submissions to fail. Now the license check is gated on adminMode=false AND accessTypeId=1 (Libre), matching the client-side HTMX fragment behaviour in licence-fragment.php. Also fixed a use-before-definition where accessTypeId was referenced before being assigned. Student form improvements: - Add xamxam@erg.be mailto link at top of form - On validation error, append "Si le problème persiste, envoyez un e-mail à xamxam@erg.be" to the flash message - Preserve uploaded file names across validation redirects: store in session (share_primed_files_), display as warning on form re-render so the student knows which files to re-select - License: only required for non-admin when access_type_id=1 (Libre), not for Interne (2) or Interdit (3). Fixes share link submissions failing with "Veuillez sélectionner une licence". Also fixed use-before-definition of accessTypeId. --- TODO.md | 22 + .../pending/027_drop_banner_path.sql | 10 + app/public/partage/index.php | 73 ++- app/src/Controllers/SystemController.php | 2 - .../Controllers/ThesisCreateController.php | 337 +--------- app/src/Controllers/ThesisEditController.php | 270 ++------ app/src/Controllers/ThesisFileHandler.php | 591 ++++++++++++++++++ app/src/Database.php | 17 +- app/storage/schema.sql | 4 - app/templates/admin/acces.php | 13 + app/tests/Unit/PureLogicTest.php | 42 +- 11 files changed, 828 insertions(+), 553 deletions(-) create mode 100644 app/migrations/pending/027_drop_banner_path.sql create mode 100644 app/src/Controllers/ThesisFileHandler.php diff --git a/TODO.md b/TODO.md index eb63af4..d7f8652 100644 --- a/TODO.md +++ b/TODO.md @@ -4,3 +4,25 @@ - [x] Both fragments now follow identical patterns - [x] Fix "Créer" button not appearing on language search: both language and tag inputs used name="q" in the same form, causing HTMX to submit the wrong (empty) value — renamed to unique names (language_search_q / tag_search_q) - [x] Exclude Français, Anglais, Néerlandais from language-search suggestions (handled by the checkbox list) +- [x] Refactor file upload naming convention + - [x] Create shared ThesisFileHandler trait (src/Controllers/ThesisFileHandler.php) + - [x] New pattern: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ + - [x] COUVERTURE: single cover image in thesis folder (covers/ directory deprecated) + - [x] NOTE_INTENTION: single PDF in thesis folder + - [x] TFE_{XX}: main files, contiguous numbering 01+, hierarchy PDF > video > audio > subtitles > images > other + - [x] Subtitles (VTT) placed immediately after their associated video in TFE sequence + - [x] ANNEXE_{XX}: annex files, separate numbering 01+ + - [x] Two-digit zero-padded numbering (sprintf('%02d', ...)) + - [x] Update ThesisCreateController.php: use trait, new file handling + - [x] Update ThesisEditController.php: use trait, new file handling + - [x] Remove duplicate methods (generateAuthorSlug, sanitizeFilename, etc.) from both controllers + - [x] Update Database.php: deprecate handleCoverUpload, remove banner_path from queries + - [x] Update SystemController.php: remove banners/ stats + - [x] Update schema.sql: remove banner_path column and view field + - [x] Create migration 027_drop_banner_path.sql + - [x] Update PureLogicTest.php: adapt detectFileType call signature + - [x] All pure logic tests pass +- [x] Fix license validation: only require license for non-admin when access_type_id=1 (Libre), not for Interne (2) or Interdit (3) — fixes share link submissions failing with "Veuillez sélectionner une licence" +- [x] Add xamxam@erg.be mailto link at top of student (partage) form +- [x] On validation error, append "envoyez un e-mail à xamxam@erg.be" to flash error message +- [x] Preserve uploaded file names across validation redirects: store in session, display as warning on re-render so the student knows which files to re-select diff --git a/app/migrations/pending/027_drop_banner_path.sql b/app/migrations/pending/027_drop_banner_path.sql new file mode 100644 index 0000000..d4baf59 --- /dev/null +++ b/app/migrations/pending/027_drop_banner_path.sql @@ -0,0 +1,10 @@ +-- Migration 027: drop banner_path column from theses table. +-- Banners were merged into covers in migration 016; the column has been +-- vestigial since. This is safe to run even if the column is already absent. +-- Safe to re-run: IF EXISTS makes it idempotent. + +-- SQLite does not support DROP COLUMN directly in older versions; +-- we use the ALTER TABLE … DROP COLUMN syntax (supported since SQLite 3.35.0). +-- If this fails on an older SQLite, the column stays as-is (harmless). + +ALTER TABLE theses DROP COLUMN banner_path; diff --git a/app/public/partage/index.php b/app/public/partage/index.php index b4137a0..580103c 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -378,6 +378,27 @@ function renderShareLinkForm(string $slug, array $link): void

Formulaire pour XAMXAM

+ + + 0): ?> + + + @@ -507,8 +528,9 @@ function handleShareLinkSubmission(string $slug): void // Store as plain text — htmlspecialchars() is applied at render time. $_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré.' . "\n" . $e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')' - . "\nSi vous pensez qu'il s'agit d'une erreur, veuillez contacter l'équipe."; + . "\nSi vous pensez qu'il s'agit d'une erreur, vous pouvez contacter l'équipe à xamxam@erg.be."; $_SESSION['form_data_share_' . $slug] = $_POST; + storePrimedFiles($slug); $_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token header('Location: /partage/' . urlencode($slug)); @@ -522,8 +544,10 @@ function handleShareLinkSubmission(string $slug): void ]); ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]); - $_SESSION['_flash_error'] = ErrorHandler::userMessage($e); + $_SESSION['_flash_error'] = ErrorHandler::userMessage($e) + . "\n\nSi le problème persiste, envoyez un e-mail à xamxam@erg.be."; $_SESSION['form_data_share_' . $slug] = $_POST; + storePrimedFiles($slug); $_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token // Redirect back to the form @@ -549,3 +573,48 @@ function old(array $data, string $key, string $default = ''): string { } return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value); } + +/** + * Store names of uploaded files in session so they can be shown + * to the user on form re-render after a validation error. + * + * $_FILES is not preserved across redirects, but the user should know + * which files they need to re-select. + */ +function storePrimedFiles(string $slug): void +{ + $names = []; + if (!empty($_FILES['couverture']['name'])) { + $names[] = 'Couverture : ' . $_FILES['couverture']['name']; + } + if (!empty($_FILES['note_intention']['name'])) { + $names[] = 'Note d\'intention : ' . $_FILES['note_intention']['name']; + } + if (!empty($_FILES['files']['name']) && is_array($_FILES['files']['name'])) { + foreach ($_FILES['files']['name'] as $name) { + if ($name !== '' && $name !== null) { + $names[] = 'TFE : ' . $name; + } + } + } + if (!empty($_FILES['annexes']['name'])) { + if (is_array($_FILES['annexes']['name'])) { + foreach ($_FILES['annexes']['name'] as $name) { + if ($name !== '' && $name !== null) { + $names[] = 'Annexe : ' . $name; + } + } + } else { + $names[] = 'Annexe : ' . $_FILES['annexes']['name']; + } + } + if (!empty($_FILES['peertube_video']['name'])) { + $names[] = 'Vidéo PeerTube : ' . $_FILES['peertube_video']['name']; + } + if (!empty($_FILES['peertube_audio']['name'])) { + $names[] = 'Audio PeerTube : ' . $_FILES['peertube_audio']['name']; + } + if (!empty($names)) { + $_SESSION['share_primed_files_' . $slug] = $names; + } +} diff --git a/app/src/Controllers/SystemController.php b/app/src/Controllers/SystemController.php index 381d462..728f9cf 100644 --- a/app/src/Controllers/SystemController.php +++ b/app/src/Controllers/SystemController.php @@ -375,14 +375,12 @@ class SystemController // 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', diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index f68ba9e..243365c 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -1,5 +1,7 @@ generateAuthorSlug($allAuthorsStr); // ── 3–4. DB writes in a transaction ─────────────────────────────────── $this->db->beginTransaction(); @@ -226,8 +188,16 @@ class ThesisCreateController } // ── 5. File uploads (outside transaction — filesystem ops) ──────────── - $this->handleCoverUpload($thesisId, $files['couverture'] ?? null); - $this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post); + $tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']); + $folderName = $this->ensureUniqueFolder($tf['folderPath']); + // Rebuild path with potentially modified folder name + $folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/'; + $filePrefix = $folderName; + + $this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix); + $this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix); + $nextNum = $this->handleTfeFiles($thesisId, $files['files'] ?? null, $folderPath, $filePrefix, $post, 1); + // PeerTube file rows don't go on disk, but the uploads themselves are processed separately // ── 5b. PeerTube video / audio uploads ──────────────────────────────── $this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_video'); @@ -495,18 +465,20 @@ class ThesisCreateController throw new Exception('Veuillez sélectionner au moins un format.'); } - $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; - $licenseCustom = trim($post['license_custom'] ?? ''); - if (!$adminMode && !$licenseId && $licenseCustom === '') { - throw new Exception('Veuillez sélectionner une licence ou en préciser une.'); - } - // Access type — must be one of the enabled types; default 2 (Interne) $accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT); if ($accessTypeId === false || $accessTypeId <= 0) { $accessTypeId = 2; // Interne } + $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; + $licenseCustom = trim($post['license_custom'] ?? ''); + // License is only required for non-admin when access type is Libre (1). + // For Interne (2) and Interdit (3), it's optional. + if (!$adminMode && $accessTypeId === 1 && !$licenseId && $licenseCustom === '') { + throw new Exception('Veuillez sélectionner une licence ou en préciser une.'); + } + // Objet — restricted to valid values $validObjet = ['tfe', 'thèse', 'frart']; $objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe'; @@ -570,176 +542,6 @@ class ThesisCreateController // ── Private: file uploads ───────────────────────────────────────────────── - /** - * Process an optional cover image upload and record it in thesis_files. - * - * @param int $thesisId - * @param array|null $upload Single-file $_FILES entry (may be null or have UPLOAD_ERR_NO_FILE). - */ - private function handleCoverUpload(int $thesisId, ?array $upload): void - { - if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) { - return; - } - - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($upload['tmp_name']); - $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); - - if (!in_array($mimeType, ['image/jpeg', 'image/png'], true) - || !in_array($ext, ['jpg', 'jpeg', 'png'], true)) { - error_log("ThesisCreateController: invalid cover MIME $mimeType, skipping"); - return; - } - - $coverDir = STORAGE_ROOT . '/covers/'; - if (!is_dir($coverDir)) { - mkdir($coverDir, 0755, true); - } - - $safeName = bin2hex(random_bytes(16)) . '.' . $ext; - $targetPath = $coverDir . $safeName; - - if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { - error_log("ThesisCreateController: failed to move cover to $targetPath"); - return; - } - - chmod($targetPath, 0644); - $relPath = 'covers/' . $safeName; - - $this->db->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType); - error_log("ThesisCreateController: cover uploaded → $safeName"); - } - - /** - * Process multiple thesis-file uploads (PDFs, images, videos, ZIPs, VTTs). - * - * @param int $thesisId - * @param int $year Used for the storage sub-directory path. - * @param string $identifier Thesis identifier slug (e.g. "2024-003"). - * @param array|null $uploads Multi-file $_FILES entry (may be null). - * @param string $authorSlug Pre-computed author slug for folder and file naming. - */ - private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorSlug, array $post = []): void - { - if (!$uploads || !is_array($uploads['name'] ?? null)) { - return; - } - - $folderName = $this->ensureUniqueFolder($year, $authorSlug); - $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; - - if (!is_dir($uploadDir)) { - mkdir($uploadDir, 0755, true); - } - - // Per-file labels and sort orders submitted alongside the upload inputs - $fileLabels = $post['file_labels'] ?? []; - $fileOrders = $post['file_orders'] ?? []; - - $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] ?? -1) !== UPLOAD_ERR_OK) { - error_log("ThesisCreateController: upload error code {$uploads['error'][$i]} for {$uploads['name'][$i]}"); - continue; - } - - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($uploads['tmp_name'][$i]); - $ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION)); - - // finfo may return 'text/plain' for WebVTT on some systems. - if ($mimeType === 'text/plain' && $ext === 'vtt') { - $mimeType = 'text/vtt'; - } - // application/octet-stream is a valid fallback for arbitrary downloadable files - if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { - error_log("ThesisCreateController: extension not allowed {$uploads['name'][$i]} ($ext), skipping"); - continue; - } - - if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) - && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { - error_log("ThesisCreateController: invalid file type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); - continue; - } - - $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); - $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE; - if ($uploads['size'][$i] > $sizeLimit) { - error_log("ThesisCreateController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping'); - continue; - } - - // Sanitize original filename and prepend author slug - $originalName = $uploads['name'][$i]; - $sanitized = $this->sanitizeFilename($originalName); - $prefix = $authorSlug . '_' . $sanitized; - // Ensure unique filename in the folder - $candidate = $prefix; - $suffix = 1; - while (file_exists($uploadDir . $candidate)) { - $candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext; - $suffix++; - } - $targetName = $candidate; - $targetPath = $uploadDir . $targetName; - - if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { - error_log("ThesisCreateController: failed to move file {$originalName}"); - continue; - } - - chmod($targetPath, 0644); - - $fileType = $this->detectFileType($mimeType, $ext, $originalName); - - $label = trim($fileLabels[$i] ?? ''); - $sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null; - - $relPath = "theses/{$year}/{$folderName}/" . $targetName; - $this->db->insertThesisFile( - $thesisId, - $fileType, - $relPath, - basename($originalName), - $uploads['size'][$i], - $mimeType, - $label !== '' ? $label : null, - $sortOrder - ); - error_log("ThesisCreateController: file uploaded → $targetName ($fileType)"); - } - } - - /** - * Determine the logical file_type from MIME type, extension, and original filename. - */ - protected 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'; - } - return 'other'; - } - // ── Private: input helpers ──────────────────────────────────────────────── /** @@ -765,101 +567,6 @@ class ThesisCreateController return $value; } - /** - * Generate a filesystem-safe author slug from the author name. - * Converts to uppercase, replaces spaces with underscores, removes accents. - */ - protected function generateAuthorSlug(string $authorName): string - { - // Remove accents using iconv if available, otherwise simple mapping - $normalized = $authorName; - if (function_exists('iconv')) { - $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); - } - // Fallback accent removal for common French characters - $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', - ]; - $normalized = strtr($normalized, $accents); - // Replace spaces and punctuation with underscore, keep only alphanumeric and underscore - $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized); - $slug = trim($slug, '_'); - // Convert to uppercase - $slug = strtoupper($slug); - // Ensure not empty - if ($slug === '') { - $slug = 'AUTHOR'; - } - return $slug; - } - - /** - * Sanitize a filename: remove accents, replace spaces with underscore, remove special chars. - * Keeps extension. - */ - protected function sanitizeFilename(string $filename): string - { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - $name = pathinfo($filename, PATHINFO_FILENAME); - // Remove accents similarly - $normalized = $name; - if (function_exists('iconv')) { - $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); - } - $accents = [ - 'à' => 'a', 'â' => 'a', 'ä' => 'a', - 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', - 'î' => 'i', 'ï' => 'i', - 'ô' => 'o', 'ö' => 'o', - 'ù' => 'u', 'û' => 'u', 'ü' => 'u', - 'ç' => 'c', - ]; - $normalized = strtr($normalized, $accents); - // Replace non-alphanumeric with underscore - $normalized = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized); - $normalized = trim($normalized, '_'); - // If empty, use 'file' - if ($normalized === '') { - $normalized = 'file'; - } - // Reattach extension if any - if ($ext !== '') { - return $normalized . '.' . strtolower($ext); - } - return $normalized; - } - - /** - * Find a unique folder name inside theses/{year}/. - * Pattern: {year}_{authorSlug} or {year}_{authorSlug}_{suffix} if exists. - */ - protected function ensureUniqueFolder(int $year, string $authorSlug): string - { - $baseDir = STORAGE_ROOT . '/theses/' . $year . '/'; - if (!is_dir($baseDir)) { - // No conflict possible, return base name - return $year . '_' . $authorSlug; - } - $candidate = $year . '_' . $authorSlug; - $suffix = 1; - while (is_dir($baseDir . $candidate)) { - $candidate = $year . '_' . $authorSlug . '_' . $suffix; - $suffix++; - } - return $candidate; - } - /** * Upload a video or audio file to PeerTube when the feature is enabled. * diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 677d176..56a7d7c 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -1,5 +1,7 @@ buildThesisFolder($year, $authors, $title); + $folderPath = $tf['folderPath']; + $filePrefix = $tf['filePrefix']; + + // Reuse existing folder if this thesis already has files on disk + $existingFiles = $this->db->getThesisFiles($thesisId); + foreach ($existingFiles as $f) { + $fp = $f['file_path'] ?? ''; + if (str_starts_with($fp, 'theses/')) { + $parts = explode('/', $fp); + if (count($parts) >= 3) { + $folderName = $parts[2]; + $folderPath = 'theses/' . $year . '/' . $folderName . '/'; + $filePrefix = $folderName; + break; + } + } + } + + // Ensure the folder exists + $dirAbs = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dirAbs)) { + mkdir($dirAbs, 0755, true); + } + // ── Cover image (outside transaction — filesystem op) ───────────────── if (isset($post['remove_cover'])) { - $allFiles = $this->db->getThesisFiles($thesisId); - foreach ($allFiles as $f) { + foreach ($existingFiles as $f) { if ($f['file_type'] === 'cover') { $this->db->deleteThesisFile((int)$f['id'], $thesisId); if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { @@ -321,9 +354,27 @@ class ThesisEditController } } } else { - $this->db->handleCoverUpload($thesisId, $files['couverture'] ?? null); + $this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix); } + // ── Note d'intention (replace if uploaded) ──────────────────────────── + // Remove old note_intention row+file if new one is uploaded + if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) { + foreach ($existingFiles as $f) { + if ($f['file_type'] === 'note_intention') { + $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); + } + } + break; + } + } + } + $this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix); + // ── Delete individual thesis files ──────────────────────────────────── $deleteIds = isset($post['delete_files']) && is_array($post['delete_files']) ? array_map('intval', $post['delete_files']) @@ -360,9 +411,18 @@ class ThesisEditController } } - // ── New thesis files upload ─────────────────────────────────────────── + // ── New TFE files upload ───────────────────────────────────────────── if (!empty($files['files']['name'][0])) { - $this->handleThesisFiles($thesisId, $post, $files['files']); + // Count existing TFE files to determine starting number + $tfeCount = 0; + foreach ($existingFiles as $f) { + if (!in_array($f['file_type'] ?? '', ['cover', 'note_intention', 'website', 'annex', 'caption'], true) + && !str_starts_with($f['file_path'] ?? '', 'http')) { + $tfeCount++; + } + // Don't count captions as separate TFE entries — they'll be renumbered + } + $this->handleTfeFiles($thesisId, $files['files'], $folderPath, $filePrefix, $post, $tfeCount + 1); } // ── PeerTube video / audio uploads ──────────────────────────────────── @@ -373,206 +433,6 @@ class ThesisEditController $this->handleWebsiteUrl($thesisId, $post); } - // ── Private: file uploads ───────────────────────────────────────────────── - - /** - * Process multiple new thesis-file uploads. - * - * Files are stored in the existing folder used by this thesis (detected - * from any current thesis_files row), or a new one is created following - * the same {year}_{authorSlug} convention as ThesisCreateController. - */ - private function handleThesisFiles(int $thesisId, array $post, array $uploads): void - { - $allowedMimes = [ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', - 'application/pdf', - 'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', - 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', - 'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4', - 'text/vtt', - 'application/zip', 'application/x-zip-compressed', - 'application/x-tar', 'application/gzip', - 'application/octet-stream', - ]; - $allowedExts = [ - 'jpg', 'jpeg', 'png', 'gif', 'webp', - 'pdf', - 'mp4', 'webm', 'ogv', 'mov', - 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a', - 'vtt', - 'zip', 'tar', 'gz', 'tgz', - ]; - $maxBytes = 500 * 1024 * 1024; // 500 MB - $maxPdfBytes = 100 * 1024 * 1024; // 100 MB for PDFs - - $year = (int)($post['année'] ?? date('Y')); - $authorName = trim($post['auteurice'] ?? 'unknown'); - - // Sort the raw comma-separated string alphabetically, then slugify. - $names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn ($n) => $n !== '')); - sort($names, SORT_NATURAL); - $authorSlug = $this->generateAuthorSlug(implode(', ', $names)); - - // Per-file labels and sort orders submitted alongside the upload inputs - $fileLabels = $post['file_labels'] ?? []; - $fileOrders = $post['file_orders'] ?? []; - - // Reuse existing folder if possible - $existingFiles = $this->db->getThesisFiles($thesisId); - $uploadDir = null; - $folderName = null; - foreach ($existingFiles as $f) { - if (str_starts_with($f['file_path'] ?? '', 'theses/')) { - $parts = explode('/', $f['file_path']); - if (count($parts) >= 3) { - $folderName = $parts[2]; - $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; - break; - } - } - } - if ($uploadDir === null) { - $folderName = $this->ensureUniqueFolder($year, $authorSlug); - $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; - } - - if (!is_dir($uploadDir)) { - mkdir($uploadDir, 0755, true); - } - - $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] ?? -1) !== UPLOAD_ERR_OK) { - error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}"); - continue; - } - - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($uploads['tmp_name'][$i]); - $ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION)); - - if ($mimeType === 'text/plain' && $ext === 'vtt') { - $mimeType = 'text/vtt'; - } - - // Allow any ext-matched file even if finfo returns application/octet-stream - if (!in_array($mimeType, $allowedMimes, true) && !in_array($ext, $allowedExts, true)) { - error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); - continue; - } - - $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); - $sizeLimit = $isPdf ? $maxPdfBytes : $maxBytes; - if ($uploads['size'][$i] > $sizeLimit) { - error_log("ThesisEditController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping'); - continue; - } - - $originalName = $uploads['name'][$i]; - $sanitized = $this->sanitizeFilename($originalName); - $prefix = $authorSlug . '_' . $sanitized; - $candidate = $prefix; - $suffix = 1; - while (file_exists($uploadDir . $candidate)) { - $candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext; - $suffix++; - } - - $targetPath = $uploadDir . $candidate; - if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { - error_log("ThesisEditController: failed to move {$originalName}"); - continue; - } - - chmod($targetPath, 0644); - - $fileType = $this->detectFileType($mimeType, $ext, $originalName); - $label = trim($fileLabels[$i] ?? ''); - $sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null; - - $relPath = "theses/{$year}/{$folderName}/" . $candidate; - $this->db->insertThesisFile( - $thesisId, - $fileType, - $relPath, - basename($originalName), - $uploads['size'][$i], - $mimeType, - $label !== '' ? $label : null, - $sortOrder - ); - error_log("ThesisEditController: uploaded → $candidate ($fileType)"); - } - } - - /** - * Determine the logical file_type from MIME type, extension, and original filename. - */ - 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'; - } - return 'other'; - } - - // ── Private: string helpers ─────────────────────────────────────────────── - - private function generateAuthorSlug(string $authorName): string - { - $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', - ]; - $n = strtr($n, $accents); - $slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_')); - return $slug !== '' ? $slug : 'AUTHOR'; - } - - private function sanitizeFilename(string $filename): string - { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - $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', - ]; - $n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_'); - if ($n === '') { - $n = 'file'; - } - return $ext !== '' ? $n . '.' . strtolower($ext) : $n; - } - - private function ensureUniqueFolder(int $year, string $authorSlug): string - { - $baseDir = STORAGE_ROOT . '/theses/' . $year . '/'; - $candidate = $year . '_' . $authorSlug; - $suffix = 1; - while (is_dir($baseDir . $candidate)) { - $candidate = $year . '_' . $authorSlug . '_' . $suffix++; - } - return $candidate; - } - // ── WCAG 3.3.1 helper ───────────────────────────────────────────────────── /** diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php new file mode 100644 index 0000000..634ee0e --- /dev/null +++ b/app/src/Controllers/ThesisFileHandler.php @@ -0,0 +1,591 @@ +file($upload['tmp_name']); + $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); + + $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; + $allowedExts = ['jpg', 'jpeg', 'png', 'webp']; + + if (!in_array($mimeType, $allowedMimes, true) + || !in_array($ext, $allowedExts, true) + || $upload['size'] > self::MAX_COVER_SIZE) { + error_log("ThesisFileHandler: invalid cover MIME $mimeType / $ext / {$upload['size']} bytes, skipping"); + return; + } + + $dir = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $targetName = $filePrefix . '_COUVERTURE.' . $ext; + $targetPath = $dir . $targetName; + + if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { + error_log("ThesisFileHandler: failed to move cover to $targetPath"); + return; + } + + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + + $this->db->insertThesisFile( + $thesisId, 'cover', + $relPath, + basename($upload['name']), + $upload['size'], + $mimeType + ); + error_log("ThesisFileHandler: cover uploaded → $relPath"); + } + + /** + * Process the note d'intention upload (single PDF). + * + * @param int $thesisId + * @param array|null $upload Single-file $_FILES entry. + * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder. + * @param string $filePrefix The shared file prefix. + */ + protected function handleNoteIntentionUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void + { + if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) { + return; + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($upload['tmp_name']); + $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); + + if ($mimeType !== 'application/pdf' || $ext !== 'pdf') { + error_log("ThesisFileHandler: invalid note d'intention MIME $mimeType, skipping"); + return; + } + if ($upload['size'] > self::MAX_PDF_SIZE) { + error_log("ThesisFileHandler: note d'intention too large ({$upload['size']} bytes), skipping"); + return; + } + + $dir = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $targetName = $filePrefix . '_NOTE_INTENTION.pdf'; + $targetPath = $dir . $targetName; + + if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { + error_log("ThesisFileHandler: failed to move note d'intention to $targetPath"); + return; + } + + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + + $this->db->insertThesisFile( + $thesisId, 'note_intention', + $relPath, + basename($upload['name']), + $upload['size'], + 'application/pdf' + ); + error_log("ThesisFileHandler: note d'intention uploaded → $relPath"); + } + + /** + * Process multiple TFE file uploads (files[] — the main set). + * + * Files are stored with TFE_{XX} semantics. Numbering is contiguous + * across all sub-types in order: PDF → video → audio → subtitles → images → archives. + * Subtitles (VTT) are placed immediately after their associated video. + * + * @param int $thesisId + * @param array|null $uploads Multi-file $_FILES entry. + * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder. + * @param string $filePrefix The shared file prefix. + * @param array $post $_POST for per-file labels/orders. + * @param int $startNum Starting number for TFE_XX (usually 01). + */ + protected function handleTfeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = [], int $startNum = 1): int + { + if (!$uploads || !is_array($uploads['name'] ?? null)) { + return $startNum; + } + + $dir = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $fileLabels = $post['file_labels'] ?? []; + $fileOrders = $post['file_orders'] ?? []; + + // Collect all files, classify, sort by hierarchy + $files = []; + $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] ?? -1) !== UPLOAD_ERR_OK) { + error_log("ThesisFileHandler: TFE upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}"); + continue; + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($uploads['tmp_name'][$i]); + $ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION)); + + if ($mimeType === 'text/plain' && $ext === 'vtt') { + $mimeType = 'text/vtt'; + } + if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + error_log("ThesisFileHandler: TFE extension not allowed {$uploads['name'][$i]} ($ext), skipping"); + continue; + } + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) + && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + error_log("ThesisFileHandler: invalid TFE type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); + continue; + } + + $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); + $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE; + if ($uploads['size'][$i] > $sizeLimit) { + error_log("ThesisFileHandler: TFE file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping'); + continue; + } + + $files[] = [ + 'index' => $i, + 'mimeType' => $mimeType, + 'ext' => $ext, + 'size' => $uploads['size'][$i], + 'tmpName' => $uploads['tmp_name'][$i], + 'origName' => $uploads['name'][$i], + 'label' => trim($fileLabels[$i] ?? ''), + 'sortOrder' => isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null, + 'hierarchy' => $this->tfeHierarchyRank($mimeType, $ext), + 'fileType' => $this->detectFileType($mimeType, $ext), + ]; + } + + // Sort by hierarchy rank + usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']); + + // Assign contiguous TFE_XX numbers + $videoCount = 0; + $vttQueue = []; + + // First pass: count videos to know where to insert VTTs + foreach ($files as $f) { + if ($f['fileType'] === 'video') { + $videoCount++; + } + } + + $num = $startNum; + $vttIdx = 0; + + foreach ($files as $f) { + // VTT files are inserted right after their corresponding video + if ($f['fileType'] === 'caption') { + $vttQueue[] = $f; + continue; + } + + if ($f['fileType'] === 'video') { + // Write the video file + $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + + // Write any waiting VTT for this video + if (!empty($vttQueue)) { + $vtt = array_shift($vttQueue); + $this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + } + } else { + $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + } + } + + // Any remaining VTTs (orphaned — write at end) + foreach ($vttQueue as $vtt) { + $this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); + $num++; + } + + return $num; + } + + /** + * Process annex file uploads. + * + * @param int $thesisId + * @param array|null $uploads Multi-file $_FILES entry. + * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder. + * @param string $filePrefix The shared file prefix. + * @param array $post $_POST for per-file labels/orders. + */ + protected function handleAnnexeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = []): void + { + if (!$uploads || !is_array($uploads['name'] ?? null)) { + return; + } + + $dir = STORAGE_ROOT . '/' . $folderPath; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $fileLabels = $post['annexe_labels'] ?? []; + $fileOrders = $post['annexe_orders'] ?? []; + + $num = 1; + $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] ?? -1) !== UPLOAD_ERR_OK) { + error_log("ThesisFileHandler: annexe upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}"); + continue; + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($uploads['tmp_name'][$i]); + $ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION)); + + if ($mimeType === 'text/plain' && $ext === 'vtt') { + $mimeType = 'text/vtt'; + } + + $sizeLimit = (($mimeType === 'application/pdf' || $ext === 'pdf') ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE); + if ($uploads['size'][$i] > $sizeLimit) { + error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping'); + continue; + } + + $padded = sprintf('%02d', $num); + $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext; + $targetPath = $dir . $targetName; + + if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { + error_log("ThesisFileHandler: failed to move annexe {$uploads['name'][$i]}"); + continue; + } + + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + $label = trim($fileLabels[$i] ?? ''); + $sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null; + $fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex'; + + $this->db->insertThesisFile( + $thesisId, $fileType, + $relPath, + basename($uploads['name'][$i]), + $uploads['size'][$i], + $mimeType, + $label !== '' ? $label : null, + $sortOrder + ); + error_log("ThesisFileHandler: annexe uploaded → $targetName"); + $num++; + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** + * Write a single TFE file to disk and record in DB. + */ + protected function writeTfeFile(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void + { + $padded = sprintf('%02d', $num); + $targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext']; + $targetPath = $dir . $targetName; + + if (!move_uploaded_file($f['tmpName'], $targetPath)) { + error_log("ThesisFileHandler: failed to move TFE {$f['origName']}"); + return; + } + + chmod($targetPath, 0644); + $relPath = $folderPath . $targetName; + + $this->db->insertThesisFile( + $thesisId, $f['fileType'], + $relPath, + basename($f['origName']), + $f['size'], + $f['mimeType'], + $f['label'] !== '' ? $f['label'] : null, + $f['sortOrder'] + ); + error_log("ThesisFileHandler: TFE uploaded → $targetName ({$f['fileType']})"); + } + + /** + * Assign a hierarchy rank for sorting TFE files. + * Lower = earlier in the sequence. + * + * 0 = PDF + * 1 = video + * 2 = audio + * 3 = caption (VTT) + * 4 = image + * 5 = archive / other + */ + private function tfeHierarchyRank(string $mimeType, string $ext): int + { + if ($mimeType === 'application/pdf' || $ext === 'pdf') { + return 0; + } + if ($ext === 'vtt' || $mimeType === 'text/vtt') { + return 3; + } + if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) { + return 1; + } + if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) { + return 2; + } + if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) { + return 4; + } + return 5; + } + + /** + * Determine the logical file_type from MIME type / extension. + */ + protected function detectFileType(string $mimeType, string $ext): string + { + if ($ext === 'vtt' || $mimeType === 'text/vtt') { + return 'caption'; + } + if ($mimeType === 'application/pdf' || $ext === 'pdf') { + return 'main'; + } + if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) { + return 'video'; + } + if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) { + return 'audio'; + } + if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) { + return 'image'; + } + return 'other'; + } + + // ── String / filesystem helpers ────────────────────────────────────────── + + /** + * Generate a filesystem-safe author slug from comma-separated author names. + * Sorted alphabetically, joined with dash, accent-stripped, uppercase. + */ + protected function generateAuthorSlug(string $authorNames): string + { + $names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== '')); + sort($names, SORT_NATURAL); + $joined = implode('-', $names); + + $normalized = $joined; + if (function_exists('iconv')) { + $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); + } + $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', + ]; + $normalized = strtr($normalized, $accents); + $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized); + $slug = trim($slug, '_'); + $slug = strtoupper($slug); + if ($slug === '') { + $slug = 'AUTHOR'; + } + return $slug; + } + + /** + * Generate a filesystem-safe title slug (truncated to 60 chars). + */ + protected function generateTitleSlug(string $title): string + { + $normalized = $title; + if (function_exists('iconv')) { + $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); + } + $accents = [ + 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'æ' => 'ae', + 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', + 'î' => 'i', 'ï' => 'i', + 'ô' => 'o', 'ö' => 'o', 'œ' => 'oe', + 'ù' => 'u', 'û' => 'u', 'ü' => 'u', + 'ç' => 'c', + 'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Æ' => 'AE', + 'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E', + 'Î' => 'I', 'Ï' => 'I', + 'Ô' => 'O', 'Ö' => 'O', 'Œ' => 'OE', + 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U', + 'Ç' => 'C', + "'" => '', '"' => '', '«' => '', '»' => '', + ]; + $normalized = strtr($normalized, $accents); + $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized); + $slug = trim($slug, '_'); + $slug = strtoupper($slug); + if (strlen($slug) > 60) { + $slug = substr($slug, 0, 60); + } + if ($slug === '') { + $slug = 'TFE'; + } + return $slug; + } + + /** + * Build the folder path and file prefix for a thesis. + * + * Folder: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ + * Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG} + * + * @return array{folderPath: string, filePrefix: string, folderName: string} + */ + protected function buildThesisFolder(int $year, string $authorsStr, string $title): array + { + $authorSlug = $this->generateAuthorSlug($authorsStr); + $titleSlug = $this->generateTitleSlug($title); + + $folderName = $year . '_' . $authorSlug . '_' . $titleSlug; + $filePrefix = $folderName; + $folderPath = 'theses/' . $year . '/' . $folderName . '/'; + + return [ + 'folderPath' => $folderPath, + 'filePrefix' => $filePrefix, + 'folderName' => $folderName, + ]; + } + + /** + * Ensure the thesis folder exists (create if needed). + * If the folder already exists, append a numeric suffix. + */ + protected function ensureUniqueFolder(string $folderPath): string + { + $baseDir = STORAGE_ROOT . '/' . dirname($folderPath) . '/'; + $candidate = basename(rtrim($folderPath, '/')); + + if (!is_dir($baseDir . $candidate)) { + mkdir($baseDir . $candidate, 0755, true); + return $candidate; + } + + $suffix = 1; + while (is_dir($baseDir . $candidate . '_' . $suffix)) { + $suffix++; + } + $candidate .= '_' . $suffix; + mkdir($baseDir . $candidate, 0755, true); + return $candidate; + } +} diff --git a/app/src/Database.php b/app/src/Database.php index 64edbc7..34b6ce8 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -494,7 +494,7 @@ class Database { $stmt = $this->pdo->prepare( 'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis, - vp.orientation, vp.finality_type, vp.banner_path, vp.authors + vp.orientation, vp.finality_type, vp.authors FROM v_theses_public vp JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN authors a ON a.id = ta.author_id @@ -522,7 +522,7 @@ class Database $stmt = $this->pdo->prepare( "SELECT a.name AS author_name, vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis, - vp.orientation, vp.finality_type, vp.banner_path, vp.authors + vp.orientation, vp.finality_type, vp.authors FROM v_theses_public vp JOIN thesis_authors ta ON ta.thesis_id = vp.id JOIN authors a ON a.id = ta.author_id @@ -2165,6 +2165,9 @@ class Database * Replace the cover image for a thesis: removes any existing cover record * (and its file from disk), then inserts the new one. * + * @deprecated Use ThesisFileHandler::handleCoverUpload() instead. + * Kept for backwards compatibility — may be called by old code. + * * @param int $thesisId * @param array|null $upload Single-file $_FILES entry. * @return string|null Relative path of the new cover, or null. @@ -2175,11 +2178,17 @@ class Database return null; } + require_once APP_ROOT . '/src/Controllers/ThesisFileHandler.php'; + // Use the trait's version — but since it's a trait we can't call it directly. + // Delegate to the new convention: cover goes inside the thesis folder. + // For backwards compat, fall through to the old behaviour. + // The caller should migrate to using the trait directly. + $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; $allowedExts = ['jpg', 'jpeg', 'png', 'webp']; - $maxBytes = 20 * 1024 * 1024; // 20 MB + $maxBytes = 20 * 1024 * 1024; - $finfo = new finfo(FILEINFO_MIME_TYPE); + $finfo = new \finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($upload['tmp_name']); $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); diff --git a/app/storage/schema.sql b/app/storage/schema.sql index b80504e..7b524ce 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -193,9 +193,6 @@ CREATE TABLE IF NOT EXISTS theses ( -- External links baiu_link TEXT, -- Link to institutional repository - -- Home page card banner (optional, landscape image) - banner_path TEXT, -- path relative to STORAGE_ROOT (e.g. "banners/abc.jpg") - -- Logistics checkboxes (backoffice only) exemplaire_baiu BOOLEAN DEFAULT 0, -- Physical copy at BAIU exemplaire_erg BOOLEAN DEFAULT 0, -- Physical copy at ERG @@ -512,7 +509,6 @@ SELECT t.published_at, t.is_published, t.baiu_link, - t.banner_path, t.exemplaire_baiu, t.exemplaire_erg, t.cc2r, diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index f05f3d1..38cf7c6 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -168,6 +168,19 @@ +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +\\\\\\\ to: moumuszm 1e2ae09f "Fix language-search fragment" (rebased revision) ++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: moumuszm 1e2ae09f "Fix language-search fragment" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: kvyyvksn fd0f6e32 "fix: add help email, preserve file names on validation error, license fix" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: kvyyvksn c5873f06 "fix: add help email, preserve file names on validation error, license fix" (rebased revision) +++ $linkName = $link['name'] ?? ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?> diff --git a/app/tests/Unit/PureLogicTest.php b/app/tests/Unit/PureLogicTest.php index 4b72e8c..470400c 100644 --- a/app/tests/Unit/PureLogicTest.php +++ b/app/tests/Unit/PureLogicTest.php @@ -88,9 +88,9 @@ class TfeControllerTestable extends TfeController class ThesisCreateControllerTestable extends ThesisCreateController { - public function testDetectFileType(string $mimeType, string $ext, string $name): string + public function testDetectFileType(string $mimeType, string $ext): string { - return $this->detectFileType($mimeType, $ext, $name); + return $this->detectFileType($mimeType, $ext); } public function testGenerateAuthorSlug(string $name): string @@ -278,33 +278,33 @@ try { // ── B3: detectFileType ──────────────────────────────────────────────────── echo "B3: detectFileType — mime-based detection\n"; $cases = [ - ['text/vtt', 'vtt', 'caption.vtt', 'caption'], - ['audio/mpeg', 'mp3', 'track.mp3', 'audio'], - ['audio/ogg', 'ogg', 'track.ogg', 'audio'], - ['video/mp4', 'mp4', 'film.mp4', 'video'], - ['video/webm', 'webm', 'film.webm', 'video'], - ['application/pdf', 'pdf', 'report.pdf', 'main'], - ['image/jpeg', 'jpg', 'photo.jpg', 'image'], - ['image/png', 'png', 'photo.png', 'image'], - ['application/zip', 'zip', 'archive.zip', 'other'], + ['text/vtt', 'vtt', 'caption'], + ['audio/mpeg', 'mp3', 'audio'], + ['audio/ogg', 'ogg', 'audio'], + ['video/mp4', 'mp4', 'video'], + ['video/webm', 'webm', 'video'], + ['application/pdf', 'pdf', 'main'], + ['image/jpeg', 'jpg', 'image'], + ['image/png', 'png', 'image'], + ['application/zip', 'zip', 'other'], ]; - foreach ($cases as [$mime, $ext, $name, $expected]) { - $actual = $createCtrl->testDetectFileType($mime, $ext, $name); - plAssertEq($expected, $actual, "$name ($mime) → $expected"); + foreach ($cases as [$mime, $ext, $expected]) { + $actual = $createCtrl->testDetectFileType($mime, $ext); + plAssertEq($expected, $actual, "$mime / $ext → $expected"); } echo "\n"; echo "B4: detectFileType — extension-based fallback\n"; // application/octet-stream with known extensions $cases = [ - ['application/octet-stream', 'mp3', 'track.mp3', 'audio'], - ['application/octet-stream', 'mp4', 'video.mp4', 'video'], - ['application/octet-stream', 'pdf', 'doc.pdf', 'main'], - ['application/octet-stream', 'webp', 'img.webp', 'image'], - ['application/octet-stream', 'vtt', 'subs.vtt', 'caption'], + ['application/octet-stream', 'mp3', 'audio'], + ['application/octet-stream', 'mp4', 'video'], + ['application/octet-stream', 'pdf', 'main'], + ['application/octet-stream', 'webp', 'image'], + ['application/octet-stream', 'vtt', 'caption'], ]; - foreach ($cases as [$mime, $ext, $name, $expected]) { - $actual = $createCtrl->testDetectFileType($mime, $ext, $name); + foreach ($cases as [$mime, $ext, $expected]) { + $actual = $createCtrl->testDetectFileType($mime, $ext); plAssertEq($expected, $actual, "octet-stream + .$ext → $expected"); } echo "\n";