mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ThesisFileHandler.php';
|
||||
|
||||
/**
|
||||
* ThesisCreateController
|
||||
*
|
||||
@@ -11,7 +13,7 @@
|
||||
* - 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
|
||||
* - Handling cover image, note d'intention, TFE, annexe, and PeerTube 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
|
||||
@@ -20,46 +22,7 @@
|
||||
*/
|
||||
class ThesisCreateController
|
||||
{
|
||||
/** 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
|
||||
|
||||
/** MIME types accepted for thesis files. */
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
// Images
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
// Video
|
||||
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
|
||||
// Audio
|
||||
'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
|
||||
'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
|
||||
// Captions
|
||||
'text/vtt',
|
||||
// Archives / other downloadables
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
'application/x-tar', 'application/gzip',
|
||||
'application/octet-stream',
|
||||
];
|
||||
|
||||
/** File extensions accepted for thesis files. */
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
// Images
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
// Documents
|
||||
'pdf',
|
||||
// Video
|
||||
'mp4', 'webm', 'ogv', 'mov',
|
||||
// Audio
|
||||
'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
|
||||
// Captions
|
||||
'vtt',
|
||||
// Archives / other
|
||||
'zip', 'tar', 'gz', 'tgz',
|
||||
];
|
||||
use ThesisFileHandler;
|
||||
|
||||
private Database $db;
|
||||
|
||||
@@ -171,7 +134,6 @@ class ThesisCreateController
|
||||
];
|
||||
}
|
||||
$allAuthorsStr = implode(', ', $data['authorNames']);
|
||||
$authorSlug = $this->generateAuthorSlug($allAuthorsStr);
|
||||
|
||||
// ── 3–4. DB writes in a transaction ───────────────────────────────────
|
||||
$this->db->beginTransaction();
|
||||
@@ -226,8 +188,16 @@ class ThesisCreateController
|
||||
}
|
||||
|
||||
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
|
||||
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $authorSlug, $post);
|
||||
$tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']);
|
||||
$folderName = $this->ensureUniqueFolder($tf['folderPath']);
|
||||
// Rebuild path with potentially modified folder name
|
||||
$folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/';
|
||||
$filePrefix = $folderName;
|
||||
|
||||
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
|
||||
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
|
||||
$nextNum = $this->handleTfeFiles($thesisId, $files['files'] ?? null, $folderPath, $filePrefix, $post, 1);
|
||||
// PeerTube file rows don't go on disk, but the uploads themselves are processed separately
|
||||
|
||||
// ── 5b. PeerTube video / audio uploads ────────────────────────────────
|
||||
$this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_video');
|
||||
@@ -495,18 +465,20 @@ class ThesisCreateController
|
||||
throw new Exception('Veuillez sélectionner au moins un format.');
|
||||
}
|
||||
|
||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
$licenseCustom = trim($post['license_custom'] ?? '');
|
||||
if (!$adminMode && !$licenseId && $licenseCustom === '') {
|
||||
throw new Exception('Veuillez sélectionner une licence ou en préciser une.');
|
||||
}
|
||||
|
||||
// Access type — must be one of the enabled types; default 2 (Interne)
|
||||
$accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT);
|
||||
if ($accessTypeId === false || $accessTypeId <= 0) {
|
||||
$accessTypeId = 2; // Interne
|
||||
}
|
||||
|
||||
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
|
||||
$licenseCustom = trim($post['license_custom'] ?? '');
|
||||
// License is only required for non-admin when access type is Libre (1).
|
||||
// For Interne (2) and Interdit (3), it's optional.
|
||||
if (!$adminMode && $accessTypeId === 1 && !$licenseId && $licenseCustom === '') {
|
||||
throw new Exception('Veuillez sélectionner une licence ou en préciser une.');
|
||||
}
|
||||
|
||||
// Objet — restricted to valid values
|
||||
$validObjet = ['tfe', 'thèse', 'frart'];
|
||||
$objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe';
|
||||
@@ -570,176 +542,6 @@ class ThesisCreateController
|
||||
|
||||
// ── 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).
|
||||
* @param string $authorSlug Pre-computed author slug for folder and file naming.
|
||||
*/
|
||||
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorSlug, array $post = []): void
|
||||
{
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$folderName = $this->ensureUniqueFolder($year, $authorSlug);
|
||||
$uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/";
|
||||
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
// Per-file labels and sort orders submitted alongside the upload inputs
|
||||
$fileLabels = $post['file_labels'] ?? [];
|
||||
$fileOrders = $post['file_orders'] ?? [];
|
||||
|
||||
$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';
|
||||
}
|
||||
// application/octet-stream is a valid fallback for arbitrary downloadable files
|
||||
if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
error_log("ThesisCreateController: 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("ThesisCreateController: invalid file 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("ThesisCreateController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanitize original filename and prepend author slug
|
||||
$originalName = $uploads['name'][$i];
|
||||
$sanitized = $this->sanitizeFilename($originalName);
|
||||
$prefix = $authorSlug . '_' . $sanitized;
|
||||
// Ensure unique filename in the folder
|
||||
$candidate = $prefix;
|
||||
$suffix = 1;
|
||||
while (file_exists($uploadDir . $candidate)) {
|
||||
$candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext;
|
||||
$suffix++;
|
||||
}
|
||||
$targetName = $candidate;
|
||||
$targetPath = $uploadDir . $targetName;
|
||||
|
||||
if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
|
||||
error_log("ThesisCreateController: failed to move file {$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}/" . $targetName;
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$relPath,
|
||||
basename($originalName),
|
||||
$uploads['size'][$i],
|
||||
$mimeType,
|
||||
$label !== '' ? $label : null,
|
||||
$sortOrder
|
||||
);
|
||||
error_log("ThesisCreateController: file uploaded → $targetName ($fileType)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the logical file_type from MIME type, extension, and original filename.
|
||||
*/
|
||||
protected 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: input helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -765,101 +567,6 @@ class ThesisCreateController
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filesystem-safe author slug from the author name.
|
||||
* Converts to uppercase, replaces spaces with underscores, removes accents.
|
||||
*/
|
||||
protected function generateAuthorSlug(string $authorName): string
|
||||
{
|
||||
// Remove accents using iconv if available, otherwise simple mapping
|
||||
$normalized = $authorName;
|
||||
if (function_exists('iconv')) {
|
||||
$normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||
}
|
||||
// Fallback accent removal for common French characters
|
||||
$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);
|
||||
// Replace spaces and punctuation with underscore, keep only alphanumeric and underscore
|
||||
$slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||||
$slug = trim($slug, '_');
|
||||
// Convert to uppercase
|
||||
$slug = strtoupper($slug);
|
||||
// Ensure not empty
|
||||
if ($slug === '') {
|
||||
$slug = 'AUTHOR';
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename: remove accents, replace spaces with underscore, remove special chars.
|
||||
* Keeps extension.
|
||||
*/
|
||||
protected function sanitizeFilename(string $filename): string
|
||||
{
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||||
// Remove accents similarly
|
||||
$normalized = $name;
|
||||
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',
|
||||
];
|
||||
$normalized = strtr($normalized, $accents);
|
||||
// Replace non-alphanumeric with underscore
|
||||
$normalized = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
|
||||
$normalized = trim($normalized, '_');
|
||||
// If empty, use 'file'
|
||||
if ($normalized === '') {
|
||||
$normalized = 'file';
|
||||
}
|
||||
// Reattach extension if any
|
||||
if ($ext !== '') {
|
||||
return $normalized . '.' . strtolower($ext);
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a unique folder name inside theses/{year}/.
|
||||
* Pattern: {year}_{authorSlug} or {year}_{authorSlug}_{suffix} if exists.
|
||||
*/
|
||||
protected function ensureUniqueFolder(int $year, string $authorSlug): string
|
||||
{
|
||||
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
|
||||
if (!is_dir($baseDir)) {
|
||||
// No conflict possible, return base name
|
||||
return $year . '_' . $authorSlug;
|
||||
}
|
||||
$candidate = $year . '_' . $authorSlug;
|
||||
$suffix = 1;
|
||||
while (is_dir($baseDir . $candidate)) {
|
||||
$candidate = $year . '_' . $authorSlug . '_' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video or audio file to PeerTube when the feature is enabled.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user