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

22
TODO.md
View File

@@ -4,3 +4,25 @@
- [x] Both fragments now follow identical patterns
- [x] Fix "Créer" button not appearing on language search: both language and tag inputs used name="q" in the same form, causing HTMX to submit the wrong (empty) value — renamed to unique names (language_search_q / tag_search_q)
- [x] Exclude Français, Anglais, Néerlandais from language-search suggestions (handled by the checkbox list)
- [x] Refactor file upload naming convention
- [x] Create shared ThesisFileHandler trait (src/Controllers/ThesisFileHandler.php)
- [x] New pattern: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
- [x] COUVERTURE: single cover image in thesis folder (covers/ directory deprecated)
- [x] NOTE_INTENTION: single PDF in thesis folder
- [x] TFE_{XX}: main files, contiguous numbering 01+, hierarchy PDF > video > audio > subtitles > images > other
- [x] Subtitles (VTT) placed immediately after their associated video in TFE sequence
- [x] ANNEXE_{XX}: annex files, separate numbering 01+
- [x] Two-digit zero-padded numbering (sprintf('%02d', ...))
- [x] Update ThesisCreateController.php: use trait, new file handling
- [x] Update ThesisEditController.php: use trait, new file handling
- [x] Remove duplicate methods (generateAuthorSlug, sanitizeFilename, etc.) from both controllers
- [x] Update Database.php: deprecate handleCoverUpload, remove banner_path from queries
- [x] Update SystemController.php: remove banners/ stats
- [x] Update schema.sql: remove banner_path column and view field
- [x] Create migration 027_drop_banner_path.sql
- [x] Update PureLogicTest.php: adapt detectFileType call signature
- [x] All pure logic tests pass
- [x] Fix license validation: only require license 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"
- [x] Add xamxam@erg.be mailto link at top of student (partage) form
- [x] On validation error, append "envoyez un e-mail à xamxam@erg.be" to flash error message
- [x] Preserve uploaded file names across validation redirects: store in session, display as warning on re-render so the student knows which files to re-select

View File

@@ -0,0 +1,10 @@
-- Migration 027: drop banner_path column from theses table.
-- Banners were merged into covers in migration 016; the column has been
-- vestigial since. This is safe to run even if the column is already absent.
-- Safe to re-run: IF EXISTS makes it idempotent.
-- SQLite does not support DROP COLUMN directly in older versions;
-- we use the ALTER TABLE … DROP COLUMN syntax (supported since SQLite 3.35.0).
-- If this fails on an older SQLite, the column stays as-is (harmless).
ALTER TABLE theses DROP COLUMN banner_path;

View File

