Add admin-only route to open Interdit thesis files from backoffice:

- reliable tab title /favicon wrapper,
- Content-Disposition filename,
- admin media route hardened to thesis-file prefixes only (defense-in-depth)
This commit is contained in:
Pontoporeia
2026-09-18 16:26:36 +02:00
parent e518163c5b
commit 6e1fc6a781
9 changed files with 404 additions and 7 deletions
+93 -6
View File
@@ -12,14 +12,40 @@
* - 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)
* - Content-Disposition filename is sanitised (no CR/LF/quotes / non-ASCII
* fallback) to prevent header injection; original uploaded name is used
* for the browser tab title.
*
* Admin bypass:
* The backoffice may open files whose owning thesis is 'Interdit' via a
* DEDICATED admin route (/admin/media.php) that is gated by
* AdminAuth::requireLogin() BEFORE this method is reached. The bypass is
* passed in as `$adminBypass = true` and only relaxes the single Interdit
* visibility gate above — path whitelist, realpath() jail and MIME checks
* are all still enforced.
*/
class MediaController
{
/** Thesis-file path prefixes (legacy + new layouts). */
private const THESIS_FILE_PREFIX = '#^(theses|documents|tfe|these|frart)/#';
/**
* Whether a requested path targets a thesis file (vs. any other storage file).
*/
public static function isThesisFilePath(string $requestedPath): bool
{
return (bool) preg_match(self::THESIS_FILE_PREFIX, $requestedPath);
}
/**
* Handle a media request. Reads $_GET['path'], validates, and streams the file.
* Sends appropriate headers and exit() — no return value.
*
* @param bool $adminBypass When true, the 'Interdit' (access_type_id=3)
* visibility gate is skipped. MUST only be set after the caller has
* confirmed an authenticated admin session (AdminAuth::requireLogin()).
*/
public function handle(): void
public function handle(bool $adminBypass = false): void
{
$requestedPath = $_GET['path'] ?? '';
@@ -51,19 +77,29 @@ class MediaController
}
// 3. Visibility gate for thesis files (legacy theses/ and documents/, new tfe/these/frart/ paths)
if (preg_match('#^(theses|documents|tfe|these|frart)/#', $requestedPath)) {
if (self::isThesisFilePath($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) {
// Interdit (access_type_id=3) is blocked for the public route
// unless the request already passed the admin auth gate.
if (!$adminBypass && $accessTypeId !== null && $accessTypeId === 3) {
http_response_code(403);
exit;
}
} catch (\Throwable $e) {
ErrorHandler::log('media_visibility', $e, ['path' => $requestedPath]);
}
} elseif ($adminBypass) {
// Defence-in-depth: the admin bypass (Interdit files) is intended for
// thesis files only. If an admin request does not target one of the
// thesis file prefixes, it can only address non-thesis storage paths
// (schema, db, tmp, backups…). Deny those outright rather than rely
// solely on the MIME allow-list to protect them.
http_response_code(403);
exit;
}
// 4. Verify MIME type
@@ -108,6 +144,25 @@ class MediaController
exit;
}
// Resolve a human-facing display name for the tab title / download name.
// Prefer the original uploaded name (thesis_files.file_name); fall back to
// the basename of the requested path. Strip anything that could break the
// Content-Disposition header (CR/LF, quotes, control chars).
$displayName = null;
if (self::isThesisFilePath($requestedPath)) {
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
try {
$mediaDb = Database::getInstance();
$displayName = $mediaDb->getFileDisplayName($requestedPath);
} catch (\Throwable $e) {
ErrorHandler::log('media_display_name', $e, ['path' => $requestedPath]);
}
}
if ($displayName === null || $displayName === '') {
$displayName = basename($requestedPath);
}
// 5. Send response headers
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . (int) filesize($realFull));
@@ -118,20 +173,22 @@ class MediaController
header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
header('Cache-Control: public, max-age=604800');
header('Content-Disposition: inline');
$this->sendInlineContentDisposition($displayName);
} elseif ($ext === 'pdf') {
header('Cache-Control: public, max-age=86400');
header('Content-Disposition: inline');
$this->sendInlineContentDisposition($displayName);
} elseif (in_array($ext, ['mp4','webm','ogv','mov'], true)) {
// Video: no cache-control range requests should work
header('Accept-Ranges: bytes');
$this->sendInlineContentDisposition($displayName);
header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
header('Accept-Ranges: bytes');
$this->sendInlineContentDisposition($displayName);
header('Cache-Control: public, max-age=86400');
} else {
// Unknown / other: serve inline, no download
header('Content-Disposition: inline');
$this->sendInlineContentDisposition($displayName);
header('Cache-Control: public, max-age=86400');
}
@@ -143,6 +200,36 @@ class MediaController
}
}
/**
* Send a Content-Disposition: inline header carrying a useful tab title.
*
* Browsers use the filename in the tab title when a binary file is shown
* inline (PDF/image/video). We provide the original uploaded name, encoded
* safely:
* - `filename` — ASCII-only fallback (RFC 2183)
* - `filename*=` — UTF-8 encoded form for accented / international
* names (RFC 6266)
*
* @param string $filename Raw display name (UTF-8).
*/
private function sendInlineContentDisposition(string $filename): void
{
// ASCII fallback: keep only printable ASCII, then drop quotes/backslashes.
$ascii = preg_replace('/[^\x20-\x7E]/', '', $filename);
if ($ascii === null) {
$ascii = '';
}
$ascii = str_replace(['"', '\\'], '', $ascii);
if ($ascii === '') {
$ascii = 'file';
}
header(
'Content-Disposition: inline; filename="' . addcslashes($ascii, '"\\') . '"'
. "; filename*=UTF-8''" . rawurlencode($filename)
);
}
/**
* Stream a file with HTTP Range support (required for HTML5 audio/video seeking).
*/
+20
View File
@@ -2087,6 +2087,26 @@ class Database
return ($val !== false) ? (int)$val : null;
}
/**
* Return the human-facing display name (thesis_files.file_name) for a file
* path, or null when the path does not belong to a stored thesis file.
*
* file_name preserves the original filename the submitter uploaded, which
* is nicer than the sanitised on-disk path when the browser uses it as a
* tab title / download name (e.g. via Content-Disposition).
*/
public function getFileDisplayName(string $filePath): ?string
{
$stmt = $this->pdo->prepare('
SELECT tf.file_name FROM thesis_files tf
WHERE tf.file_path = ?
LIMIT 1
');
$stmt->execute([$filePath]);
$val = $stmt->fetchColumn();
return ($val !== false && $val !== '') ? (string)$val : null;
}
/**
* Return total number of rows in the theses table (for system status display).
*/