db = $db; } // ── Factory ─────────────────────────────────────────────────────────────── /** * Convenience factory — instantiates Database and returns a ready * controller. Accepts an optional existing Database instance so callers * that already hold one (e.g. during testing) can avoid a second * connection. */ public static function create(?Database $db = null): self { require_once APP_ROOT . '/src/Database.php'; return new self($db ?? Database::getInstance()); } // ── Read / view data ───────────────────────────────────────────────────── /** * Load all data required to render the edit form. * * Returns a flat array of view variables: * - 'thesis' – thesis row (from getThesis) * - '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 * - 'languages' – lookup rows * - 'formatTypes' – lookup rows * - 'licenseTypes' – lookup rows * - 'accessTypes' – lookup rows * - 'currentLicenseId' – int|null * - 'currentAccessTypeId'– int|null * - 'currentContextNote' – string * - 'pageTitle' – string * * @throws Exception if the thesis is not found or a DB error occurs. */ public function load(int $thesisId): array { if ($thesisId <= 0) { throw new InvalidArgumentException('ID invalide'); } $thesis = $this->db->getThesis($thesisId); if (!$thesis) { throw new RuntimeException('TFE non trouvé'); } $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(); $finalityTypes = $this->db->getAllFinalityTypes(); $languages = $this->db->getAllLanguages(); $formatTypes = $this->db->getAllFormatTypes(); $licenseTypes = $this->db->getAllLicenseTypes(); $enabledAccessTypes = $this->db->getEnabledFormAccessTypes(); $rawRow = $this->db->getThesisRawFields($thesisId); $currentLicenseId = $rawRow['license_id'] ?? null; $currentAccessTypeId = $rawRow['access_type_id'] ?? null; $currentContextNote = $rawRow['context_note'] ?? ''; // Author contact info (from view) $currentAuthorEmail = $thesis['author_email'] ?? ''; $currentAuthorShowContact = (bool)($thesis['author_show_contact'] ?? false); return [ 'thesis' => $thesis, 'currentLanguages' => $currentLanguages, 'currentFormats' => $currentFormats, 'jury' => $jury, 'currentFiles' => $currentFiles, 'currentCover' => $currentCover, 'orientations' => $orientations, 'apPrograms' => $apPrograms, 'finalityTypes' => $finalityTypes, 'languages' => $languages, 'formatTypes' => $formatTypes, 'licenseTypes' => $licenseTypes, 'enabledAccessTypes' => $enabledAccessTypes, 'currentLicenseId' => $currentLicenseId, 'currentAccessTypeId' => $currentAccessTypeId, 'currentContextNote' => $currentContextNote, 'currentAuthorEmail' => $currentAuthorEmail, 'currentAuthorShowContact' => $currentAuthorShowContact, 'pageTitle' => 'Éditer TFE - ' . htmlspecialchars($thesis['title']), ]; } // ── Write / action ──────────────────────────────────────────────────────── /** * Validate and persist a thesis-edit POST submission. * * Runs the full update inside a transaction: * 1. Thesis metadata (title, subtitle, year, orientation, ap, finality, * synopsis, context_note, file_size_info, baiu_link, license_id, * access_type_id, is_published) * 2. Authors (setThesisAuthors) * 3. Jury (setThesisJury) * 4. Languages (setThesisLanguages) * 5. Formats (setThesisFormats) * 6. Tags (setThesisTags) * Then handles banner upload/removal outside the transaction. * * @param int $thesisId Validated thesis ID (> 0). * @param array $post Sanitised $_POST array. * @param array $files $_FILES array (expects 'banner' key). * * @throws Exception on validation or DB error (caller must rollback if * the transaction is still open, but this method rolls * back internally before re-throwing). */ public function save(int $thesisId, array $post, array $files): void { if ($thesisId <= 0) { throw new InvalidArgumentException('ID de TFE invalide.'); } $this->db->beginTransaction(); try { // ── 1. Thesis metadata ──────────────────────────────────────────── $this->db->updateThesis($thesisId, [ 'title' => trim($post['titre'] ?? ''), 'subtitle' => trim($post['subtitle'] ?? ''), 'year' => intval($post['année'] ?? 0), 'orientation_id' => intval($post['orientation'] ?? 0), 'ap_program_id' => intval($post['ap'] ?? 0), 'finality_id' => intval($post['finality'] ?? 0), 'synopsis' => trim($post['synopsis'] ?? ''), 'context_note' => trim($post['context_note'] ?? ''), 'file_size_info' => trim($post['duration_info'] ?? ''), 'baiu_link' => trim($post['lien'] ?? ''), 'license_id' => filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null, 'access_type_id' => filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null, 'is_published' => isset($post['is_published']), ]); // ── 2. Authors ──────────────────────────────────────────────────── $authorsRaw = trim($post['auteurice'] ?? ''); $showContact = !empty($post['contact_public']); $authorEntries = []; if ($authorsRaw !== '') { foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) { if ($name !== '') { $authorEntries[] = [ 'name' => $name, 'email' => $i === 0 ? ($post['mail'] ?? null) : null, 'show_contact' => $i === 0 ? $showContact : false, ]; } } } $this->db->setThesisAuthors($thesisId, $authorEntries); // ── 3. Jury ─────────────────────────────────────────────────────── $juryMembers = $this->collectJuryMembers($post); $this->db->setThesisJury($thesisId, $juryMembers); // ── 4. Languages ────────────────────────────────────────────────── $this->db->setThesisLanguages( $thesisId, isset($post['languages']) && is_array($post['languages']) ? $post['languages'] : [] ); // ── 5. Formats ──────────────────────────────────────────────────── $this->db->setThesisFormats( $thesisId, isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : [] ); // ── 6. Tags ─────────────────────────────────────────────────────── $keywordsRaw = trim($post['tag'] ?? ''); $keywords = $keywordsRaw !== '' ? array_map('trim', explode(',', $keywordsRaw)) : []; $this->db->setThesisTags($thesisId, $keywords); $this->db->commit(); } catch (Exception $e) { $this->db->rollback(); throw $e; } // ── Banner (outside transaction — filesystem op) ────────────────────── if (isset($post['remove_banner'])) { $currentBannerPath = $this->db->getThesisBannerPath($thesisId); if ($currentBannerPath && defined('STORAGE_ROOT')) { $absPath = STORAGE_ROOT . '/' . $currentBannerPath; if (file_exists($absPath)) { unlink($absPath); } } $this->db->setBannerPath($thesisId, null); } 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); } } } // ── Reorder existing files ──────────────────────────────────────────── if (!empty($post['file_sort_order']) && is_array($post['file_sort_order'])) { $this->db->reorderThesisFiles($thesisId, $post['file_sort_order']); } // ── Update display labels for existing files ────────────────────────── if (!empty($post['file_label']) && is_array($post['file_label'])) { foreach ($post['file_label'] as $fileId => $label) { $fileId = (int)$fileId; if ($fileId <= 0) { continue; } $this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null); } } // ── 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', '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 $year = (int)($post['année'] ?? date('Y')); $authorName = trim($post['auteurice'] ?? 'unknown'); $authorSlug = $this->generateAuthorSlug($authorName); // 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; } 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 = $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 ───────────────────────────────────────────────────── /** * Map a validation exception message to the name of the field that should * receive autofocus when the form is re-rendered. * * Returns null when no field mapping is found. */ 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'; } return null; } // ── Private helpers ─────────────────────────────────────────────────────── /** * Build the jury-members array from POST data. * * @param array $post Raw $_POST. * @return array */ private function collectJuryMembers(array $post): array { $members = []; if (!empty(trim($post['jury_president'] ?? ''))) { $members[] = [ 'name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0, ]; } if (!empty(trim($post['jury_promoteur'] ?? ''))) { $members[] = [ 'name' => trim($post['jury_promoteur']), 'role' => 'promoteur', 'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0, ]; } foreach ($post['jury_lecteurs'] ?? [] as $i => $name) { $name = trim($name); if ($name !== '') { $members[] = [ 'name' => $name, 'role' => 'lecteur', 'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0, ]; } } return $members; } }