mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
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:
@@ -68,47 +68,128 @@ class MediaController
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($realFull);
|
||||
|
||||
$allowedMimes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
'video/mp4',
|
||||
'application/zip',
|
||||
'text/vtt', // WebVTT caption sidecar files
|
||||
];
|
||||
|
||||
// finfo may return 'text/plain' for WebVTT files on some systems;
|
||||
// re-classify by extension so we don't block them.
|
||||
if ($mimeType === 'text/plain' && strtolower(pathinfo($realFull, PATHINFO_EXTENSION)) === 'vtt') {
|
||||
$ext = strtolower(pathinfo($realFull, PATHINFO_EXTENSION));
|
||||
if ($mimeType === 'text/plain' && $ext === 'vtt') {
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
// finfo may return application/octet-stream for valid downloadable files
|
||||
// that have known extensions — allow them through.
|
||||
$knownDownloadExts = ['zip','tar','gz','tgz','mp3','ogg','oga','wav','flac','aac','m4a',
|
||||
'webm','ogv','mov','gif','webp','pdf','vtt'];
|
||||
|
||||
if (!in_array($mimeType, $allowedMimes, true)) {
|
||||
$allowedMimes = [
|
||||
// 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
|
||||
'application/zip', 'application/x-zip-compressed',
|
||||
'application/x-tar', 'application/gzip',
|
||||
// Generic binary (allowed when ext is known)
|
||||
'application/octet-stream',
|
||||
];
|
||||
|
||||
$isAllowed = in_array($mimeType, $allowedMimes, true)
|
||||
|| in_array($ext, $knownDownloadExts, true);
|
||||
|
||||
if (!$isAllowed) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5. Send response headers
|
||||
// 5. Determine if download was explicitly requested
|
||||
$forceDownload = !empty($_GET['download']) && $_GET['download'] === '1';
|
||||
|
||||
// File types that should be displayed inline by default
|
||||
$inlineExts = ['jpg','jpeg','png','gif','webp','pdf','mp4','webm','ogv','mov',
|
||||
'mp3','ogg','oga','wav','flac','aac','m4a','vtt'];
|
||||
$inline = in_array($ext, $inlineExts, true) && !$forceDownload;
|
||||
|
||||
// 6. Send response headers
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . filesize($realFull));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$ext = strtolower(pathinfo($realFull, PATHINFO_EXTENSION));
|
||||
|
||||
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true)) {
|
||||
header('Cache-Control: public, max-age=604800');
|
||||
} elseif ($ext === 'pdf') {
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('Content-Disposition: inline');
|
||||
} elseif ($ext === 'vtt') {
|
||||
if ($ext === 'vtt') {
|
||||
header('Content-Type: text/vtt; charset=utf-8');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
} elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
|
||||
header('Cache-Control: public, max-age=604800');
|
||||
if (!$forceDownload) header('Content-Disposition: inline');
|
||||
} elseif ($ext === 'pdf') {
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
header('Content-Disposition: ' . ($forceDownload ? 'attachment' : 'inline'));
|
||||
} elseif (in_array($ext, ['mp4','webm','ogv','mov'], true)) {
|
||||
// Video: no cache-control range requests should work
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
} elseif (in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=86400');
|
||||
} else {
|
||||
// Unknown / other: force download
|
||||
$safeFilename = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($realFull));
|
||||
header('Content-Disposition: attachment; filename="' . $safeFilename . '"');
|
||||
header('Cache-Control: private, no-store');
|
||||
}
|
||||
|
||||
// 6. Stream file
|
||||
readfile($realFull);
|
||||
// 7. Stream file (with range support for media)
|
||||
if (in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
|
||||
$this->streamWithRange($realFull, $mimeType);
|
||||
} else {
|
||||
readfile($realFull);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file with HTTP Range support (required for HTML5 audio/video seeking).
|
||||
*/
|
||||
private function streamWithRange(string $path, string $mimeType): void
|
||||
{
|
||||
$size = filesize($path);
|
||||
$start = 0;
|
||||
$end = $size - 1;
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
$range = $_SERVER['HTTP_RANGE'];
|
||||
if (!preg_match('/bytes=\d*-\d*/', $range)) {
|
||||
header('HTTP/1.1 416 Range Not Satisfiable');
|
||||
header('Content-Range: bytes */' . $size);
|
||||
exit;
|
||||
}
|
||||
[, $range] = explode('=', $range, 2);
|
||||
[$start, $end] = array_pad(explode('-', $range, 2), 2, '');
|
||||
$start = ($start === '') ? 0 : (int)$start;
|
||||
$end = ($end === '') ? $size - 1 : (int)$end;
|
||||
if ($end >= $size) $end = $size - 1;
|
||||
if ($start > $end) { http_response_code(416); exit; }
|
||||
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||
header('Content-Length: ' . ($end - $start + 1));
|
||||
} else {
|
||||
header('Content-Length: ' . $size);
|
||||
}
|
||||
|
||||
$fp = fopen($path, 'rb');
|
||||
if ($fp === false) { http_response_code(500); exit; }
|
||||
fseek($fp, $start);
|
||||
$remaining = $end - $start + 1;
|
||||
while ($remaining > 0 && !feof($fp)) {
|
||||
$chunk = fread($fp, min(8192, $remaining));
|
||||
if ($chunk === false) break;
|
||||
echo $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
}
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -275,6 +275,20 @@ class ThesisEditController
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reorder existing files ────────────────────────────────────────────
|
||||
if (!empty($post['file_sort_order']) && is_array($post['file_sort_order'])) {
|
||||
$this->db->reorderThesisFiles($thesisId, $post['file_sort_order']);
|
||||
}
|
||||
|
||||
// ── Update display labels for existing files ──────────────────────────
|
||||
if (!empty($post['file_label']) && is_array($post['file_label'])) {
|
||||
foreach ($post['file_label'] as $fileId => $label) {
|
||||
$fileId = (int)$fileId;
|
||||
if ($fileId <= 0) continue;
|
||||
$this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── New thesis files upload ───────────────────────────────────────────
|
||||
if (!empty($files['files']['name'][0])) {
|
||||
$this->handleThesisFiles($thesisId, $post, $files['files']);
|
||||
@@ -293,16 +307,34 @@ class ThesisEditController
|
||||
private function handleThesisFiles(int $thesisId, array $post, array $uploads): void
|
||||
{
|
||||
$allowedMimes = [
|
||||
'image/jpeg', 'image/png', 'application/pdf',
|
||||
'video/mp4', 'application/zip', 'text/vtt',
|
||||
'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', 'pdf', 'mp4', 'zip', 'vtt'];
|
||||
$maxBytes = 50 * 1024 * 1024; // 50 MB
|
||||
$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
|
||||
|
||||
$year = (int)($post['année'] ?? date('Y'));
|
||||
$authorName = trim($post['auteurice'] ?? 'unknown');
|
||||
$authorSlug = $this->generateAuthorSlug($authorName);
|
||||
|
||||
// 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;
|
||||
@@ -342,8 +374,9 @@ class ThesisEditController
|
||||
$mimeType = 'text/vtt';
|
||||
}
|
||||
|
||||
if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true)) {
|
||||
error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType), skipping");
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -370,17 +403,34 @@ class ThesisEditController
|
||||
|
||||
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}/" . $candidate;
|
||||
$this->db->insertThesisFile($thesisId, $fileType, $relPath, basename($originalName), $uploads['size'][$i], $mimeType);
|
||||
$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
|
||||
|
||||
Reference in New Issue
Block a user