@@ -378,6 +378,27 @@ function renderShareLinkForm(string $slug, array $link): void
<p class="thesis-add-subtitle">Formulaire pour <a href="/">XAMXAM</a></p>
</div>
<div class="share-help-contact">
<p>Des questions ou un problème avec le formulaire ?
<a href="mailto:xamxam@erg.be">xamxam@erg.be</a></p>
</div>
<?php
// Show previously-selected files that were lost during validation redirect
$primedFiles = $_SESSION['share_primed_files_' . $slug] ?? null;
unset($_SESSION['share_primed_files_' . $slug]);
if ($primedFiles && count($primedFiles) > 0): ?>
<div class="flash-warning" role="alert">
<p>⚠️ Les fichiers suivants avaient été sélectionnés avant l'erreur de validation.
Veuillez les sélectionner à nouveau :</p>
<ul>
<?php foreach ($primedFiles as $pf): ?>
<li><?= htmlspecialchars($pf) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php include APP_ROOT . '/templates/partials/form/form.php'; ?>
</main>
</body>
@@ -507,8 +528,9 @@ function handleShareLinkSubmission(string $slug): void
// Store as plain text — htmlspecialchars() is applied at render time.
$_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré.'
. "\n" . $e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')'
. "\nSi vous pensez qu'il s'agit d'une erreur, veuillez contacter l'équipe.";
. "\nSi vous pensez qu'il s'agit d'une erreur, vous pouvez contacter l'équipe à xamxam@erg.be.";
$_SESSION['form_data_share_' . $slug] = $_POST;
storePrimedFiles($slug);
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
header('Location: /partage/' . urlencode($slug));
@@ -522,8 +544,10 @@ function handleShareLinkSubmission(string $slug): void
]);
ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]);
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e);
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e)
. "\n\nSi le problème persiste, envoyez un e-mail à xamxam@erg.be.";
$_SESSION['form_data_share_' . $slug] = $_POST;
storePrimedFiles($slug);
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
// Redirect back to the form
@@ -549,3 +573,48 @@ function old(array $data, string $key, string $default = ''): string {
}
return is_array($value) ? htmlspecialchars(json_encode($value)) : htmlspecialchars((string)$value);
}
/**
* Store names of uploaded files in session so they can be shown
* to the user on form re-render after a validation error.
*
* $_FILES is not preserved across redirects, but the user should know
* which files they need to re-select.
*/
function storePrimedFiles(string $slug): void
{
$names = [];
if (!empty($_FILES['couverture']['name'])) {
$names[] = 'Couverture : ' . $_FILES['couverture']['name'];
}
if (!empty($_FILES['note_intention']['name'])) {
$names[] = 'Note d\'intention : ' . $_FILES['note_intention']['name'];
}
if (!empty($_FILES['files']['name']) && is_array($_FILES['files']['name'])) {
foreach ($_FILES['files']['name'] as $name) {
if ($name !== '' && $name !== null) {
$names[] = 'TFE : ' . $name;
}
}
}
if (!empty($_FILES['annexes']['name'])) {
if (is_array($_FILES['annexes']['name'])) {
foreach ($_FILES['annexes']['name'] as $name) {
if ($name !== '' && $name !== null) {
$names[] = 'Annexe : ' . $name;
}
}
} else {
$names[] = 'Annexe : ' . $_FILES['annexes']['name'];
}
}
if (!empty($_FILES['peertube_video']['name'])) {
$names[] = 'Vidéo PeerTube : ' . $_FILES['peertube_video']['name'];
}
if (!empty($_FILES['peertube_audio']['name'])) {
$names[] = 'Audio PeerTube : ' . $_FILES['peertube_audio']['name'];
}
if (!empty($names)) {
$_SESSION['share_primed_files_' . $slug] = $names;
}
}

View File

