fix: req annexes, add HTMX inline file validation (MIME/size)

- Annexes file input now required when 'has_annexes' checkbox is checked
- PHP-side validation: if has_annexes but no files, throw error
- HTMX inline file validation: POSTs to validate-file-fragment on file change
  - Validates MIME type against per-field whitelists (couverture, note_intention,
    tfe, annexes)
  - Validates file size with PDF-specific 100MB limit
  - Supports both single-file and multi-file inputs
  - Returns green ✓ or red ✕ inline validation messages
- Shared validation logic in src/Controllers/validate-file-fragment-shared.php
- Admin wrapper: admin/validate-file-fragment.php (with AdminAuth guard)
- Partage route: /partage/validate-file-fragment (dispatched via index.php)
- CSS: .file-validation-msg, .fv-ok (green), .fv-error (red)
- file-field.php: accepts $fieldName for per-input validation type,
  auto-detects admin/partage validate URL
This commit is contained in:
Pontoporeia
2026-05-10 15:55:35 +02:00
parent a1a5d4609f
commit e06a317499
10 changed files with 503 additions and 130 deletions

View File

@@ -512,6 +512,12 @@ class ThesisCreateController
$exemplaireErg = !empty($post['exemplaire_erg']);
$cc2r = !empty($post['cc2r']);
// Annexes validation: if has_annexes is checked, at least one annexe file must be provided
$hasAnnexes = !empty($post['has_annexes']);
if (!$adminMode && $hasAnnexes && empty($_FILES['annexes']['name'][0])) {
throw new Exception('Veuillez fournir au moins un fichier d\'annexe.');
}
return compact(
'authorNames',
'mail',

View File

@@ -0,0 +1,191 @@
<?php
/**
* validate-file-fragment.php
*
* HTMX fragment: validates a single uploaded file against MIME type and size
* constraints. Returns an inline error message or clears the file input wrapper.
*
* Expected POST (multipart/form-data):
* file — the uploaded file
* field_name — 'couverture' | 'note_intention' | 'tfe' | 'annexes'
* admin_mode — '1' to skip validation (admins can upload anything)
*/
require_once __DIR__ . '/../../bootstrap.php';
$adminMode = ($_POST['admin_mode'] ?? '0') === '1';
$fieldName = $_POST['field_name'] ?? '';
// Read file from the field-name-specific key (e.g., $_FILES['couverture'], $_FILES['annexes'])
// For multi-file inputs (name ends with []), the first file is validated.
$rawFile = $_FILES[$fieldName] ?? null;
if ($rawFile && is_array($rawFile['name'] ?? null)) {
// Multi-file: flatten first entry
$file = [
'name' => $rawFile['name'][0] ?? null,
'tmp_name' => $rawFile['tmp_name'][0] ?? null,
'error' => $rawFile['error'][0] ?? UPLOAD_ERR_NO_FILE,
'size' => $rawFile['size'][0] ?? 0,
];
// Validate ALL selected files (batch validation)
$allFiles = [];
$count = count($rawFile['name'] ?? []);
for ($i = 0; $i < $count; $i++) {
$allFiles[] = [
'name' => $rawFile['name'][$i] ?? null,
'tmp_name' => $rawFile['tmp_name'][$i] ?? null,
'error' => $rawFile['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $rawFile['size'][$i] ?? 0,
];
}
} else {
$file = $rawFile;
$allFiles = $file ? [$file] : [];
}
// ── No file provided — clear any existing validation state ──────────────────
if (empty($allFiles)) {
echo '';
exit;
}
if ($adminMode) {
// Admins: no validation, always OK
$count = count($allFiles);
echo '<span class="fv-ok">✓ ' . $count . ' fichier' . ($count > 1 ? 's' : '') . ' accepté' . ($count > 1 ? 's' : '') . '</span>';
exit;
}
// ── MIME + extension whitelist per field type ────────────────────────────────
$finfo = new finfo(FILEINFO_MIME_TYPE);
$constraints = match ($fieldName) {
'couverture' => [
'mimes' => ['image/jpeg', 'image/png', 'image/webp'],
'exts' => ['jpg', 'jpeg', 'png', 'webp'],
'maxSize' => 20 * 1024 * 1024, // 20 MB
'label' => 'Image de couverture',
'allowedDesc' => 'JPG, PNG ou WEBP',
'maxSizeDesc' => '20 MB',
],
'note_intention' => [
'mimes' => ['application/pdf'],
'exts' => ['pdf'],
'maxSize' => 100 * 1024 * 1024, // 100 MB
'label' => 'Note d\'intention',
'allowedDesc' => 'PDF uniquement',
'maxSizeDesc' => '100 MB',
],
'tfe' => [
'mimes' => [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
'text/vtt',
'application/zip', 'application/x-zip-compressed',
'application/x-tar', 'application/gzip',
'application/octet-stream',
],
'exts' => [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf',
'mp4', 'webm', 'ogv', 'mov',
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
'vtt', 'zip', 'tar', 'gz', 'tgz',
],
'maxSize' => 500 * 1024 * 1024, // 500 MB
'label' => 'Fichier TFE',
'allowedDesc' => 'PDF, images, vidéos, audio, archives',
'maxSizeDesc' => '500 MB',
],
'annexes' => [
'mimes' => [
'application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
'text/vtt',
'application/zip', 'application/x-zip-compressed',
'application/x-tar', 'application/gzip',
'application/octet-stream',
],
'exts' => [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf',
'mp4', 'webm', 'ogv', 'mov',
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
'vtt', 'zip', 'tar', 'gz', 'tgz',
],
'maxSize' => 500 * 1024 * 1024, // 500 MB
'label' => 'Annexe',
'allowedDesc' => 'PDF, archives, images',
'maxSizeDesc' => '500 MB',
],
default => [
'mimes' => [],
'exts' => [],
'maxSize' => 500 * 1024 * 1024,
'label' => 'Fichier',
'allowedDesc' => 'tous types',
'maxSizeDesc' => '500 MB',
],
};
// ── Validate each file ──────────────────────────────────────────────────────
$errors = [];
$oks = [];
foreach ($allFiles as $idx => $f) {
if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
continue;
}
$mimeType = $finfo->file($f['tmp_name']);
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
$size = $f['size'];
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
// PDF size limit
$effMaxSize = $constraints['maxSize'];
$effMaxDesc = $constraints['maxSizeDesc'];
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
$effMaxSize = min($effMaxSize, 100 * 1024 * 1024);
$effMaxDesc = '100 MB';
}
// Validate MIME
$mimeOk = false;
if ($mimeType === 'application/octet-stream' && !in_array($ext, $constraints['exts'], true)) {
$mimeOk = false;
} elseif (in_array($mimeType, $constraints['mimes'], true)) {
$mimeOk = true;
} elseif (in_array($ext, $constraints['exts'], true)) {
$mimeOk = true;
}
if (!$mimeOk) {
$errors[] = '✕ <em>' . htmlspecialchars($f['name']) . '</em> : type non accepté.'
. ' Formats acceptés : ' . htmlspecialchars($constraints['allowedDesc']) . '.';
continue;
}
if ($size > $effMaxSize) {
$mb = round($size / 1024 / 1024, 1);
$errors[] = '✕ <em>' . htmlspecialchars($f['name']) . '</em> : fichier trop volumineux ('
. $mb . ' MB). Maximum : ' . htmlspecialchars($effMaxDesc) . '.';
continue;
}
$oks[] = '✓ <em>' . htmlspecialchars($f['name']) . '</em> : ' . round($size / 1024 / 1024, 1) . ' MB';
}
// ── Output ────────────────────────────────────────────────────────────────────
if (!empty($errors)) {
echo '<span class="fv-error">' . implode('<br>', $errors) . '</span>';
} elseif (!empty($oks)) {
echo '<span class="fv-ok">' . implode('<br>', $oks) . '</span>';
} else {
echo '';
}