mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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
205 lines
7.6 KiB
PHP
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);
|
|
}
|
|
}
|