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

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