mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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`) |
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user