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
+1
View File
@@ -35,6 +35,7 @@
- [x] #default-exemplaire-baiu Default Exemplaire BAIU + ERG to true on student (partage) submission in ThesisCreateController, replicating admin add-form default - [x] #default-exemplaire-baiu Default Exemplaire BAIU + ERG to true on student (partage) submission in ThesisCreateController, replicating admin add-form default
- [x] #m-style-links-on Style links on licences, charte, a propos pages (underline + accent hover) - [x] #m-style-links-on Style links on licences, charte, a propos pages (underline + accent hover)
- [x] #recherche-backoffice-par-identifiant Recherche backoffice par identifiant : ajouter t.identifier a la WHERE de getThesesList/getThesesListCount - [x] #recherche-backoffice-par-identifiant Recherche backoffice par identifiant : ajouter t.identifier a la WHERE de getThesesList/getThesesListCount
- [x] #admin-open-interdit-files Open 'Interdit' thesis files from backoffice: dedicated /admin/media.php route (AdminAuth session-gated) that bypasses the Interdit visibility gate in MediaController, keeping path jail + MIME checks; recap page links Interdit files to it. Also: file links open via /admin/media-viewer.php HTML wrapper so the tab shows the original file name instead of media.php; media.php sets a meaningful Content-Disposition filename too. Hardened: admin media route restricted to thesis-file prefixes only (defense-in-depth over the MIME/jail checks)
## Deferred / Blocked ## Deferred / Blocked
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal - [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
+2
View File
@@ -17,6 +17,8 @@ the XAMXAM TFE database.
| `contenus-edit.php` | Edit a content page | | `contenus-edit.php` | Edit a content page |
| `acces.php` | Share-link management | | `acces.php` | Share-link management |
| `file-access.php` | Restricted-file access requests | | `file-access.php` | Restricted-file access requests |
| `media.php` | Admin file viewer — opens files of 'Interdit' (access_type_id=3) theses; session-gated (`AdminAuth::requireLogin`), delegates to `MediaController::handle(adminBypass: true)` |
| `media-viewer.php` | HTML wrapper that opens a thesis file with a reliable tab title (original file name); embeds the file via `media.php` in a full-viewport iframe |
| `account.php` | Admin account / password | | `account.php` | Admin account / password |
| `login.php` | Login (session) | | `login.php` | Login (session) |
| `import.php` | Redirects to `/admin/` (CSV import is inline in `index.php`) | | `import.php` | Redirects to `/admin/` (CSV import is inline in `index.php`) |
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* Admin media viewer — a minimal HTML wrapper that reliably sets a useful
* browser tab title when opening thesis files (incl. 'Interdit') from the
* backoffice.
*
* Why a wrapper?
* - PDFs served directly show the URL ("media.php") as the tab title because
* most don't embed a /Title metadata and Chrome/Firefox's PDF viewer ignores
* Content-Disposition: filename for the tab. A wrapper <title> always wins.
* - The actual bytes stream through /admin/media.php (the server validated
* inlined below), preserving the existing security checks (path whitelist,
* realpath() jail, MIME allow-list, Interdit bypass gated by admin session).
*
* Security:
* - Auth-gated by AdminAuth::requireLogin().
* - Path is validated against thesis-file prefixes before it is passed on.
* - The embedded iframe is same-origin (/admin/media.php); admin CSP permits
* frame-src 'self'. noindex/nofollow and no-referrer keep it private.
*
* Usage: /admin/media-viewer.php?path=tfe/2025/2025-001/rapport.pdf
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
$path = trim((string)($_GET['path'] ?? ''));
// Only thesis-file paths are ever served; reject anything else early.
if (!preg_match('#^(theses|documents|tfe|these|frart)/[^?&]{1,255}$#', $path)) {
http_response_code(400);
echo 'Chemin invalide.';
exit;
}
$displayName = 'Document';
try {
$mediaDb = Database::getInstance();
$displayName = $mediaDb->getFileDisplayName($path) ?? basename($path);
} catch (\Throwable $e) {
ErrorHandler::log('media_viewer_display', $e, ['path' => $path]);
}
if ($displayName === '') {
$displayName = 'Document';
}
$src = '/admin/media.php?path=' . urlencode($path);
$title = htmlspecialchars($displayName);
$srcAttr = htmlspecialchars($src);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
<title><?= $title ?> – XAMXAM</title>
<style>
html, body { margin: 0; height: 100%; background: #525659; }
iframe { display: block; width: 100%; height: 100%; border: 0; background: #525659; }
</style>
</head>
<body>
<iframe src="<?= $srcAttr ?>" title="<?= $title ?>" allow="autoplay; fullscreen"></iframe>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* Dedicated admin file viewer — lets a backoffice user open files whose owning
* thesis is marked 'Interdit' (access_type_id=3).
*
* Security:
* - Auth-gated by AdminAuth::requireLogin() → only an authenticated admin
* session (cookie scoped to /admin) can reach the handler below.
* - Delegates to MediaController::handle(adminBypass: true), which bypasses
* ONLY the Interdit visibility gate. The strict path-whitelist, realpath()
* storage jail and MIME allow-list remain fully enforced, so an admin
* cannot read arbitrary files on the server.
*
* Usage: /admin/media.php?path=tfe/2025/2025-001/rapport.pdf
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/MediaController.php';
$controller = new MediaController();
$controller->handle(adminBypass: true);
+93 -6
View File
@@ -12,14 +12,40 @@
* - realpath() jail: resolved path must stay inside STORAGE_ROOT * - realpath() jail: resolved path must stay inside STORAGE_ROOT
* - MIME type verified against an allow-list before serving * - MIME type verified against an allow-list before serving
* - Access-type gate for thesis files (blocks 'Interdit' access_type_id=3) * - 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 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. * Handle a media request. Reads $_GET['path'], validates, and streams the file.
* Sends appropriate headers and exit() — no return value. * 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'] ?? ''; $requestedPath = $_GET['path'] ?? '';
@@ -51,19 +77,29 @@ class MediaController
} }
// 3. Visibility gate for thesis files (legacy theses/ and documents/, new tfe/these/frart/ paths) // 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/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php'; require_once APP_ROOT . '/src/ErrorHandler.php';
try { try {
$mediaDb = Database::getInstance(); $mediaDb = Database::getInstance();
$accessTypeId = $mediaDb->getFileVisibility($requestedPath); $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); http_response_code(403);
exit; exit;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
ErrorHandler::log('media_visibility', $e, ['path' => $requestedPath]); 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 // 4. Verify MIME type
@@ -108,6 +144,25 @@ class MediaController
exit; 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 // 5. Send response headers
header('Content-Type: ' . $mimeType); header('Content-Type: ' . $mimeType);
header('Content-Length: ' . (int) filesize($realFull)); header('Content-Length: ' . (int) filesize($realFull));
@@ -118,20 +173,22 @@ class MediaController
header('Cache-Control: public, max-age=86400'); header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) { } elseif (in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
header('Cache-Control: public, max-age=604800'); header('Cache-Control: public, max-age=604800');
header('Content-Disposition: inline'); $this->sendInlineContentDisposition($displayName);
} elseif ($ext === 'pdf') { } elseif ($ext === 'pdf') {
header('Cache-Control: public, max-age=86400'); header('Cache-Control: public, max-age=86400');
header('Content-Disposition: inline'); $this->sendInlineContentDisposition($displayName);
} elseif (in_array($ext, ['mp4','webm','ogv','mov'], true)) { } elseif (in_array($ext, ['mp4','webm','ogv','mov'], true)) {
// Video: no cache-control range requests should work // Video: no cache-control range requests should work
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
$this->sendInlineContentDisposition($displayName);
header('Cache-Control: public, max-age=86400'); header('Cache-Control: public, max-age=86400');
} elseif (in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) { } elseif (in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
$this->sendInlineContentDisposition($displayName);
header('Cache-Control: public, max-age=86400'); header('Cache-Control: public, max-age=86400');
} else { } else {
// Unknown / other: serve inline, no download // Unknown / other: serve inline, no download
header('Content-Disposition: inline'); $this->sendInlineContentDisposition($displayName);
header('Cache-Control: public, max-age=86400'); 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). * 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 ($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). * Return total number of rows in the theses table (for system status display).
*/ */
+8 -1
View File
@@ -243,7 +243,14 @@
} elseif ($isExternal) { } elseif ($isExternal) {
$mediaUrl = htmlspecialchars($filePath); $mediaUrl = htmlspecialchars($filePath);
} else { } else {
$mediaUrl = '/media?path=' . urlencode($filePath); // Interdit (access_type_id=3): the public /media route returns
// 403 for these. Open them via the dedicated admin viewer
// (/admin/media-viewer.php), served under /admin so the
// administrator's session cookie is sent and the tab title
// shows the file name.
$_isForbidden = ((int)($thesis['access_type_id'] ?? 0) === 3);
$mediaUrl = ($_isForbidden ? '/admin/media-viewer.php' : '/media')
. '?path=' . urlencode($filePath);
} }
$typeIcon = match (true) { $typeIcon = match (true) {
+37
View File
@@ -175,6 +175,43 @@ Files are never served directly from disk. All access goes through `MediaControl
- Visibility gate: `access_type_id = 3` (Interdit) → HTTP 403 - Visibility gate: `access_type_id = 3` (Interdit) → HTTP 403
- MIME allow-list check before serving - MIME allow-list check before serving
### Opening “Interdit” files from the backoffice
Publicly, an Interdit file's `/media?path=…` returns 403. The admin can still open
one via a **dedicated admin-only route**: `/admin/media.php?path=…`.
- It is gated by `AdminAuth::requireLogin()` — served under `/admin` so the
administrator's session cookie (scoped to `/admin`) is sent.
- It calls `MediaController::handle(adminBypass: true)`, which lifts **only** the
Interdit visibility gate. Path whitelist, `realpath()` jail and the MIME
allow-list are still enforced — an admin cannot read arbitrary server files.
- **Defence-in-depth:** on the admin route the requested `path` is additionally
restricted to the five thesis-file prefixes (`tfe/`, `these/`, `frart/`,
`documents/`, `theses/`) via `MediaController::isThesisFilePath()`. Any other
storage path (`schema.sql`, `xamxam.db`, `tmp/`, `backups/`, `cache/`, …) is
rejected with 403 before it even reaches the MIME check.
- The backoffice recap page (`recapitulatif.php`) already emits the admin URL
for files whose owning thesis is `access_type_id = 3`.
### Reliable tab title for opened files
The recap opens an Interdit file via `/admin/media-viewer.php?path=…`, a small
HTML wrapper that sets a proper `<title>` (the original uploaded file name) and
embeds the file through `/admin/media.php?path=…` in a full-viewport iframe.
This gives a useful, consistent tab title across every file type. (Serving the
raw PDF directly shows the URL, `media.php`, instead of the file name, because
most PDFs carry no embedded `/Title` metadata that Chrome/Firefox's viewer
would otherwise use.) The wrapper also re-declares the site's favicon
(`/assets/favicon/…`) so the tab keeps the site icon even though it's a
standalone page.
Every served file (admin route and public `/media`) sets `Content-Disposition`
to `inline` together with the original uploaded filename (`thesis_files.file_name`),
so the browser shows a meaningful tab title (e.g. `rapport_2024.pdf`) instead of
`media.php`. The name is emitted safely as an ASCII `filename` fallback plus a
UTF-8 `filename*=UTF-8''…` form for accented / international names (RFC 6266),
with control chars / quotes / backslashes stripped to prevent header injection.
--- ---
## Security notes ## Security notes
@@ -0,0 +1,145 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* MediaControllerVisibilityTest — integration tests for the file-visibility
* gate that MediaController relies on (Database::getFileVisibility).
*
* The MediaController::handle() public route blocks thesis files whose owner is
* 'Interdit' (access_type_id=3) with HTTP 403. The dedicated admin route
* (/admin/media.php) calls handle(adminBypass: true) to lift that single gate
* after AdminAuth::requireLogin() has confirmed an administrator session.
*
* These tests pin down the data contract (which access_type_id maps to which
* outcome) so a change to the gate logic can't silently break either route.
*/
class MediaControllerVisibilityTest extends TestCase
{
private Database $db;
protected function setUp(): void
{
TestDatabase::resetData();
$this->db = TestDatabase::getInstance();
require_once APP_ROOT . '/src/Controllers/MediaController.php';
}
/**
* An 'Interdit' thesis file (access_type_id=3) must be flagged as
* forbidden so the public route returns 403 (and only the admin bypass
* may open it).
*/
public function testInterditThesisFileIsFlaggedForbidden(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Interdit TFE', 'Author', 2024);
$pdo = TestDatabase::getPDO();
$pdo->prepare('UPDATE theses SET access_type_id = 3 WHERE id = ?')
->execute([$thesisId]);
$pdo->prepare(
"INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type)
VALUES (?, 'main', ?, 'rapport.pdf', 0, 'application/pdf')"
)->execute([$thesisId, 'documents/2024-001/rapport.pdf']);
$accessTypeId = $this->db->getFileVisibility('documents/2024-001/rapport.pdf');
$this->assertSame(3, $accessTypeId);
}
/**
* A 'Libre' thesis file (access_type_id=1) is served publicly by /media —
* never blocked by the gate.
*/
public function testLibreThesisFileIsNotFlaggedForbidden(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Libre TFE', 'Author', 2024);
$pdo = TestDatabase::getPDO();
$pdo->prepare('UPDATE theses SET access_type_id = 1 WHERE id = ?')
->execute([$thesisId]);
$accessTypeId = $this->db->getFileVisibility('documents/2024-001/cover.jpg');
$this->assertSame(1, $accessTypeId);
}
/**
* A path that belongs to no thesis file resolves to null — the gate is
* a no-op (file still subject to MIME + jail checks in MediaController).
*/
public function testUnrelatedPathReturnsNull(): void
{
// No files seeded at all — seed an Interdit thesis without entries.
$pdo = TestDatabase::getPDO();
$pdo->prepare(
"INSERT INTO theses (title, year, identifier, is_published, objet, access_type_id)
VALUES ('Orphan', 2024, '2024-999', 1, 'tfe', 3)"
)->execute();
$accessTypeId = $this->db->getFileVisibility('theses/2024-999/unknown.pdf');
$this->assertNull($accessTypeId);
}
// ── getFileDisplayName (tab title / download name) ────────────────────────
/**
* getFileDisplayName returns the original uploaded file_name for a known
* thesis file — used to set a useful browser tab title instead of 'media.php'.
*/
public function testFileDisplayNameFromStoredFileName(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Display Name', 'Author', 2024);
$pdo = TestDatabase::getPDO();
$pdo->prepare(
"INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type)
VALUES (?, 'main', 'tfe/2024-001/rapport.pdf', 'Note de synthese — Étude erg.pdf', 0, 'application/pdf')"
)->execute([$thesisId]);
$this->assertSame(
'Note de synthese — Étude erg.pdf',
$this->db->getFileDisplayName('tfe/2024-001/rapport.pdf')
);
}
/**
* getFileDisplayName returns null for a path that belongs to no thesis file,
* so MediaController falls back to the basename of the requested path.
*/
public function testFileDisplayNameUnknownPathReturnsNull(): void
{
$this->assertNull($this->db->getFileDisplayName('theses/2024-999/unknown.pdf'));
}
// ── isThesisFilePath (admin-bypass defense-in-depth) ──────────────────────
/**
* The admin bypass route is restricted to thesis-file prefixes only.
* All five storage layouts (tfe/ these/ frart/ documents/ theses/) count;
* any other storage path (schema, db, tmp, backups…) is rejected.
*/
public function testIsThesisFilePathRecognisesAllThesisPrefixes(): void
{
foreach (['tfe', 'these', 'frart', 'documents', 'theses'] as $prefix) {
$this->assertTrue(
MediaController::isThesisFilePath($prefix . '/2024/2024-001/rapport.pdf'),
"Expected '{$prefix}/…' to be a thesis file path"
);
}
}
public function testIsThesisFilePathRejectsNonThesisStoragePaths(): void
{
foreach ([
'schema.sql',
'xamxam.db',
'tmp/evil.php',
'backups/xamxam-2024.db',
'cache/foo.png',
] as $nonThesisPath) {
$this->assertFalse(
MediaController::isThesisFilePath($nonThesisPath),
"Expected '{$nonThesisPath}' to be rejected as a non-thesis path"
);
}
}
}