db = $db; } // ── Factory ─────────────────────────────────────────────────────────────── /** * Convenience factory — instantiates Database and returns a ready * controller instance. */ public static function make(): self { require_once APP_ROOT . '/src/Database.php'; return new self(new Database()); } // ── Helpers ─────────────────────────────────────────────────────────────── /** * Get the identifier string for a thesis (caller supplies ID). */ public function getIdentifier(int $thesisId): string { return $this->db->getThesisIdentifier($thesisId); } // ── Read / view data ───────────────────────────────────────────────────── /** * Load all lookup tables required to render the add-thesis form. * * Returns a flat array of view variables: * - 'orientations' – orientation lookup rows * - 'apPrograms' – AP program lookup rows * - 'finalityTypes' – finality type lookup rows * - 'languages' – language lookup rows * - 'formatTypes' – format type lookup rows * - 'licenseTypes' – license type lookup rows * * @return array * @throws Exception on DB error. */ public function loadFormData(): array { return [ '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(), ]; } // ── Write / action ──────────────────────────────────────────────────────── /** * Validate and persist a new-thesis POST submission. * * On success, returns the new thesis ID so the caller can redirect to * recapitulatif.php?id=. On validation or DB failure, throws an Exception * (caller must flash the message and redirect back to the form). * * Execution order: * 1. Validate + sanitise POST fields * 2. Find/create author record * 3. INSERT thesis row + link author (inside transaction) * 4. Link jury, languages, formats, tags (inside transaction) * 5. COMMIT * 6. Handle file uploads: cover, banner, thesis files (outside transaction) * * @param array $post Sanitised $_POST array. * @param array $files $_FILES array. * @return int The newly created thesis ID. * @throws Exception On validation or DB error. */ public function submit(array $post, array $files): int { // ── 1. Validate + sanitise ──────────────────────────────────────────── $data = $this->validateAndSanitise($post); // ── 1b. Duplicate detection ─────────────────────────────────────────── require_once APP_ROOT . '/src/DuplicateThesisException.php'; $duplicate = $this->db->findDuplicateThesis($data['titre'], $data['authorNames'], $data['annee']); if ($duplicate !== null) { throw new DuplicateThesisException( $duplicate['id'], $duplicate['identifier'], $duplicate['title'], $duplicate['author'], $duplicate['year'] ); } // ── 2. Build author entries (alphabetically sorted) ─────────────────── $authorEntries = []; foreach ($data['authorNames'] as $i => $name) { $authorEntries[] = [ 'name' => $name, 'email' => $i === 0 ? ($data['mail'] ?: null) : null, 'show_contact' => $i === 0 ? $data['showContact'] : false, ]; } $allAuthorsStr = implode(', ', $data['authorNames']); $authorSlug = $this->generateAuthorSlug($allAuthorsStr); // ── 3–4. DB writes in a transaction ─────────────────────────────────── $this->db->beginTransaction(); try { $thesisId = $this->db->createThesis([ 'year' => $data['annee'], 'orientation_id' => $data['orientationId'], 'ap_program_id' => $data['apProgramId'], 'finality_id' => $data['finalityId'], 'title' => $data['titre'], 'subtitle' => $data['subtitle'], 'synopsis' => $data['synopsis'], 'file_size_info' => $data['durationInfo'], 'baiu_link' => $data['lien'], 'license_id' => $data['licenseId'], 'access_type_id' => $data['accessTypeId'], 'objet' => $data['objet'], ]); $identifier = $this->db->getThesisIdentifier($thesisId); error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . " author(s)"); $this->db->setThesisAuthors($thesisId, $authorEntries); $this->db->setThesisJury($thesisId, $data['juryMembers']); $this->db->setThesisLanguages($thesisId, $data['languageIds']); $this->db->setThesisFormats($thesisId, $data['formatIds']); $this->db->setThesisTags($thesisId, $data['keywords']); $this->db->commit(); } catch (Exception $e) { $this->db->rollback(); throw $e; } // ── 5. File uploads (outside transaction — filesystem ops) ──────────── $this->handleCoverUpload($thesisId, $files['couverture'] ?? null); $this->db->handleBannerUpload($thesisId, $files['banner'] ?? null); $this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post); return $thesisId; } // ── 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 after an error. * * Returns null when no field mapping is found. */ public static function autofocusFieldForError(string $message): ?string { if (str_contains($message, 'Nom/Prénom/Pseudo')) { return 'auteurice'; } if (str_contains($message, 'Titre du mémoire')) { return 'titre'; } if (str_contains($message, 'Synopsis')) { return 'synopsis'; } if (str_contains($message, 'Année invalide')) { return 'année'; } if (str_contains($message, 'orientation')) { return 'orientation'; } if (str_contains($message, 'Atelier Pratique')) { return 'ap'; } if (str_contains($message, 'finalité')) { return 'finality'; } if (str_contains($message, 'langue')) { return 'languages'; } if (str_contains($message, 'mots-clés')) { return 'tag'; } if (str_contains($message, 'Lien URL')) { return 'lien'; } if (str_contains($message, 'e-mail de confirmation')) { return 'confirmation_email'; } return null; } // ── Private: validation ─────────────────────────────────────────────────── /** * Validate and sanitise all POST fields for a new thesis submission. * * Returns a flat associative array of clean values. * * @param array $post Raw $_POST. * @return array * @throws Exception on validation failure. */ private function validateAndSanitise(array $post): array { // Split authors by comma, trim, filter empty, sort alphabetically. $authorRaw = $this->sanitiseString($post['auteurice'] ?? ''); $authorNames = []; if ($authorRaw !== '') { $authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn($n) => $n !== ''); $authorNames = array_values($authorNames); sort($authorNames, SORT_NATURAL); } if (empty($authorNames)) { throw new Exception("Le champ 'Nom/Prénom/Pseudo' est requis."); } $mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : ''; $showContact = !empty($post['contact_public']) ? true : false; $annee = filter_var($post['année'] ?? '', FILTER_VALIDATE_INT); if ($annee === false || $annee < 2000 || $annee > ((int) date('Y') + 1)) { throw new Exception('Année invalide. Veuillez entrer une année valide.'); } $orientationId = filter_var($post['orientation'] ?? '', FILTER_VALIDATE_INT); if ($orientationId === false) { throw new Exception('Veuillez sélectionner une orientation.'); } $apProgramId = filter_var($post['ap'] ?? '', FILTER_VALIDATE_INT); if ($apProgramId === false) { throw new Exception('Veuillez sélectionner un Atelier Pratique.'); } $finalityId = filter_var($post['finality'] ?? '', FILTER_VALIDATE_INT); if ($finalityId === false) { throw new Exception('Veuillez sélectionner une finalité.'); } $titre = $this->validateRequired($this->sanitiseString($post['titre'] ?? ''), 'Titre du mémoire'); $subtitle = $this->sanitiseString($post['subtitle'] ?? ''); $synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis'); $durationInfo = $this->sanitiseString($post['duration_info'] ?? ''); // Jury members $juryMembers = []; if (!empty(trim($post['jury_president'] ?? ''))) { $juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0]; } if (!empty(trim($post['jury_promoteur'] ?? ''))) { $juryMembers[] = [ 'name' => trim($post['jury_promoteur']), 'role' => 'promoteur', 'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0, 'is_ulb' => isset($post['jury_promoteur_ulb']) ? 1 : 0, ]; } foreach ($post['jury_lecteurs'] ?? [] as $i => $name) { $name = trim($name); if ($name !== '') { $juryMembers[] = [ 'name' => $name, 'role' => 'lecteur', 'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0, ]; } } // Keywords (max 10) $tagRaw = $this->sanitiseString($post['tag'] ?? ''); $keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : []; if (count($keywords) > 10) { throw new Exception('Maximum 10 mots-clés autorisés.'); } // Languages (at least one required) $languageIds = isset($post['languages']) && is_array($post['languages']) ? array_map('intval', $post['languages']) : []; if (empty($languageIds)) { throw new Exception('Veuillez sélectionner au moins une langue.'); } // Formats (optional) $formatIds = isset($post['formats']) && is_array($post['formats']) ? array_map('intval', $post['formats']) : []; $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; // 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 } // Objet — restricted to valid values $validObjet = ['tfe', 'thèse', 'frart']; $objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe'; // External link (optional) $lien = ''; if (!empty($post['lien'])) { $lien = filter_var($post['lien'], FILTER_VALIDATE_URL); if ($lien === false) { throw new Exception('Lien URL invalide.'); } } // Confirmation e-mail (optional) $confirmationEmail = trim($post['confirmation_email'] ?? ''); if ($confirmationEmail !== '') { $confirmationEmail = filter_var($confirmationEmail, FILTER_VALIDATE_EMAIL); if ($confirmationEmail === false) { throw new Exception("L'adresse e-mail de confirmation n'est pas valide."); } } return compact( 'authorNames', 'mail', 'showContact', 'confirmationEmail', 'annee', 'orientationId', 'apProgramId', 'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo', 'juryMembers', 'keywords', 'languageIds', 'formatIds', 'licenseId', 'lien', 'accessTypeId', 'objet' ); } // ── 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; } if ($uploads['size'][$i] > self::MAX_FILE_SIZE) { error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, 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. */ 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: input helpers ──────────────────────────────────────────────── /** * Trim and strip HTML tags from a string value. * htmlspecialchars is applied at render time, not here. */ private function sanitiseString(string $input): string { return strip_tags(trim($input)); } /** * Assert that a string value is non-empty. * * @throws Exception if $value is empty. */ private function validateRequired(string $value, string $fieldName): string { if ($value === '') { throw new Exception("Le champ '$fieldName' est requis."); } return $value; } /** * Generate a filesystem-safe author slug from the author name. * Converts to uppercase, replaces spaces with underscores, removes accents. */ private 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. */ private 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. */ private 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; } }