Files
xamxam/app/src/Controllers/ThesisCreateController.php
Pontoporeia dce0e0b301 schema: validate against new TFE field spec
- add exemplaire_baiu, exemplaire_erg, cc4r, remarks;
- add is_ulb to jury;
- split jury_lecteurs into interne/externe in view;
- refactor admin edit form with backoffice fields;
- update public fiche to show promoteur ULB and split lecteurs
2026-05-07 17:53:24 +02:00

703 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* ThesisCreateController
*
* Centralises all validation, data-fetching, and persistence logic for the
* admin "add new thesis" workflow (admin/add.php + admin/actions/formulaire.php).
*
* Responsibilities:
* - Loading lookup tables for the add-form view (loadFormData)
* - Validating and sanitising POST submissions
* - Creating the thesis record, linking authors / jury / languages / formats /
* tags in a single database transaction
* - Handling cover image, banner, and multi-file uploads
* - WCAG 3.3.1: mapping validation exception messages to autofocus field hints
*
* The class has NO output side-effects; all redirects, flash writes, session
* mutations, and template rendering stay in the thin dispatcher files so the
* view layer remains easy to inspect and modify.
*/
class ThesisCreateController
{
/** Maximum allowed file size for thesis files (bytes). */
private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
/** MIME types accepted for thesis files. */
private const ALLOWED_MIME_TYPES = [
// Images
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
// Documents
'application/pdf',
// Video
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
// Audio
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
// Captions
'text/vtt',
// Archives / other downloadables
'application/zip', 'application/x-zip-compressed',
'application/x-tar', 'application/gzip',
'application/octet-stream',
];
/** File extensions accepted for thesis files. */
private const ALLOWED_EXTENSIONS = [
// Images
'jpg', 'jpeg', 'png', 'gif', 'webp',
// Documents
'pdf',
// Video
'mp4', 'webm', 'ogv', 'mov',
// Audio
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
// Captions
'vtt',
// Archives / other
'zip', 'tar', 'gz', 'tgz',
];
private Database $db;
public function __construct(Database $db)
{
$this->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<string, mixed>
* @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=<n>. 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);
// ── 34. 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<string, mixed>
* @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;
}
}