Extract ThesisCreateController; add Database publish methods

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.
This commit is contained in:
Pontoporeia
2026-04-06 14:37:56 +02:00
parent b1e70a2bf1
commit 2841e05716
7 changed files with 501 additions and 384 deletions

View File

@@ -11,6 +11,7 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md):
## Recently completed (this session) ## 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/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 - [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

View File

@@ -1,346 +1,45 @@
<?php // formulaire.php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../../config/bootstrap.php"; require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
// Configure error reporting
ini_set('display_errors', 0); ini_set('display_errors', 0);
ini_set('log_errors', 1); ini_set('log_errors', 1);
ini_set('error_log', 'error.log'); ini_set('error_log', 'error.log');
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin(); AdminAuth::requireLogin();
// Verify CSRF token // Verify CSRF token
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) || if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log("CSRF token validation failed"); error_log('CSRF token validation failed in formulaire.php');
die("Erreur de sécurité : token invalide. Veuillez recharger le formulaire."); die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.');
} }
// Log the content of the $_FILES array error_log('FILES array: ' . print_r($_FILES, true));
error_log("FILES array: " . print_r($_FILES, true));
require_once __DIR__ . '/../../../src/Database.php'; require_once APP_ROOT . '/src/ThesisCreateController.php';
// Helper function to sanitize string input
function sanitize_string($input) {
// Trim and strip raw HTML tags only — htmlspecialchars belongs at render time, not storage time.
// PDO parameterised queries handle SQL injection; the templates call htmlspecialchars() on output.
return strip_tags(trim($input));
}
// Helper function to validate required field
function validate_required($value, $fieldName) {
if (empty($value)) {
throw new Exception("Le champ '$fieldName' est requis.");
}
return $value;
}
try { try {
// Initialize database connection $ctrl = ThesisCreateController::make();
$db = new Database(); $thesisId = $ctrl->submit($_POST, $_FILES);
// Begin transaction - all or nothing
$db->beginTransaction();
// ===== 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']); unset($_SESSION['csrf_token']);
// Redirect to thank you page
header('Location: ../thanks.php?id=' . urlencode($thesisId)); header('Location: ../thanks.php?id=' . urlencode($thesisId));
exit(); exit();
} catch (Exception $e) { } catch (Exception $e) {
// Rollback transaction on error error_log('ThesisCreateController error: ' . $e->getMessage());
if (isset($db)) {
$db->rollback();
}
error_log("Form processing error: " . $e->getMessage());
// Save error message and form data to session
App::flash('error', $e->getMessage()); App::flash('error', $e->getMessage());
$_SESSION['form_data'] = $_POST; $_SESSION['form_data'] = $_POST;
// WCAG 3.3.1 — identify which field caused the error and request autofocus. $autofocusField = ThesisCreateController::autofocusFieldForError($e->getMessage());
// 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';
if ($autofocusField !== null) { if ($autofocusField !== null) {
App::flashAutofocus($autofocusField); App::flashAutofocus($autofocusField);
} }
// Redirect back to form with preserved data
header('Location: ../add.php'); header('Location: ../add.php');
exit(); exit();
} }

View File

