feat: multi-type file upload with sort order, labels, and expanded MIME support

- DB migration 007: add sort_order + display_label to thesis_files
- Database: getThesisFiles ordered by sort_order; insertThesisFile accepts label/order;
  new reorderThesisFiles() and updateThesisFileLabel() methods
- ThesisCreateController + ThesisEditController: expand allowed MIME/exts to include
  audio (mp3/ogg/wav/flac/aac/m4a), video (webm/mov/ogv), image (gif/webp),
  archives (tar/gz), any-ext via octet-stream; max size raised to 500 MB;
  accept file_labels[] and file_orders[] POST fields; detectFileType() helper
- MediaController: expanded MIME allowlist; HTTP Range support for audio/video;
  force-download for unknown types; inline for known displayable types
- fieldset-files.php: sortable queue UI with SortableJS, per-file labels, 500 MB hint
- templates/admin/edit.php: existing files as sortable list with drag handles,
  type icons, label inputs, delete checkboxes, hidden sort-order fields
- file-upload-queue.js: new JS replacing file-preview.js — sortable new-file queue,
  per-file labels, hidden order fields on submit, backward-compat legacy preview
- tfe.php: renders audio (<audio>), all video formats, images, PDF, and
  download-only 'other' files; reads display_label; sorted by sort_order
- tfe.css + form.css: styles for audio player, download files, sortable queue,
  drag handles, file type badges, label inputs
- .htaccess + .user.ini: upload_max_filesize=512M / post_max_size=520M
This commit is contained in:
Pontoporeia
2026-04-30 13:07:09 +02:00
parent 2188ff5479
commit a83dc1c74e
17 changed files with 1026 additions and 274 deletions

View File

@@ -20,16 +20,42 @@
class ThesisCreateController
{
/** Maximum allowed file size for thesis files (bytes). */
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
/** MIME types accepted for thesis files. */
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'application/pdf',
'video/mp4', 'application/zip', 'text/vtt',
// 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 = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
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',
];
private Database $db;
@@ -159,7 +185,7 @@ class ThesisCreateController
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null);
$this->db->handleBannerUpload($thesisId, $files['banner'] ?? null);
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $data['auteurName']);
$this->handleThesisFiles($thesisId, $data['annee'], $identifier, $files['files'] ?? null, $data['auteurName'], $post);
return $thesisId;
}
@@ -370,7 +396,7 @@ class ThesisCreateController
* @param array|null $uploads Multi-file $_FILES entry (may be null).
* @param string $authorName Author name for folder and file naming.
*/
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorName): void
private function handleThesisFiles(int $thesisId, int $year, string $identifier, ?array $uploads, string $authorName, array $post = []): void
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
@@ -385,6 +411,10 @@ class ThesisCreateController
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) {
@@ -404,10 +434,15 @@ class ThesisCreateController
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), skipping");
&& !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
error_log("ThesisCreateController: invalid file type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
continue;
}
@@ -437,14 +472,10 @@ class ThesisCreateController
chmod($targetPath, 0644);
$fileType = 'other';
if ($ext === 'vtt') {
$fileType = 'caption';
} elseif (stripos($originalName, 'annex') !== false) {
$fileType = 'annex';
} elseif ($ext === 'pdf') {
$fileType = 'main';
}
$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(
@@ -453,12 +484,27 @@ class ThesisCreateController
$relPath,
basename($originalName),
$uploads['size'][$i],
$mimeType
$mimeType,
$label !== '' ? $label : null,
$sortOrder
);
error_log("ThesisCreateController: file uploaded → $targetName ($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: input helpers ────────────────────────────────────────────────
/**