Files
xamxam/app/src/Controllers/MediaController.php
Pontoporeia 79eddf5d5a feat: fix file deletion on save + trash policy + documents/ prefix + relink browser
1. note_intention: Delete old file only when a genuinely new upload arrives
   (32-char hex file_id), not when the FilePond pool preserves an existing
   file by sending its DB integer ID.  Previously the DB integer ID
   triggered $hasNewNote=true, which deleted the existing note_intention
   from disk+DB, then handleFilePondSingleFile couldn't re-process it
   because the regex requires a hex pattern.  Same fix applied to cover.

2. All file deletions now use deleteThesisFileToTrash() which renames
   files to tmp/_trash/ instead of unlinking.  The trash preserves
   original filenames prefixed with DB id for traceability.  Skips
   website URLs and PeerTube refs (no disk file).

3. Storage prefix changed from theses/ to documents/ to reflect that
   the folder holds all document types (determined by file_type in DB).
   MediaController visibility gate supports both prefixes for backward
   compat with existing files.

4. File browser + relink feature for orphaned files:
   - /admin/fragments/file-browser.php — HTMX tree browser for
     storage/documents/ and storage/theses/
   - /admin/actions/filepond/relink.php — POST endpoint that inserts
     a thesis_files row pointing to existing on-disk file
   - Per-pool "📂 Relier" buttons (edit mode only)
   - JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
   - CSS: .relink-modal dialog + .file-browser tree styles
2026-05-19 00:08:06 +02:00

205 lines
7.6 KiB
PHP

<?php
/**
* MediaController
*
* Serves uploaded files stored outside the webroot (STORAGE_ROOT).
* This is the sole access point for thesis files, covers, and annexes — they
* are never exposed as direct filesystem paths from the web server.
*
* Security:
* - Strict character whitelist on the path parameter (no path traversal)
* - realpath() jail: resolved path must stay inside STORAGE_ROOT
* - MIME type verified against an allow-list before serving
* - Access-type gate for thesis files (blocks 'Interdit' access_type_id=3)
*/
class MediaController
{
/**
* Handle a media request. Reads $_GET['path'], validates, and streams the file.
* Sends appropriate headers and exit() — no return value.
*/
public function handle(): void
{
$requestedPath = $_GET['path'] ?? '';
// 1. Validate path characters
if (!preg_match('#^[a-zA-Z0-9/_\-.]+$#', $requestedPath) || $requestedPath === '') {
http_response_code(400);
exit;
}
// 2. Resolve path + storage jail
$storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : '/var/www/xamxam/storage';
$fullPath = $storageRoot . '/' . $requestedPath;
$realStorage = realpath($storageRoot);
$realFull = realpath($fullPath);
if (
$realFull === false
|| $realStorage === false
|| strpos($realFull, $realStorage . '/') !== 0
) {
http_response_code(404);
exit;
}
if (!is_file($realFull)) {
http_response_code(404);
exit;
}
// 3. Visibility gate for thesis files (both legacy theses/ and new documents/ paths)
if (preg_match('#^(theses|documents)/#', $requestedPath)) {
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
try {
$mediaDb = Database::getInstance();
$accessTypeId = $mediaDb->getFileVisibility($requestedPath);
if ($accessTypeId !== null && $accessTypeId === 3) {
http_response_code(403);
exit;
}
} catch (\Throwable $e) {
ErrorHandler::log('media_visibility', $e, ['path' => $path]);
}
}
// 4. Verify MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($realFull);
// finfo may return 'text/plain' for WebVTT files on some systems;
// re-classify by extension so we don't block them.
$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'];
$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. Determine if download was explicitly requested
$forceDownload = !empty($_GET['download']) && $_GET['download'] === '1';
// 6. Send response headers
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . (int) filesize($realFull));
header('X-Content-Type-Options: nosniff');
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');
}
// 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 = (int) 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);
}
}