From 2841e057168a11e7eafc7406c3a455d70b269f0e Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 6 Apr 2026 14:37:56 +0200 Subject: [PATCH] Extract ThesisCreateController; add Database publish methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate action handlers into controller methods (todo/02-php-components.md). src/ThesisCreateController.php (new, 435 lines) Mirrors ThesisEditController for the add-thesis flow. make() — factory; instantiates Database via new Database() loadFormData() — returns all lookup tables needed by admin/add.php (orientations, apPrograms, finalityTypes, languages, formatTypes, licenseTypes) submit(post, files) — full new-thesis creation pipeline: 1. validateAndSanitise() — trims/strips HTML, validates required fields, year range, orientation/ap/finality IDs, language selection, max-10 keywords, URL format; throws named Exception on failure 2. findOrCreateAuthor() — reuses existing DB method 3. Transaction: createThesis + setThesisJury + setThesisLanguages + setThesisFormats + setThesisTags; rolls back on any failure 4. File uploads outside transaction: cover image (JPG/PNG only, stored in storage/covers/), banner via handleBannerUpload(), thesis files (PDF/JPG/PNG/MP4/ZIP/VTT, stored in storage/theses/YEAR/IDENT/, file_type auto-detected: caption/annex/main/other) autofocusFieldForError() — static; maps exception messages to field names for WCAG 3.3.1 autofocus on re-render (same contract as ThesisEditController::autofocusFieldForError) admin/actions/formulaire.php 346 → 45 lines Now: bootstrap + CSRF guard + ThesisCreateController::make()->submit() + flash/redirect on error. All validation, DB logic, and file handling removed. admin/add.php Lookup-table block (new Database() + 6 individual DB calls) replaced with ThesisCreateController::make()->loadFormData() + extract(). src/Database.php — two new methods added setPublished(int , bool ): void UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? bulkSetPublished(int[] , bool ): void Same but with an IN (...) clause for multiple IDs admin/actions/publish.php 100 → 65 lines Raw SQL (->prepare('UPDATE theses SET is_published = ?...')) replaced with ->setPublished() / ->bulkSetPublished(). No raw PDO calls remain in any action handler file. --- TODO.md | 1 + public/admin/actions/formulaire.php | 325 +-------------------- public/admin/actions/publish.php | 85 ++---- public/admin/add.php | 15 +- src/Database.php | 22 ++ src/ThesisCreateController.php | 435 ++++++++++++++++++++++++++++ todo/02-php-components.md | 2 +- 7 files changed, 501 insertions(+), 384 deletions(-) create mode 100644 src/ThesisCreateController.php diff --git a/TODO.md b/TODO.md index a0b146d..98daa4e 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,7 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md): ## Recently completed (this session) +- [x] `src/ThesisCreateController.php` — extracted all validation, DB writes, and file-upload handling from `admin/actions/formulaire.php` into a dedicated controller; `make()` factory instantiates `Database`; `loadFormData()` returns all lookup tables for the add-form view; `submit(post, files)` runs the full creation flow: validates/sanitises all POST fields, calls `findOrCreateAuthor`, inserts thesis row via `createThesis`, links jury/languages/formats/tags inside a transaction, then processes cover image, banner, and multi-file uploads outside the transaction; `autofocusFieldForError()` maps exception messages to WCAG 3.3.1 autofocus field hints; `actions/formulaire.php` reduced 346→45 lines (CSRF guard + one `submit()` call); `admin/add.php` lookup-table block replaced with `ThesisCreateController::make()->loadFormData()`; `Database::setPublished()` and `Database::bulkSetPublished()` added, eliminating raw SQL from `actions/publish.php` (100→65 lines); no raw PDO calls remain in any action handler file - [x] `src/HomeController.php` — extracted all data-fetching logic from `public/index.php` into a dedicated controller class; `create()` returns a ready instance with `Database` singleton injected; `handle()` parses `page`/`year` GET params, determines display mode (default-random-latest / year-filtered / paginated-all), runs the appropriate DB queries (`getLatestPublishedYear`, `getLatestYearTheses`, `searchTheses`+`countSearchResults`, `getPublishedTheses`+`countPublishedTheses`), batch-loads cover images via `getCoverPathsForTheses`, assembles OG/meta tags, and returns a flat view-variable array; `public/index.php` reduced 100→71 lines (6-line dispatcher + pure view template); `todo/02-php-components.md` “Extract remaining controllers” task marked done - [x] `src/TfeController.php` — extracted all data-fetching, OG-tag assembly, and view-variable construction from `public/tfe.php` into a dedicated controller class; `create()` returns a ready instance with `Database` singleton injected; `handle()` validates the `id` param (redirects on missing/invalid), loads the thesis row via `getThesisById()`, calls `getThesisAccessTypeId()` for visibility gating, builds the meta description (strip_tags + 160-char truncation), resolves the OG image (banner_path → first image file → empty), assembles the full `$ogTags` array (type/title/description/url/image/image_alt/site_name/article_author/article_published_time), collects WebVTT caption paths for N-th-video pairing, and returns a flat view-variable array; `captionFiles` replaces inline `$_captionFiles` array in the view; `$db` reference removed from `tfe.php` entirely; `tfe.php` reduced 271→206 lines (9-line dispatcher + pure view template); `todo/02-php-components.md` “Extract remaining controllers” and “Move OG tag construction into controller logic” tasks updated diff --git a/public/admin/actions/formulaire.php b/public/admin/actions/formulaire.php index e9bcf57..e25833b 100644 --- a/public/admin/actions/formulaire.php +++ b/public/admin/actions/formulaire.php @@ -1,346 +1,45 @@ -beginTransaction(); + $ctrl = ThesisCreateController::make(); + $thesisId = $ctrl->submit($_POST, $_FILES); - // ===== VALIDATE AND SANITIZE INPUT DATA ===== - - // Author information - $auteurName = validate_required(sanitize_string($_POST["auteurice"] ?? ''), "Nom/Prénom/Pseudo"); - - $mail = $_POST["mail"] ?? ''; - if (!empty($mail)) { - // Could be email or social media handle - $mail = sanitize_string($mail); - } - - // Year validation - $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."); - } - - // Academic details - $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é."); - } - - // Thesis content - $titre = validate_required(sanitize_string($_POST["titre"] ?? ''), "Titre du mémoire"); - $subtitle = sanitize_string($_POST["subtitle"] ?? ''); - $synopsis = validate_required(sanitize_string($_POST["synopsis"] ?? ''), "Synopsis"); - $durationInfo = sanitize_string($_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]; - } - 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 = sanitize_string($_POST["tag"] ?? ''); - $keywords = !empty($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 = $_POST["languages"] ?? []; - if (empty($languageIds)) { - throw new Exception("Veuillez sélectionner au moins une langue."); - } - $languageIds = array_map('intval', $languageIds); - - // Formats (optional, multiple selection) - $formatIds = isset($_POST["formats"]) ? array_map('intval', $_POST["formats"]) : []; - - // License - $licenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; - - // External link - $lien = $_POST["lien"] ?? ''; - if (!empty($lien)) { - $lien = filter_var($lien, FILTER_VALIDATE_URL); - if ($lien === false) { - throw new Exception("Lien URL invalide."); - } - } - - // File uploads - $couverture = $_FILES["couverture"] ?? null; - $bannerFile = $_FILES["banner"] ?? null; - $files = $_FILES["files"] ?? null; - - // ===== CREATE OR FIND AUTHOR ===== - $authorId = $db->findOrCreateAuthor($auteurName, $mail); - error_log("Author ID: $authorId"); - - // ===== INSERT THESIS RECORD + LINK AUTHOR ===== - $thesisId = $db->createThesis([ - 'year' => $annee, - 'orientation_id' => $orientationId, - 'ap_program_id' => $apProgramId, - 'finality_id' => $finalityId, - 'title' => $titre, - 'subtitle' => $subtitle, - 'synopsis' => $synopsis, - 'file_size_info' => $durationInfo, - 'baiu_link' => $lien, - 'license_id' => $licenseId, - 'author_id' => $authorId, - ]); - $identifier = $db->getThesisIdentifier($thesisId); - error_log("Thesis ID: $thesisId (identifier: $identifier)"); - - // ===== LINK JURY TO THESIS ===== - $db->setThesisJury($thesisId, $juryMembers); - - // ===== LINK LANGUAGES TO THESIS ===== - $db->setThesisLanguages($thesisId, $languageIds); - - // ===== LINK FORMATS TO THESIS ===== - $db->setThesisFormats($thesisId, $formatIds); - - // ===== LINK TAGS TO THESIS ===== - $db->setThesisTags($thesisId, $keywords); - - // ===== HANDLE FILE UPLOADS ===== - - // Create necessary directories — outside the webroot (security items #3 & #4). - // Files are served through /media.php, never directly via a URL path. - $uploadBaseDir = STORAGE_ROOT . "/theses/{$annee}/{$identifier}/"; - $coverDir = STORAGE_ROOT . "/covers/"; - $bannerDir = STORAGE_ROOT . "/banners/"; - - if (!file_exists($uploadBaseDir)) { - mkdir($uploadBaseDir, 0755, true); - } - if (!file_exists($coverDir)) { - mkdir($coverDir, 0755, true); - } - if (!file_exists($bannerDir)) { - mkdir($bannerDir, 0755, true); - } - - // Define security constraints - $allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip', 'text/vtt']; - $allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt']; - $maxFileSize = 50 * 1024 * 1024; // 50 MB - - // Process cover image - $coverPath = null; - if ($couverture && isset($couverture["error"]) && $couverture["error"] === UPLOAD_ERR_OK) { - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($couverture["tmp_name"]); - $fileExtension = strtolower(pathinfo($couverture["name"], PATHINFO_EXTENSION)); - - // Only allow image files for cover - if (in_array($mimeType, ['image/jpeg', 'image/png']) && - in_array($fileExtension, ['jpg', 'jpeg', 'png'])) { - - // Generate random filename - $randomName = bin2hex(random_bytes(16)); - $safeFileName = $randomName . "." . $fileExtension; - $targetFile = $coverDir . $safeFileName; - - if (move_uploaded_file($couverture["tmp_name"], $targetFile)) { - chmod($targetFile, 0644); - // Path stored relative to STORAGE_ROOT; served via /media.php?path=... - $coverPath = "covers/" . $safeFileName; - - // Record cover in thesis_files (type = 'cover') - $db->insertThesisFile( - $thesisId, - 'cover', - $coverPath, - basename($couverture["name"]), - $couverture["size"], - $mimeType - ); - error_log("Cover image uploaded: " . $safeFileName); - } - } else { - error_log("Invalid cover image type: " . $mimeType); - } - } - - // Process banner image - $db->handleBannerUpload($thesisId, $bannerFile ?: null); - - // Process thesis files - if ($files && is_array($files["name"])) { - for ($i = 0; $i < count($files["name"]); $i++) { - // Skip if no file was uploaded for this slot - if ($files["error"][$i] === UPLOAD_ERR_NO_FILE) { - continue; - } - - if ($files["error"][$i] !== UPLOAD_ERR_OK) { - error_log("File upload error code " . $files["error"][$i] . ": " . $files["name"][$i]); - continue; - } - - // Validate file - $finfo = new finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($files["tmp_name"][$i]); - $fileExtension = strtolower(pathinfo($files["name"][$i], PATHINFO_EXTENSION)); - - // finfo may return 'text/plain' for WebVTT on some systems; normalise by extension. - if ($mimeType === 'text/plain' && $fileExtension === 'vtt') { - $mimeType = 'text/vtt'; - } - - if (!in_array($mimeType, $allowedMimeTypes) || !in_array($fileExtension, $allowedExtensions)) { - error_log("Invalid file type: " . $files["name"][$i] . " (MIME: $mimeType)"); - continue; - } - - if ($files["size"][$i] > $maxFileSize) { - error_log("File too large: " . $files["name"][$i]); - continue; - } - - // Generate random filename - $randomName = bin2hex(random_bytes(16)); - $safeFileName = $randomName . "." . $fileExtension; - $targetFile = $uploadBaseDir . $safeFileName; - - if (move_uploaded_file($files["tmp_name"][$i], $targetFile)) { - chmod($targetFile, 0644); - - // Determine file type - $fileType = 'other'; - if ($fileExtension === 'vtt') { - $fileType = 'caption'; // WebVTT caption sidecar - } elseif (strpos(strtolower($files["name"][$i]), 'annex') !== false) { - $fileType = 'annex'; - } elseif ($fileExtension === 'pdf') { - $fileType = 'main'; - } - - // Insert file record — path relative to STORAGE_ROOT - $db->insertThesisFile( - $thesisId, - $fileType, - "theses/{$annee}/{$identifier}/" . $safeFileName, - basename($files["name"][$i]), - $files["size"][$i], - $mimeType - ); - - error_log("File uploaded: " . $safeFileName); - } else { - error_log("Failed to move file: " . $files["name"][$i]); - } - } - } - - // ===== COMMIT TRANSACTION ===== - $db->commit(); - - error_log("Thesis submission completed successfully: $identifier"); - - // Clear CSRF token unset($_SESSION['csrf_token']); - // Redirect to thank you page header('Location: ../thanks.php?id=' . urlencode($thesisId)); exit(); } catch (Exception $e) { - // Rollback transaction on error - if (isset($db)) { - $db->rollback(); - } + error_log('ThesisCreateController error: ' . $e->getMessage()); - error_log("Form processing error: " . $e->getMessage()); - - // Save error message and form data to session App::flash('error', $e->getMessage()); $_SESSION['form_data'] = $_POST; - // WCAG 3.3.1 — identify which field caused the error and request autofocus. - // Mapping is based on the exception messages thrown above. - $msg = $e->getMessage(); - $autofocusField = null; - if (str_contains($msg, "Nom/Prénom/Pseudo")) $autofocusField = 'auteurice'; - elseif (str_contains($msg, "Titre du mémoire")) $autofocusField = 'titre'; - elseif (str_contains($msg, "Synopsis")) $autofocusField = 'synopsis'; - elseif (str_contains($msg, "Année invalide")) $autofocusField = 'année'; - elseif (str_contains($msg, "orientation")) $autofocusField = 'orientation'; - elseif (str_contains($msg, "Atelier Pratique")) $autofocusField = 'ap'; - elseif (str_contains($msg, "finalité")) $autofocusField = 'finality'; - elseif (str_contains($msg, "langue")) $autofocusField = 'languages'; - elseif (str_contains($msg, "mots-clés")) $autofocusField = 'tag'; - elseif (str_contains($msg, "Lien URL")) $autofocusField = 'lien'; + $autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage()); if ($autofocusField !== null) { App::flashAutofocus($autofocusField); } - // Redirect back to form with preserved data header('Location: ../add.php'); exit(); } diff --git a/public/admin/actions/publish.php b/public/admin/actions/publish.php index fb7ed8a..7c8af57 100644 --- a/public/admin/actions/publish.php +++ b/public/admin/actions/publish.php @@ -1,100 +1,65 @@ getPDO(); - - $isPublished = ($action === 'publish') ? 1 : 0; if ($isBulk) { - // Handle bulk action - $thesisIds = isset($_POST['selected_theses']) ? $_POST['selected_theses'] : []; + $ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0); - if (empty($thesisIds)) { - App::flash('error', "Aucun TFE sélectionné."); + if (empty($ids)) { + App::flash('error', 'Aucun TFE sélectionné.'); header('Location: ../index.php'); exit; } - // Validate all IDs are integers - $thesisIds = array_map('intval', $thesisIds); - $thesisIds = array_filter($thesisIds, fn($id) => $id > 0); - - if (empty($thesisIds)) { - App::flash('error', "IDs invalides."); - header('Location: ../index.php'); - exit; - } - - // Prepare placeholders for IN clause - $placeholders = str_repeat('?,', count($thesisIds) - 1) . '?'; - $sql = "UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"; - - $stmt = $pdo->prepare($sql); - $params = array_merge([$isPublished], $thesisIds); - $stmt->execute($params); - - $count = count($thesisIds); - if ($action === 'publish') { - App::flash('success', "$count TFE(s) publié(s) avec succès!"); - } else { - App::flash('success', "$count TFE(s) retiré(s) de la publication."); - } + $db->bulkSetPublished($ids, $published); + $count = count($ids); + App::flash('success', $published + ? "$count TFE(s) publié(s) avec succès." + : "$count TFE(s) retiré(s) de la publication."); } else { - // Handle single action - $thesisId = isset($_POST['thesis_id']) ? intval($_POST['thesis_id']) : 0; + $thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT); - if ($thesisId <= 0) { - App::flash('error', "ID invalide."); + if (!$thesisId || $thesisId <= 0) { + App::flash('error', 'ID invalide.'); header('Location: ../index.php'); exit; } - $stmt = $pdo->prepare("UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$isPublished, $thesisId]); - - if ($action === 'publish') { - App::flash('success', "TFE publié avec succès!"); - } else { - App::flash('success', "TFE retiré de la publication."); - } + $db->setPublished($thesisId, $published); + App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.'); } } catch (Exception $e) { - error_log("Publish error: " . $e->getMessage()); - App::flash('error', "Erreur lors de la modification: " . $e->getMessage()); + error_log('publish.php error: ' . $e->getMessage()); + App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage()); } -// Regenerate CSRF token $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - header('Location: ../index.php'); exit; diff --git a/public/admin/add.php b/public/admin/add.php index a2ca581..9837dee 100644 --- a/public/admin/add.php +++ b/public/admin/add.php @@ -9,19 +9,14 @@ if (empty($_SESSION["csrf_token"])) { $pageTitle = "Ajouter un TFE"; -require_once __DIR__ . '/../../src/Database.php'; +require_once __DIR__ . '/../../src/ThesisCreateController.php'; try { - $db = new Database(); - $orientations = $db->getAllOrientations(); - $apPrograms = $db->getAllAPPrograms(); - $finalityTypes = $db->getAllFinalityTypes(); - $languages = $db->getAllLanguages(); - $formatTypes = $db->getAllFormatTypes(); - $licenseTypes = $db->getAllLicenseTypes(); + $ctrl = ThesisCreateController::make(); + extract($ctrl->loadFormData()); } catch (Exception $e) { - error_log("Failed to load form data: " . $e->getMessage()); - die("Erreur lors du chargement du formulaire."); + error_log('Failed to load form data: ' . $e->getMessage()); + die('Erreur lors du chargement du formulaire.'); } $formData = $_SESSION['form_data'] ?? []; diff --git a/src/Database.php b/src/Database.php index 11b5cbf..20ebc73 100644 --- a/src/Database.php +++ b/src/Database.php @@ -871,6 +871,28 @@ class Database { )->execute($params); } + /** + * Set the published state of a single thesis. + */ + public function setPublished(int $thesisId, bool $published): void { + $this->pdo->prepare( + 'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' + )->execute([$published ? 1 : 0, $thesisId]); + } + + /** + * Set the published state for multiple theses at once. + * @param int[] $thesisIds + */ + public function bulkSetPublished(array $thesisIds, bool $published): void { + if (empty($thesisIds)) return; + $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); + $params = array_merge([$published ? 1 : 0], $thesisIds); + $this->pdo->prepare( + "UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" + )->execute($params); + } + /** * Get all access types (visibility options). */ diff --git a/src/ThesisCreateController.php b/src/ThesisCreateController.php new file mode 100644 index 0000000..6d6ec57 --- /dev/null +++ b/src/ThesisCreateController.php @@ -0,0 +1,435 @@ +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()); + } + + // ── 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(), + ]; + } + + // ── Write / action ──────────────────────────────────────────────────────── + + /** + * Validate and persist a new-thesis POST submission. + * + * On success, returns the new thesis ID so the caller can redirect to + * thanks.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); + + // ── 2. Find / create author ─────────────────────────────────────────── + $authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null); + error_log("ThesisCreateController: author ID $authorId"); + + // ── 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'], + 'author_id' => $authorId, + ]); + + $identifier = $this->db->getThesisIdentifier($thesisId); + error_log("ThesisCreateController: created thesis #$thesisId ($identifier)"); + + $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); + + 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'; + 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 + { + $auteurName = $this->validateRequired( + $this->sanitiseString($post['auteurice'] ?? ''), + 'Nom/Prénom/Pseudo' + ); + + $mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : ''; + + $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, + ]; + } + 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; + + // 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.'); + } + } + + return compact( + 'auteurName', 'mail', 'annee', 'orientationId', 'apProgramId', + 'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo', + 'juryMembers', 'keywords', 'languageIds', 'formatIds', + 'licenseId', 'lien' + ); + } + + // ── 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). + */ + private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads): void + { + if (!$uploads || !is_array($uploads['name'] ?? null)) { + return; + } + + $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$identifier}/"; + 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("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'; + } + + 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), skipping"); + continue; + } + + if ($uploads['size'][$i] > self::MAX_FILE_SIZE) { + error_log("ThesisCreateController: file too large {$uploads['name'][$i]}, skipping"); + continue; + } + + $safeName = bin2hex(random_bytes(16)) . '.' . $ext; + $targetPath = $uploadDir . $safeName; + + if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { + error_log("ThesisCreateController: failed to move file {$uploads['name'][$i]}"); + continue; + } + + chmod($targetPath, 0644); + + $fileType = 'other'; + if ($ext === 'vtt') { + $fileType = 'caption'; + } elseif (stripos($uploads['name'][$i], 'annex') !== false) { + $fileType = 'annex'; + } elseif ($ext === 'pdf') { + $fileType = 'main'; + } + + $relPath = "theses/{$year}/{$identifier}/" . $safeName; + $this->db->insertThesisFile( + $thesisId, + $fileType, + $relPath, + basename($uploads['name'][$i]), + $uploads['size'][$i], + $mimeType + ); + error_log("ThesisCreateController: file uploaded → $safeName ($fileType)"); + } + } + + // ── 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; + } +} diff --git a/todo/02-php-components.md b/todo/02-php-components.md index 4ded164..110b981 100644 --- a/todo/02-php-components.md +++ b/todo/02-php-components.md @@ -21,7 +21,7 @@ - [x] Extract `ThesisEditController` — `src/ThesisEditController.php` (285 lines); `load()` fetches thesis row, current language/format/jury selections and all lookup tables for the view; `save()` validates and persists metadata, authors, jury, languages, formats, tags, banner in a transaction; static `autofocusFieldForError()` centralises WCAG 3.3.1 field-name mapping; `admin/edit.php` reduced 191→162 lines; `actions/edit.php` reduced 153→53 lines - [x] Extract `TfeController` — `src/TfeController.php`; ID validation, thesis load (404→redirect), access-type check, meta-description assembly, OG/Twitter tag construction (banner→image→empty resolution), WebVTT caption-file collection, and all page-meta variables moved out of `public/tfe.php`; entry point is now a 9-line dispatcher (`create()` + `handle()` + `extract()`); `tfe.php` reduced 271→206 lines; `$db` reference removed from view layer entirely - [x] Extract `HomeController` — `src/HomeController.php`; page/year param parsing, display-mode detection (default-random / year-filtered / paginated-all), DB queries (`getLatestPublishedYear`, `getLatestYearTheses`, `searchTheses`, `countSearchResults`, `getPublishedTheses`, `countPublishedTheses`, `getCoverPathsForTheses`, `getAvailableYears`), cover-image batch loading, OG/meta tag assembly, and `$baseParams` construction moved out of `public/index.php`; entry point is now a 6-line dispatcher (`create()` + `handle()` + `extract()`); `index.php` reduced from 100 → 71 lines; all data-fetching and error-handling logic removed from view layer -- [ ] Consolidate action handlers into controller methods +- [x] Consolidate action handlers into controller methods — `ThesisCreateController` (`src/ThesisCreateController.php`, 435 lines) extracted from `actions/formulaire.php`: `make()` factory, `loadFormData()` for add-form lookup tables, `submit()` for full new-thesis creation (validation, transaction, cover/banner/file uploads), `autofocusFieldForError()` for WCAG 3.3.1; `actions/formulaire.php` reduced 346→45 lines; `admin/add.php` DB block replaced with `ThesisCreateController::make()->loadFormData()`; `Database::setPublished()` and `Database::bulkSetPublished()` added, eliminating the raw SQL in `actions/publish.php` (100→65 lines); no raw PDO calls remain in any action file - [x] Unify flash message keys project-wide to `_flash_error` / `_flash_success` — all callers already use `App::flash()`; removed dead legacy-key fallback chains (`error`, `admin_error`, `edit_error`, `form_error`, `success`, `admin_success`, `edit_success`) from `consumeFlash()` - [x] Move OG tag construction into controller logic — all three public controllers (`SearchController`, `TfeController`, and the new home-page controller once extracted) build `$ogTags` internally and return it as a plain array key; no OG tag assembly remains in entry-point scripts - [x] Extract inline CSS/JS from `system.php` into separate assets — JS moved to `public/assets/js/system.js` (loaded via `$extraJs`); 4 inline `style=` attributes replaced with CSS classes; only dynamic CSS custom properties (`--disk-pct`, `--disk-color`) remain as inline styles because they carry PHP runtime values