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-19 00:08:05 +02:00
parent 6224e3ede0
commit ab6e266807
11 changed files with 828 additions and 553 deletions
+65 -205
View File
@@ -1,5 +1,7 @@
<?php
require_once __DIR__ . '/ThesisFileHandler.php';
/**
* ThesisEditController
*
@@ -18,6 +20,8 @@
*/
class ThesisEditController
{
use ThesisFileHandler;
private Database $db;
public function __construct(Database $db)
@@ -305,10 +309,39 @@ class ThesisEditController
throw $e;
}
// ── Resolve thesis folder path (reuse existing or build new) ────────
$year = intval($post['année'] ?? date('Y'));
$title = trim($post['titre'] ?? '');
$authors = trim($post['auteurice'] ?? '');
$tf = $this->buildThesisFolder($year, $authors, $title);
$folderPath = $tf['folderPath'];
$filePrefix = $tf['filePrefix'];
// Reuse existing folder if this thesis already has files on disk
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
$fp = $f['file_path'] ?? '';
if (str_starts_with($fp, 'theses/')) {
$parts = explode('/', $fp);
if (count($parts) >= 3) {
$folderName = $parts[2];
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
$filePrefix = $folderName;
break;
}
}
}
// Ensure the folder exists
$dirAbs = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dirAbs)) {
mkdir($dirAbs, 0755, true);
}
// ── Cover image (outside transaction — filesystem op) ─────────────────
if (isset($post['remove_cover'])) {
$allFiles = $this->db->getThesisFiles($thesisId);
foreach ($allFiles as $f) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
@@ -321,9 +354,27 @@ class ThesisEditController
}
}
} else {
$this->db->handleCoverUpload($thesisId, $files['couverture'] ?? null);
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
}
// ── Note d'intention (replace if uploaded) ────────────────────────────
// Remove old note_intention row+file if new one is uploaded
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
break;
}
}
}
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
// ── Delete individual thesis files ────────────────────────────────────
$deleteIds = isset($post['delete_files']) && is_array($post['delete_files'])
? array_map('intval', $post['delete_files'])
@@ -360,9 +411,18 @@ class ThesisEditController
}
}
// ── New thesis files upload ───────────────────────────────────────────
// ── New TFE files upload ─────────────────────────────────────────────
if (!empty($files['files']['name'][0])) {
$this->handleThesisFiles($thesisId, $post, $files['files']);
// Count existing TFE files to determine starting number
$tfeCount = 0;
foreach ($existingFiles as $f) {
if (!in_array($f['file_type'] ?? '', ['cover', 'note_intention', 'website', 'annex', 'caption'], true)
&& !str_starts_with($f['file_path'] ?? '', 'http')) {
$tfeCount++;
}
// Don't count captions as separate TFE entries — they'll be renumbered
}
$this->handleTfeFiles($thesisId, $files['files'], $folderPath, $filePrefix, $post, $tfeCount + 1);
}
// ── PeerTube video / audio uploads ────────────────────────────────────
@@ -373,206 +433,6 @@ class ThesisEditController
$this->handleWebsiteUrl($thesisId, $post);
}
// ── Private: file uploads ─────────────────────────────────────────────────
/**
* Process multiple new thesis-file uploads.
*
* Files are stored in the existing folder used by this thesis (detected
* from any current thesis_files row), or a new one is created following
* the same {year}_{authorSlug} convention as ThesisCreateController.
*/
private function handleThesisFiles(int $thesisId, array $post, array $uploads): void
{
$allowedMimes = [
'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',
];
$allowedExts = [
'jpg', 'jpeg', 'png', 'gif', 'webp',
'pdf',
'mp4', 'webm', 'ogv', 'mov',
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
'vtt',
'zip', 'tar', 'gz', 'tgz',
];
$maxBytes = 500 * 1024 * 1024; // 500 MB
$maxPdfBytes = 100 * 1024 * 1024; // 100 MB for PDFs
$year = (int)($post['année'] ?? date('Y'));
$authorName = trim($post['auteurice'] ?? 'unknown');
// Sort the raw comma-separated string alphabetically, then slugify.
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn ($n) => $n !== ''));
sort($names, SORT_NATURAL);
$authorSlug = $this->generateAuthorSlug(implode(', ', $names));
// Per-file labels and sort orders submitted alongside the upload inputs
$fileLabels = $post['file_labels'] ?? [];
$fileOrders = $post['file_orders'] ?? [];
// Reuse existing folder if possible
$existingFiles = $this->db->getThesisFiles($thesisId);
$uploadDir = null;
$folderName = null;
foreach ($existingFiles as $f) {
if (str_starts_with($f['file_path'] ?? '', 'theses/')) {
$parts = explode('/', $f['file_path']);
if (count($parts) >= 3) {
$folderName = $parts[2];
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
break;
}
}
}
if ($uploadDir === null) {
$folderName = $this->ensureUniqueFolder($year, $authorSlug);
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
}
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("ThesisEditController: 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';
}
// Allow any ext-matched file even if finfo returns application/octet-stream
if (!in_array($mimeType, $allowedMimes, true) && !in_array($ext, $allowedExts, true)) {
error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
continue;
}
$isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf');
$sizeLimit = $isPdf ? $maxPdfBytes : $maxBytes;
if ($uploads['size'][$i] > $sizeLimit) {
error_log("ThesisEditController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
continue;
}
$originalName = $uploads['name'][$i];
$sanitized = $this->sanitizeFilename($originalName);
$prefix = $authorSlug . '_' . $sanitized;
$candidate = $prefix;
$suffix = 1;
while (file_exists($uploadDir . $candidate)) {
$candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext;
$suffix++;
}
$targetPath = $uploadDir . $candidate;
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
error_log("ThesisEditController: failed to move {$originalName}");
continue;
}
chmod($targetPath, 0644);
$fileType = $this->detectFileType($mimeType, $ext, $originalName);
$label = trim($fileLabels[$i] ?? '');
$sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null;
$relPath = "theses/{$year}/{$folderName}/" . $candidate;
$this->db->insertThesisFile(
$thesisId,
$fileType,
$relPath,
basename($originalName),
$uploads['size'][$i],
$mimeType,
$label !== '' ? $label : null,
$sortOrder
);
error_log("ThesisEditController: uploaded → $candidate ($fileType)");
}
}
/**
* Determine the logical file_type from MIME type, extension, and original filename.
*/
private function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
}
if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
return 'audio';
}
if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
return 'video';
}
if ($mimeType === 'application/pdf' || $ext === 'pdf') {
return 'main';
}
if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
return 'image';
}
return 'other';
}
// ── Private: string helpers ───────────────────────────────────────────────
private function generateAuthorSlug(string $authorName): string
{
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $authorName) : $authorName;
$accents = [
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
];
$n = strtr($n, $accents);
$slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_'));
return $slug !== '' ? $slug : 'AUTHOR';
}
private function sanitizeFilename(string $filename): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$name = pathinfo($filename, PATHINFO_FILENAME);
$n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name;
$accents = [
'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e',
'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c',
];
$n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_');
if ($n === '') {
$n = 'file';
}
return $ext !== '' ? $n . '.' . strtolower($ext) : $n;
}
private function ensureUniqueFolder(int $year, string $authorSlug): string
{
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
$candidate = $year . '_' . $authorSlug;
$suffix = 1;
while (is_dir($baseDir . $candidate)) {
$candidate = $year . '_' . $authorSlug . '_' . $suffix++;
}
return $candidate;
}
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
/**