diff --git a/TODO.md b/TODO.md index 23cb845..5721831 100644 --- a/TODO.md +++ b/TODO.md @@ -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] #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] #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 - [ ] #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 diff --git a/app/public/admin/README.md b/app/public/admin/README.md index 224738f..628f681 100644 --- a/app/public/admin/README.md +++ b/app/public/admin/README.md @@ -17,6 +17,8 @@ the XAMXAM TFE database. | `contenus-edit.php` | Edit a content page | | `acces.php` | Share-link management | | `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 | | `login.php` | Login (session) | | `import.php` | Redirects to `/admin/` (CSV import is inline in `index.php`) | diff --git a/app/public/admin/media-viewer.php b/app/public/admin/media-viewer.php new file mode 100644 index 0000000..bcd9944 --- /dev/null +++ b/app/public/admin/media-viewer.php @@ -0,0 +1,74 @@ + 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); +?> + + + + + + + + + + +<?= $title ?> – XAMXAM + + + + + + diff --git a/app/public/admin/media.php b/app/public/admin/media.php new file mode 100644 index 0000000..ed962a2 --- /dev/null +++ b/app/public/admin/media.php @@ -0,0 +1,24 @@ +handle(adminBypass: true); diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php index e6638b6..5a60151 100644 --- a/app/src/Controllers/MediaController.php +++ b/app/src/Controllers/MediaController.php @@ -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). */ diff --git a/app/src/Database.php b/app/src/Database.php index 48465c6..a33c14d 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -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). */ diff --git a/app/templates/admin/recapitulatif.php b/app/templates/admin/recapitulatif.php index 516df01..ee10a6d 100644 --- a/app/templates/admin/recapitulatif.php +++ b/app/templates/admin/recapitulatif.php @@ -243,7 +243,14 @@ } elseif ($isExternal) { $mediaUrl = htmlspecialchars($filePath); } 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) { diff --git a/docs/file-uploads.md b/docs/file-uploads.md index 946d7bb..4625f35 100644 --- a/docs/file-uploads.md +++ b/docs/file-uploads.md @@ -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 - 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 `` (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 diff --git a/tests/phpunit/MediaControllerVisibilityTest.php b/tests/phpunit/MediaControllerVisibilityTest.php new file mode 100644 index 0000000..960efe6 --- /dev/null +++ b/tests/phpunit/MediaControllerVisibilityTest.php @@ -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" + ); + } + } +}