Files
xamxam/public/admin/actions/formulaire.php
Pontoporeia c2eff75789 WCAG 3.3.1: autofocus first invalid field on add/edit form validation failure
Add App::flashAutofocus(fieldName) and consumeAutofocus() to the thin App
helper so action handlers can identify which field caused a validation error
and the form page can move browser focus directly to it on reload.

Changes:
- src/App.php — flashAutofocus() stores field name in _flash_autofocus
  session key; consumeAutofocus() drains it and returns the name (or null)
- actions/formulaire.php — catch block maps exception messages to field
  names (auteurice, titre, synopsis, année, orientation, ap, finality,
  languages, tag, lien) and calls App::flashAutofocus()
- actions/edit.php — catch block maps common edit errors to field names
  and calls App::flashAutofocus()
- add.php — consumes the hint via App::consumeAutofocus() into
  $autofocusField; withAutofocus() helper merges autofocus=>true into
  $attrs for every field include; synopsis textarea gets inline autofocus
- edit.php — same pattern with inline ternary merges and textarea autofocus
- templates/partials/form/text-field.php — $attrs loop now emits bare
  attribute names (no ="...") when value === true, supporting autofocus,
  disabled, readonly etc. without special-casing
- templates/partials/form/select-field.php — same boolean-attr support
  added; $attrs variable initialised to [] when caller omits it

Closes WCAG 3.3.1 autofocus item in todo/04-accessibility.md.
2026-04-06 15:33:08 +02:00

347 lines
13 KiB
PHP

<?php // formulaire.php
// Bootstrap application
require_once __DIR__ . "/../../../config/bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
// Configure error reporting
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'error.log');
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Verify CSRF token
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log("CSRF token validation failed");
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));
require_once __DIR__ . '/../../../src/Database.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 {
// Initialize database connection
$db = new Database();
// 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']);
// 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("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';
if ($autofocusField !== null) {
App::flashAutofocus($autofocusField);
}
// Redirect back to form with preserved data
header('Location: ../add.php');
exit();
}