From 8e864fc62482d7361e08f85e7e0075195174e1d4 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 27 Apr 2026 20:33:21 +0200 Subject: [PATCH] admin edit.php: add cover image + thesis file management fields - Database: add deleteThesisFile() and handleCoverUpload() methods - ThesisEditController::load(): expose currentFiles + currentCover to view - ThesisEditController::save(): handle couverture upload/removal, per-file deletion (delete_files[]), and new thesis file uploads - edit.php template: new Fichiers fieldset with cover preview+remove, existing files list with delete checkboxes, new file upload input (mirrors add.php / partage.php) --- TODO.md | 9 + app/src/Controllers/ThesisEditController.php | 188 +++++++++++++++++++ app/src/Database.php | 89 +++++++++ app/templates/admin/edit.php | 54 ++++++ 4 files changed, 340 insertions(+) diff --git a/TODO.md b/TODO.md index 8695959..fba1064 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,14 @@ # TFE Access Restriction Feature +## Admin Edit Form — File Management +- [x] Add cover image upload/preview/remove to edit.php +- [x] Add existing thesis files listing with per-file delete checkboxes +- [x] Add new thesis files upload field (PDF, JPG, PNG, MP4, ZIP, VTT) +- [x] Add `deleteThesisFile()` and `handleCoverUpload()` to Database.php +- [x] Update `ThesisEditController::save()` to handle cover, file deletion, new uploads +- [x] Update `ThesisEditController::load()` to expose `currentFiles` + `currentCover` + + ## Overview Add access restriction for TFE attached files based on user email domain, with admin validation workflow. diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 08f3e83..929befa 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -49,6 +49,8 @@ class ThesisEditController * - 'currentLanguages' – int[] * - 'currentFormats' – int[] * - 'jury' – jury rows + * - 'currentFiles' – all thesis_files rows (cover + thesis files) + * - 'currentCover' – single thesis_files row for cover, or null * - 'orientations' – lookup rows * - 'apPrograms' – lookup rows * - 'finalityTypes' – lookup rows @@ -77,6 +79,16 @@ class ThesisEditController $currentLanguages = $this->db->getThesisLanguageIds($thesisId); $currentFormats = $this->db->getThesisFormatIds($thesisId); $jury = $this->db->getThesisJury($thesisId); + $currentFiles = $this->db->getThesisFiles($thesisId); + + // Separate out the cover entry for convenience + $currentCover = null; + foreach ($currentFiles as $f) { + if ($f['file_type'] === 'cover') { + $currentCover = $f; + break; + } + } $orientations = $this->db->getAllOrientations(); $apPrograms = $this->db->getAllAPPrograms(); @@ -100,6 +112,8 @@ class ThesisEditController 'currentLanguages' => $currentLanguages, 'currentFormats' => $currentFormats, 'jury' => $jury, + 'currentFiles' => $currentFiles, + 'currentCover' => $currentCover, 'orientations' => $orientations, 'apPrograms' => $apPrograms, 'finalityTypes' => $finalityTypes, @@ -230,6 +244,180 @@ class ThesisEditController } else { $this->db->handleBannerUpload($thesisId, $files['banner'] ?? null); } + + // ── Cover image (outside transaction — filesystem op) ───────────────── + if (isset($post['remove_cover'])) { + $allFiles = $this->db->getThesisFiles($thesisId); + foreach ($allFiles as $f) { + if ($f['file_type'] === 'cover') { + $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; + } + } + } else { + $this->db->handleCoverUpload($thesisId, $files['couverture'] ?? null); + } + + // ── Delete individual thesis files ──────────────────────────────────── + $deleteIds = isset($post['delete_files']) && is_array($post['delete_files']) + ? array_map('intval', $post['delete_files']) + : []; + foreach ($deleteIds as $fileId) { + 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); + } + } + + // ── New thesis files upload ─────────────────────────────────────────── + if (!empty($files['files']['name'][0])) { + $this->handleThesisFiles($thesisId, $post, $files['files']); + } + } + + // ── 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', 'application/pdf', + 'video/mp4', 'application/zip', 'text/vtt', + ]; + $allowedExts = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt']; + $maxBytes = 50 * 1024 * 1024; // 50 MB + + $year = (int)($post['année'] ?? date('Y')); + $authorName = trim($post['auteurice'] ?? 'unknown'); + $authorSlug = $this->generateAuthorSlug($authorName); + + // 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'; + } + + if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true)) { + error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType), skipping"); + continue; + } + + if ($uploads['size'][$i] > $maxBytes) { + error_log("ThesisEditController: file too large {$uploads['name'][$i]}, 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 = 'other'; + if ($ext === 'vtt') $fileType = 'caption'; + elseif (stripos($originalName, 'annex') !== false) $fileType = 'annex'; + elseif ($ext === 'pdf') $fileType = 'main'; + + $relPath = "theses/{$year}/{$folderName}/" . $candidate; + $this->db->insertThesisFile($thesisId, $fileType, $relPath, basename($originalName), $uploads['size'][$i], $mimeType); + error_log("ThesisEditController: uploaded → $candidate ($fileType)"); + } + } + + // ── 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/Database.php b/app/src/Database.php index 3e772df..bd9d4df 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -1744,6 +1744,95 @@ class Database { return $this->pdo->lastInsertId(); } + /** + * Delete a single thesis file record by its ID and optionally remove the + * file from disk. Returns the file_path that was deleted (or null if not + * found), so the caller can clean up the filesystem. + * + * @param int $fileId Primary key of thesis_files row. + * @param int $thesisId Owning thesis ID (used as a safety guard). + * @return string|null The file_path that was stored, or null. + */ + public function deleteThesisFile(int $fileId, int $thesisId): ?string + { + $stmt = $this->pdo->prepare( + "SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1" + ); + $stmt->execute([$fileId, $thesisId]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + $this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$fileId]); + return $row['file_path']; + } + + /** + * Replace the cover image for a thesis: removes any existing cover record + * (and its file from disk), then inserts the new one. + * + * @param int $thesisId + * @param array|null $upload Single-file $_FILES entry. + * @return string|null Relative path of the new cover, or null. + */ + public function handleCoverUpload(int $thesisId, ?array $upload): ?string + { + if (!$upload || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + return null; + } + + $allowedMimes = ['image/jpeg', 'image/png']; + $allowedExts = ['jpg', 'jpeg', 'png']; + $maxBytes = 10 * 1024 * 1024; // 10 MB + + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($upload['tmp_name']); + $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); + + if (!in_array($mimeType, $allowedMimes, true) + || !in_array($ext, $allowedExts, true) + || $upload['size'] > $maxBytes) { + error_log("handleCoverUpload: rejected {$upload['name']} ($mimeType, {$upload['size']} bytes)"); + return null; + } + + // Remove existing cover record + file + $existing = $this->pdo->prepare( + "SELECT id, file_path FROM thesis_files WHERE thesis_id = ? AND file_type = 'cover' LIMIT 1" + ); + $existing->execute([$thesisId]); + if ($old = $existing->fetch()) { + $this->pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$old['id']]); + if (!empty($old['file_path']) && defined('STORAGE_ROOT')) { + $abs = STORAGE_ROOT . '/' . $old['file_path']; + if (file_exists($abs)) @unlink($abs); + } + } + + $coverDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/covers/' : null; + if (!$coverDir) { + error_log('handleCoverUpload: STORAGE_ROOT not defined'); + return null; + } + 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("handleCoverUpload: move_uploaded_file failed for {$upload['name']}"); + return null; + } + + chmod($targetPath, 0644); + $relPath = 'covers/' . $safeName; + $this->insertThesisFile($thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType); + error_log("handleCoverUpload: saved $relPath"); + return $relPath; + } + // ======================================================================== // EXPORT HELPERS — used by ExportController // ======================================================================== diff --git a/app/templates/admin/edit.php b/app/templates/admin/edit.php index 4c65479..2978b9c 100644 --- a/app/templates/admin/edit.php +++ b/app/templates/admin/edit.php @@ -95,6 +95,60 @@ + +
+ Fichiers + + +
+ +
+ +
+ Couverture actuelle + +
+ + +
+
+ + + $f['file_type'] !== 'cover'); + ?> + +
+ +
    + +
  • + + [] + + + + + ( MB) + + + +
  • + +
+
+ + + + +
+