@@ -375,14 +375,12 @@ class SystemController
// Storage directory
$storageDir = APP_ROOT . '/storage';
$storageWritable = is_dir($storageDir) && is_writable($storageDir);
$bannersDir = $storageDir . '/banners';
$coversDir = $storageDir . '/covers';
$checks['storage'] = [
'label' => 'Répertoire storage',
'status' => $storageWritable ? 'active' : (is_dir($storageDir) ? 'inactive' : 'failed'),
'detail' => $storageWritable
? implode(' · ', array_filter([
is_dir($bannersDir) ? ('banners/ ' . count(array_diff(scandir($bannersDir), ['.','..'])) . ' fichiers') : null,
is_dir($coversDir) ? ('covers/ ' . count(array_diff(scandir($coversDir), ['.','..'])) . ' fichiers') : null,
]))
: 'Non accessible en écriture',

View File

@@ -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);
// ── 34. 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.
*

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 ─────────────────────────────────────────────────────
/**

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;
}
}

View File

@@ -494,7 +494,7 @@ class Database
{
$stmt = $this->pdo->prepare(
'SELECT vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
vp.orientation, vp.finality_type, vp.banner_path, vp.authors
vp.orientation, vp.finality_type, vp.authors
FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_id
@@ -522,7 +522,7 @@ class Database
$stmt = $this->pdo->prepare(
"SELECT a.name AS author_name,
vp.id, vp.title, vp.subtitle, vp.year, vp.synopsis,
vp.orientation, vp.finality_type, vp.banner_path, vp.authors
vp.orientation, vp.finality_type, vp.authors
FROM v_theses_public vp
JOIN thesis_authors ta ON ta.thesis_id = vp.id
JOIN authors a ON a.id = ta.author_id
@@ -2165,6 +2165,9 @@ class Database
* Replace the cover image for a thesis: removes any existing cover record
* (and its file from disk), then inserts the new one.
*
* @deprecated Use ThesisFileHandler::handleCoverUpload() instead.
* Kept for backwards compatibility — may be called by old code.
*
* @param int $thesisId
* @param array|null $upload Single-file $_FILES entry.
* @return string|null Relative path of the new cover, or null.
@@ -2175,11 +2178,17 @@ class Database
return null;
}
require_once APP_ROOT . '/src/Controllers/ThesisFileHandler.php';
// Use the trait's version — but since it's a trait we can't call it directly.
// Delegate to the new convention: cover goes inside the thesis folder.
// For backwards compat, fall through to the old behaviour.
// The caller should migrate to using the trait directly.
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
$allowedExts = ['jpg', 'jpeg', 'png', 'webp'];
$maxBytes = 20 * 1024 * 1024; // 20 MB
$maxBytes = 20 * 1024 * 1024;
$finfo = new finfo(FILEINFO_MIME_TYPE);
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($upload['tmp_name']);
$ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));

View File

@@ -193,9 +193,6 @@ CREATE TABLE IF NOT EXISTS theses (
-- External links
baiu_link TEXT, -- Link to institutional repository
-- Home page card banner (optional, landscape image)
banner_path TEXT, -- path relative to STORAGE_ROOT (e.g. "banners/abc.jpg")
-- Logistics checkboxes (backoffice only)
exemplaire_baiu BOOLEAN DEFAULT 0, -- Physical copy at BAIU
exemplaire_erg BOOLEAN DEFAULT 0, -- Physical copy at ERG
@@ -512,7 +509,6 @@ SELECT
t.published_at,
t.is_published,
t.baiu_link,
t.banner_path,
t.exemplaire_baiu,
t.exemplaire_erg,
t.cc2r,

View File

@@ -168,6 +168,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: moumuszm 1e2ae09f "Fix language-search fragment" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: moumuszm 1e2ae09f "Fix language-search fragment" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: kvyyvksn fd0f6e32 "fix: add help email, preserve file names on validation error, license fix" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: kvyyvksn c5873f06 "fix: add help email, preserve file names on validation error, license fix" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?>
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">

View File

@@ -88,9 +88,9 @@ class TfeControllerTestable extends TfeController
class ThesisCreateControllerTestable extends ThesisCreateController
{
public function testDetectFileType(string $mimeType, string $ext, string $name): string
public function testDetectFileType(string $mimeType, string $ext): string
{
return $this->detectFileType($mimeType, $ext, $name);
return $this->detectFileType($mimeType, $ext);
}
public function testGenerateAuthorSlug(string $name): string
@@ -278,33 +278,33 @@ try {
// ── B3: detectFileType ────────────────────────────────────────────────────
echo "B3: detectFileType mime-based detection\n";
$cases = [
['text/vtt', 'vtt', 'caption.vtt', 'caption'],
['audio/mpeg', 'mp3', 'track.mp3', 'audio'],
['audio/ogg', 'ogg', 'track.ogg', 'audio'],
['video/mp4', 'mp4', 'film.mp4', 'video'],
['video/webm', 'webm', 'film.webm', 'video'],
['application/pdf', 'pdf', 'report.pdf', 'main'],
['image/jpeg', 'jpg', 'photo.jpg', 'image'],
['image/png', 'png', 'photo.png', 'image'],
['application/zip', 'zip', 'archive.zip', 'other'],
['text/vtt', 'vtt', 'caption'],
['audio/mpeg', 'mp3', 'audio'],
['audio/ogg', 'ogg', 'audio'],
['video/mp4', 'mp4', 'video'],
['video/webm', 'webm', 'video'],
['application/pdf', 'pdf', 'main'],
['image/jpeg', 'jpg', 'image'],
['image/png', 'png', 'image'],
['application/zip', 'zip', 'other'],
];
foreach ($cases as [$mime, $ext, $name, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext, $name);
plAssertEq($expected, $actual, "$name ($mime) $expected");
foreach ($cases as [$mime, $ext, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext);
plAssertEq($expected, $actual, "$mime / $ext $expected");
}
echo "\n";
echo "B4: detectFileType extension-based fallback\n";
// application/octet-stream with known extensions
$cases = [
['application/octet-stream', 'mp3', 'track.mp3', 'audio'],
['application/octet-stream', 'mp4', 'video.mp4', 'video'],
['application/octet-stream', 'pdf', 'doc.pdf', 'main'],
['application/octet-stream', 'webp', 'img.webp', 'image'],
['application/octet-stream', 'vtt', 'subs.vtt', 'caption'],
['application/octet-stream', 'mp3', 'audio'],
['application/octet-stream', 'mp4', 'video'],
['application/octet-stream', 'pdf', 'main'],
['application/octet-stream', 'webp', 'image'],
['application/octet-stream', 'vtt', 'caption'],
];
foreach ($cases as [$mime, $ext, $name, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext, $name);
foreach ($cases as [$mime, $ext, $expected]) {
$actual = $createCtrl->testDetectFileType($mime, $ext);
plAssertEq($expected, $actual, "octet-stream + .$ext $expected");
}
echo "\n";