@@ -1,100 +1,65 @@
<?php <?php
// Bootstrap application require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . "/../../../config/bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/AdminAuth.php';
/**
* Handle publish/unpublish actions for theses
*/
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin(); AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php'; require_once __DIR__ . '/../../../src/Database.php';
// Verify CSRF token if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', "Erreur de sécurité : token invalide."); App::flash('error', 'Erreur de sécurité : token invalide.');
header('Location: ../index.php'); header('Location: ../index.php');
exit; exit;
} }
$action = isset($_POST['action']) ? $_POST['action'] : ''; $action = $_POST['action'] ?? '';
$isBulk = isset($_POST['bulk']) && $_POST['bulk'] == '1'; $isBulk = !empty($_POST['bulk']);
if (!in_array($action, ['publish', 'unpublish'])) { if (!in_array($action, ['publish', 'unpublish'], true)) {
App::flash('error', "Action invalide."); App::flash('error', 'Action invalide.');
header('Location: ../index.php'); header('Location: ../index.php');
exit; exit;
} }
$published = ($action === 'publish');
try { try {
$db = new Database(); $db = new Database();
$pdo = $db->getPDO();
$isPublished = ($action === 'publish') ? 1 : 0;
if ($isBulk) { if ($isBulk) {
// Handle bulk action $ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
$thesisIds = isset($_POST['selected_theses']) ? $_POST['selected_theses'] : [];
if (empty($thesisIds)) { if (empty($ids)) {
App::flash('error', "Aucun TFE sélectionné."); App::flash('error', 'Aucun TFE sélectionné.');
header('Location: ../index.php'); header('Location: ../index.php');
exit; exit;
} }
// Validate all IDs are integers $db->bulkSetPublished($ids, $published);
$thesisIds = array_map('intval', $thesisIds); $count = count($ids);
$thesisIds = array_filter($thesisIds, fn($id) => $id > 0); App::flash('success', $published
? "$count TFE(s) publié(s) avec succès."
if (empty($thesisIds)) { : "$count TFE(s) retiré(s) de la publication.");
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.");
}
} else { } else {
// Handle single action $thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
$thesisId = isset($_POST['thesis_id']) ? intval($_POST['thesis_id']) : 0;
if ($thesisId <= 0) { if (!$thesisId || $thesisId <= 0) {
App::flash('error', "ID invalide."); App::flash('error', 'ID invalide.');
header('Location: ../index.php'); header('Location: ../index.php');
exit; exit;
} }
$stmt = $pdo->prepare("UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $db->setPublished($thesisId, $published);
$stmt->execute([$isPublished, $thesisId]); App::flash('success', $published ? 'TFE publié avec succès.' : 'TFE retiré de la publication.');
if ($action === 'publish') {
App::flash('success', "TFE publié avec succès!");
} else {
App::flash('success', "TFE retiré de la publication.");
}
} }
} catch (Exception $e) { } catch (Exception $e) {
error_log("Publish error: " . $e->getMessage()); error_log('publish.php error: ' . $e->getMessage());
App::flash('error', "Erreur lors de la modification: " . $e->getMessage()); App::flash('error', 'Erreur lors de la modification : ' . $e->getMessage());
} }
// Regenerate CSRF token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ../index.php'); header('Location: ../index.php');
exit; exit;

View File

@@ -9,19 +9,14 @@ if (empty($_SESSION["csrf_token"])) {
$pageTitle = "Ajouter un TFE"; $pageTitle = "Ajouter un TFE";
require_once __DIR__ . '/../../src/Database.php'; require_once __DIR__ . '/../../src/ThesisCreateController.php';
try { try {
$db = new Database(); $ctrl = ThesisCreateController::make();
$orientations = $db->getAllOrientations(); extract($ctrl->loadFormData());
$apPrograms = $db->getAllAPPrograms();
$finalityTypes = $db->getAllFinalityTypes();
$languages = $db->getAllLanguages();
$formatTypes = $db->getAllFormatTypes();
$licenseTypes = $db->getAllLicenseTypes();
} catch (Exception $e) { } catch (Exception $e) {
error_log("Failed to load form data: " . $e->getMessage()); error_log('Failed to load form data: ' . $e->getMessage());
die("Erreur lors du chargement du formulaire."); die('Erreur lors du chargement du formulaire.');
} }
$formData = $_SESSION['form_data'] ?? []; $formData = $_SESSION['form_data'] ?? [];

View File

@@ -871,6 +871,28 @@ class Database {
)->execute($params); )->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). * Get all access types (visibility options).
*/ */

View File

@@ -0,0 +1,435 @@
<?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 = 50 * 1024 * 1024; // 50 MB
/** MIME types accepted for thesis files. */
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'application/pdf',
'video/mp4', 'application/zip', 'text/vtt',
];
/** File extensions accepted for thesis files. */
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
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());
}
// ── 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(),
];
}
// ── 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=<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);
// ── 2. Find / create author ───────────────────────────────────────────
$authorId = $this->db->findOrCreateAuthor($data['auteurName'], $data['mail'] ?: null);
error_log("ThesisCreateController: author ID $authorId");
// ── 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'],
'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<string, mixed>
* @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;
}
}

View File

@@ -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 `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 `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 - [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] 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] 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 - [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