fix: add help email, preserve file names on validation error, license fix

The share link (partage) form does not expose a license field and does
not send access_type_id (defaults to 2/Interne). Server-side validation
was unconditionally requiring a license for non-admin submissions,
causing all share link submissions to fail.

Now the license check is gated on adminMode=false AND accessTypeId=1
(Libre), matching the client-side HTMX fragment behaviour in
licence-fragment.php. Also fixed a use-before-definition where
accessTypeId was referenced before being assigned.

Student form improvements:
- Add xamxam@erg.be mailto link at top of form
- On validation error, append "Si le problème persiste, envoyez un
  e-mail à xamxam@erg.be" to the flash message
- Preserve uploaded file names across validation redirects: store in
  session (share_primed_files_<slug>), display as warning on form
  re-render so the student knows which files to re-select

- License: only required for non-admin when access_type_id=1 (Libre),
  not for Interne (2) or Interdit (3). Fixes share link submissions
  failing with "Veuillez sélectionner une licence". Also fixed
  use-before-definition of accessTypeId.
This commit is contained in:
Pontoporeia
2026-05-10 14:06:05 +02:00
parent 6224e3ede0
commit ab6e266807
11 changed files with 828 additions and 553 deletions

View File

@@ -0,0 +1,591 @@
<?php
/**
* Trait ThesisFileHandler
*
* Shared file-upload logic used by ThesisCreateController and
* ThesisEditController. All on-disk files are stored under:
*
* theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
*
* Filenames follow:
*
* {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}.ext (single file)
* {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}_{XX}.ext (numbered, 01-based)
*
* Semantic groups:
* COUVERTURE single cover image
* NOTE_INTENTION single PDF
* TFE_{XX} main thesis files (contiguous numbering)
* ANNEXE_{XX} annex files (separate numbering)
*
* TFE hierarchy (determines numbering order):
* 1. PDFs
* 2. Videos
* 3. Audio
* 4. Subtitles (VTT placed right after their video)
* 5. Images
* 6. Archives / other
*
* PeerTube and website URLs are stored as thesis_files rows with
* file_path containing the URL; no on-disk file is created.
*
* The original user-provided filename is preserved in thesis_files.file_name.
*/
trait ThesisFileHandler
{
/** Maximum allowed file size for thesis files (bytes). */
private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
/** Maximum allowed file size for PDF files specifically (bytes). */
private const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB
/** Cover image max size. */
private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
/** MIME types accepted for thesis files. */
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'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',
];
/** File extensions accepted for thesis files. */
private const ALLOWED_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp',
'pdf',
'mp4', 'webm', 'ogv', 'mov',
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
'vtt',
'zip', 'tar', 'gz', 'tgz',
];
// ── Public entry points (called by controllers) ──────────────────────────
/**
* Process a cover image upload.
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry.
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "theses/2025/2025_SMITH_Mon_Titre/").
* @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre").
*/
protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): 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));
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
if (!in_array($mimeType, $allowedMimes, true)
|| !in_array($ext, $allowedExts, true)
|| $upload['size'] > self::MAX_COVER_SIZE) {
error_log("ThesisFileHandler: invalid cover MIME $mimeType / $ext / {$upload['size']} bytes, skipping");
return;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$targetName = $filePrefix . '_COUVERTURE.' . $ext;
$targetPath = $dir . $targetName;
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
error_log("ThesisFileHandler: failed to move cover to $targetPath");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, 'cover',
$relPath,
basename($upload['name']),
$upload['size'],
$mimeType
);
error_log("ThesisFileHandler: cover uploaded → $relPath");
}
/**
* Process the note d'intention upload (single PDF).
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry.
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
* @param string $filePrefix The shared file prefix.
*/
protected function handleNoteIntentionUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): 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 ($mimeType !== 'application/pdf' || $ext !== 'pdf') {
error_log("ThesisFileHandler: invalid note d'intention MIME $mimeType, skipping");
return;
}
if ($upload['size'] > self::MAX_PDF_SIZE) {
error_log("ThesisFileHandler: note d'intention too large ({$upload['size']} bytes), skipping");
return;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$targetName = $filePrefix . '_NOTE_INTENTION.pdf';
$targetPath = $dir . $targetName;
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
error_log("ThesisFileHandler: failed to move note d'intention to $targetPath");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, 'note_intention',
$relPath,
basename($upload['name']),
$upload['size'],
'application/pdf'
);
error_log("ThesisFileHandler: note d'intention uploaded → $relPath");
}
/**
* Process multiple TFE file uploads (files[] — the main set).
*
* Files are stored with TFE_{XX} semantics. Numbering is contiguous
* across all sub-types in order: PDF → video → audio → subtitles → images → archives.
* Subtitles (VTT) are placed immediately after their associated video.
*
* @param int $thesisId
* @param array|null $uploads Multi-file $_FILES entry.
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
* @param string $filePrefix The shared file prefix.
* @param array $post $_POST for per-file labels/orders.
* @param int $startNum Starting number for TFE_XX (usually 01).
*/
protected function handleTfeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = [], int $startNum = 1): int
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return $startNum;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$fileLabels = $post['file_labels'] ?? [];
$fileOrders = $post['file_orders'] ?? [];
// Collect all files, classify, sort by hierarchy
$files = [];
$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("ThesisFileHandler: TFE upload error {$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));
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
error_log("ThesisFileHandler: TFE extension not allowed {$uploads['name'][$i]} ($ext), skipping");
continue;
}
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
&& !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
error_log("ThesisFileHandler: invalid TFE type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
continue;
}
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
$sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE;
if ($uploads['size'][$i] > $sizeLimit) {
error_log("ThesisFileHandler: TFE file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
continue;
}
$files[] = [
'index' => $i,
'mimeType' => $mimeType,
'ext' => $ext,
'size' => $uploads['size'][$i],
'tmpName' => $uploads['tmp_name'][$i],
'origName' => $uploads['name'][$i],
'label' => trim($fileLabels[$i] ?? ''),
'sortOrder' => isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null,
'hierarchy' => $this->tfeHierarchyRank($mimeType, $ext),
'fileType' => $this->detectFileType($mimeType, $ext),
];
}
// Sort by hierarchy rank
usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']);
// Assign contiguous TFE_XX numbers
$videoCount = 0;
$vttQueue = [];
// First pass: count videos to know where to insert VTTs
foreach ($files as $f) {
if ($f['fileType'] === 'video') {
$videoCount++;
}
}
$num = $startNum;
$vttIdx = 0;
foreach ($files as $f) {
// VTT files are inserted right after their corresponding video
if ($f['fileType'] === 'caption') {
$vttQueue[] = $f;
continue;
}
if ($f['fileType'] === 'video') {
// Write the video file
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
// Write any waiting VTT for this video
if (!empty($vttQueue)) {
$vtt = array_shift($vttQueue);
$this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
} else {
$this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
}
// Any remaining VTTs (orphaned — write at end)
foreach ($vttQueue as $vtt) {
$this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
$num++;
}
return $num;
}
/**
* Process annex file uploads.
*
* @param int $thesisId
* @param array|null $uploads Multi-file $_FILES entry.
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder.
* @param string $filePrefix The shared file prefix.
* @param array $post $_POST for per-file labels/orders.
*/
protected function handleAnnexeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = []): void
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
$dir = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$fileLabels = $post['annexe_labels'] ?? [];
$fileOrders = $post['annexe_orders'] ?? [];
$num = 1;
$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("ThesisFileHandler: annexe upload error {$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));
if ($mimeType === 'text/plain' && $ext === 'vtt') {
$mimeType = 'text/vtt';
}
$sizeLimit = (($mimeType === 'application/pdf' || $ext === 'pdf') ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE);
if ($uploads['size'][$i] > $sizeLimit) {
error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
continue;
}
$padded = sprintf('%02d', $num);
$targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext;
$targetPath = $dir . $targetName;
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
error_log("ThesisFileHandler: failed to move annexe {$uploads['name'][$i]}");
continue;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$label = trim($fileLabels[$i] ?? '');
$sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null;
$fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex';
$this->db->insertThesisFile(
$thesisId, $fileType,
$relPath,
basename($uploads['name'][$i]),
$uploads['size'][$i],
$mimeType,
$label !== '' ? $label : null,
$sortOrder
);
error_log("ThesisFileHandler: annexe uploaded → $targetName");
$num++;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Write a single TFE file to disk and record in DB.
*/
protected function writeTfeFile(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void
{
$padded = sprintf('%02d', $num);
$targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext'];
$targetPath = $dir . $targetName;
if (!move_uploaded_file($f['tmpName'], $targetPath)) {
error_log("ThesisFileHandler: failed to move TFE {$f['origName']}");
return;
}
chmod($targetPath, 0644);
$relPath = $folderPath . $targetName;
$this->db->insertThesisFile(
$thesisId, $f['fileType'],
$relPath,
basename($f['origName']),
$f['size'],
$f['mimeType'],
$f['label'] !== '' ? $f['label'] : null,
$f['sortOrder']
);
error_log("ThesisFileHandler: TFE uploaded → $targetName ({$f['fileType']})");
}
/**
* Assign a hierarchy rank for sorting TFE files.
* Lower = earlier in the sequence.
*
* 0 = PDF
* 1 = video
* 2 = audio
* 3 = caption (VTT)
* 4 = image
* 5 = archive / other
*/
private function tfeHierarchyRank(string $mimeType, string $ext): int
{
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 0;
}
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 3;
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 1;
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 2;
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 4;
}
return 5;
}
/**
* Determine the logical file_type from MIME type / extension.
*/
protected function detectFileType(string $mimeType, string $ext): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
}
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 'main';
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 'video';
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 'audio';
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 'image';
}
return 'other';
}
// ── String / filesystem helpers ──────────────────────────────────────────
/**
* Generate a filesystem-safe author slug from comma-separated author names.
* Sorted alphabetically, joined with dash, accent-stripped, uppercase.
*/
protected function generateAuthorSlug(string $authorNames): string
{
$names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== ''));
sort($names, SORT_NATURAL);
$joined = implode('-', $names);
$normalized = $joined;
if (function_exists('iconv')) {
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
}
$accents = [
'à' => 'a', 'â' => 'a', 'ä' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
'î' => 'i', 'ï' => 'i',
'ô' => 'o', 'ö' => 'o',
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
'ç' => 'c',
'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'Î' => 'I', 'Ï' => 'I',
'Ô' => 'O', 'Ö' => 'O',
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
'Ç' => 'C',
];
$normalized = strtr($normalized, $accents);
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
$slug = trim($slug, '_');
$slug = strtoupper($slug);
if ($slug === '') {
$slug = 'AUTHOR';
}
return $slug;
}
/**
* Generate a filesystem-safe title slug (truncated to 60 chars).
*/
protected function generateTitleSlug(string $title): string
{
$normalized = $title;
if (function_exists('iconv')) {
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
}
$accents = [
'à' => 'a', 'â' => 'a', 'ä' => 'a', 'æ' => 'ae',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
'î' => 'i', 'ï' => 'i',
'ô' => 'o', 'ö' => 'o', 'œ' => 'oe',
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
'ç' => 'c',
'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Æ' => 'AE',
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'Î' => 'I', 'Ï' => 'I',
'Ô' => 'O', 'Ö' => 'O', 'Œ' => 'OE',
'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
'Ç' => 'C',
"'" => '', '"' => '', '«' => '', '»' => '',
];
$normalized = strtr($normalized, $accents);
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
$slug = trim($slug, '_');
$slug = strtoupper($slug);
if (strlen($slug) > 60) {
$slug = substr($slug, 0, 60);
}
if ($slug === '') {
$slug = 'TFE';
}
return $slug;
}
/**
* Build the folder path and file prefix for a thesis.
*
* Folder: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG}
*
* @return array{folderPath: string, filePrefix: string, folderName: string}
*/
protected function buildThesisFolder(int $year, string $authorsStr, string $title): array
{
$authorSlug = $this->generateAuthorSlug($authorsStr);
$titleSlug = $this->generateTitleSlug($title);
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
$filePrefix = $folderName;
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
return [
'folderPath' => $folderPath,
'filePrefix' => $filePrefix,
'folderName' => $folderName,
];
}
/**
* Ensure the thesis folder exists (create if needed).
* If the folder already exists, append a numeric suffix.
*/
protected function ensureUniqueFolder(string $folderPath): string
{
$baseDir = STORAGE_ROOT . '/' . dirname($folderPath) . '/';
$candidate = basename(rtrim($folderPath, '/'));
if (!is_dir($baseDir . $candidate)) {
mkdir($baseDir . $candidate, 0755, true);
return $candidate;
}
$suffix = 1;
while (is_dir($baseDir . $candidate . '_' . $suffix)) {
$suffix++;
}
$candidate .= '_' . $suffix;
mkdir($baseDir . $candidate, 0755, true);
return $candidate;
}
}