Implement TFE file access restriction feature (complete)

Requirements:
- parametres.php toggle: 'restricted_files_enabled' enables/disables the feature
- Public TFE page: when enabled + access_type=Interne, hides files, shows French
  restriction message + access request form (metadata/synopsis still visible)
- ERG emails (@erg.school / @erg.be): auto-approve, send 24h access link immediately
- External emails: show justification textarea, create pending request, notify admin
- Admin panel /admin/file-access.php: approve/reject requests with optional notes,
  sends access email on approval (linked from admin nav with pending count badge)

Security:
- One-time 24h email tokens (used_at + is_valid=0 on first click)
- Token redeemed via POST /validate-access (GET shows confirmation page only)
- Long-lived 30-day browser session in file_access_sessions table
- Cookie: HttpOnly + Secure + SameSite=Strict
- CSRF on all mutations, rate limiting on request submission
- Audit trail: IP, UA, event, timestamp in file_access_audit

Bug fixes:
- admin/file-access.php: $vars never extract()ed → page was blank
- Template had self-contained head/footer includes (double-include)
- Admin approval URL used $requestId instead of $request['thesis_id']
- App::boot() now starts session so CSRF token works on public pages
- Dispatcher routes /validate-access and /request-access through front controller
This commit is contained in:
Pontoporeia
2026-04-27 20:12:43 +02:00
parent 5c776dd39e
commit 27e1b6828d
21 changed files with 6256 additions and 15 deletions

View File

@@ -22,6 +22,9 @@ class App
require_once APP_ROOT . '/src/Database.php';
self::$booted = true;
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
self::ensureCsrf();
return Database::getInstance();
}

View File

@@ -0,0 +1,137 @@
<?php
/**
* FileAccessController
*
* Handles admin management of TFE file access requests.
* - List pending/approved/rejected requests
* - Approve/reject individual requests
* - Send notification emails on approval
*/
class FileAccessController
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
public static function create(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(Database::getInstance());
}
/**
* Main admin page handler - lists access requests
*/
public function handle(): array
{
// Get filter parameter
$status = $_GET['status'] ?? 'pending';
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 20;
$offset = ($page - 1) * $perPage;
// Fetch requests based on filter
if ($status === 'approved') {
$requests = $this->getApprovedRequests($perPage, $offset);
$totalCount = $this->countApprovedRequests();
} elseif ($status === 'rejected') {
$requests = $this->getRejectedRequests($perPage, $offset);
$totalCount = $this->countRejectedRequests();
} else {
$requests = $this->db->getPendingAccessRequests($perPage, $offset);
$totalCount = $this->db->countPendingAccessRequests();
}
$totalPages = max(1, ceil($totalCount / $perPage));
return [
'requests' => $requests,
'status' => $status,
'page' => $page,
'totalPages' => $totalPages,
'totalCount' => $totalCount,
'pendingCount' => $this->db->countPendingAccessRequests(),
'approvedCount' => $this->countApprovedRequests(),
'rejectedCount' => $this->countRejectedRequests(),
'currentPage' => 'file-access',
];
}
private function getApprovedRequests(int $limit, int $offset): array
{
$sql = "
SELECT
far.id,
far.email,
far.justification,
far.created_at,
far.approved_at,
t.id as thesis_id,
t.title,
t.subtitle,
a.name as authors,
t.year
FROM file_access_requests far
JOIN theses t ON far.thesis_id = t.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
WHERE far.status = 'approved'
ORDER BY far.approved_at DESC
LIMIT ? OFFSET ?
";
$stmt = $this->db->getPDO()->prepare($sql);
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll();
}
private function countApprovedRequests(): int
{
$stmt = $this->db->getPDO()->query(
"SELECT COUNT(*) as count FROM file_access_requests WHERE status = 'approved'"
);
$result = $stmt->fetch();
return (int)$result['count'];
}
private function getRejectedRequests(int $limit, int $offset): array
{
$sql = "
SELECT
far.id,
far.email,
far.justification,
far.admin_notes,
far.created_at,
far.approved_at,
t.id as thesis_id,
t.title,
t.subtitle,
a.name as authors,
t.year
FROM file_access_requests far
JOIN theses t ON far.thesis_id = t.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
WHERE far.status = 'rejected'
ORDER BY far.approved_at DESC
LIMIT ? OFFSET ?
";
$stmt = $this->db->getPDO()->prepare($sql);
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll();
}
private function countRejectedRequests(): int
{
$stmt = $this->db->getPDO()->query(
"SELECT COUNT(*) as count FROM file_access_requests WHERE status = 'rejected'"
);
$result = $stmt->fetch();
return (int)$result['count'];
}
}

View File

@@ -72,8 +72,23 @@ class TfeController
}
// Access type (1 = open, 2 = restricted, 3 = forbidden)
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3);
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3);
// Check if restricted files feature is enabled and user has access
$restrictedEnabled = $this->db->isRestrictedFilesEnabled();
$hasRestrictedAccess = false;
if ($restrictedEnabled && $accessTypeId === 2) {
// Check for cookie-based access
$cookieToken = $_COOKIE['tfe_access_' . $thesisId] ?? null;
if ($cookieToken) {
$hasRestrictedAccess = $this->db->hasValidCookieAccess($cookieToken, $thesisId);
}
}
// If access is restricted and user doesn't have valid access, hide files
$shouldHideFiles = ($restrictedEnabled && $accessTypeId === 2 && !$hasRestrictedAccess);
// Caption (WebVTT) files — N-th VTT is paired with the N-th <video>
$captionFiles = $this->collectCaptionPaths($data['files'] ?? []);
@@ -101,6 +116,11 @@ class TfeController
'promoteursExternes' => $juryByRole['externes'],
'juryLecteurs' => $juryByRole['lecteurs'],
// Restricted files access
'restrictedEnabled' => $restrictedEnabled,
'hasRestrictedAccess' => $hasRestrictedAccess,
'shouldHideFiles' => $shouldHideFiles,
// Page meta
'pageTitle' => $pageTitle,
'metaDescription' => $metaDescription,

View File

@@ -1907,4 +1907,332 @@ class Database {
// phpcs:ignore
trigger_error('Cannot unserialize singleton ' . static::class, E_USER_ERROR);
}
}
// ========================================================================
// FILE ACCESS RESTRICTION METHODS
// ========================================================================
/**
* Check if restricted files feature is enabled.
*/
public function isRestrictedFilesEnabled(): bool {
return $this->getSetting('restricted_files_enabled', '0') === '1';
}
/**
* Create a new file access request.
*
* @param int $thesisId
* @param string $email
* @param string $justification Optional justification for non-ERG emails
* @return int New request ID
*/
public function createFileAccessRequest(int $thesisId, string $email, ?string $justification = null): int {
$stmt = $this->pdo->prepare(
"INSERT INTO file_access_requests (thesis_id, email, justification, status)
VALUES (?, ?, ?, 'pending')"
);
$stmt->execute([$thesisId, $email, $justification]);
return (int)$this->pdo->lastInsertId();
}
/**
* Generate and store an access token for a request.
*
* @param int $requestId
* @param int $expiryDays Number of days until token expires (default: 30)
* @return string The generated token
*/
/**
* Generate and store a short-lived one-time email access token.
* Default: 24 hours. Token is invalidated after first redemption.
*
* @param int $requestId
* @param int $expiryHours Hours until token expires (default: 24)
* @return string The generated token (256-bit hex)
*/
public function generateAccessToken(int $requestId, int $expiryHours = 24): string {
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + $expiryHours * 3600);
$stmt = $this->pdo->prepare(
"INSERT INTO file_access_tokens (request_id, token, expires_at)
VALUES (?, ?, ?)"
);
$stmt->execute([$requestId, $token, $expiresAt]);
return $token;
}
/**
* Validate a one-time email token and mark it as used (one-time use).
* Returns the thesis_id on success, null on failure.
* Logs the redemption attempt in file_access_audit.
*
* @param string $token
* @param string $ip Client IP address for audit log
* @param string $ua Client User-Agent for audit log
* @return int|null Thesis ID on success, null on invalid/expired/used
*/
/**
* Validate and redeem a one-time email access token.
*
* Returns ['thesis_id' => int, 'request_id' => int] on success.
* Returns null if the token is invalid, expired, or already used.
* Logs the redemption attempt in file_access_audit.
*
* @param string $token
* @param string $ip Client IP for audit log
* @param string $ua Client User-Agent for audit log
* @return array{thesis_id:int,request_id:int}|null
*/
public function redeemAccessToken(string $token, string $ip = '', string $ua = ''): ?array {
// Look up the token — only valid if unused, unexpired, and approved
$stmt = $this->pdo->prepare(
"SELECT fat.id AS token_id, fat.request_id, fr.thesis_id
FROM file_access_tokens fat
JOIN file_access_requests fr ON fat.request_id = fr.id
WHERE fat.token = ?
AND fat.is_valid = 1
AND fat.used_at IS NULL
AND fat.expires_at > CURRENT_TIMESTAMP
AND fr.status = 'approved'"
);
$stmt->execute([$token]);
$row = $stmt->fetch();
if (!$row) {
// Log failed attempt if we can find the token at all
$check = $this->pdo->prepare(
"SELECT fat.request_id FROM file_access_tokens fat WHERE fat.token = ? LIMIT 1"
);
$check->execute([$token]);
$bad = $check->fetch();
if ($bad) {
$this->logAccessAudit((int)$bad['request_id'], 'invalid_or_expired', $ip, $ua);
}
return null;
}
// Mark token as used (one-time)
$this->pdo->prepare(
"UPDATE file_access_tokens SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?"
)->execute([(int)$row['token_id']]);
// Audit log
$this->logAccessAudit((int)$row['request_id'], 'redeemed', $ip, $ua);
return [
'thesis_id' => (int)$row['thesis_id'],
'request_id' => (int)$row['request_id'],
];
}
/**
* Create a long-lived browser session token after a successful link redemption.
* Stored in file_access_sessions (separate from one-time email tokens).
*
* @param int $requestId
* @param int $expiryDays Days until session expires (default: 30)
* @return string Session token (256-bit hex)
*/
public function createAccessSession(int $requestId, int $expiryDays = 30): string {
$sessionToken = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + $expiryDays * 86400);
$this->pdo->prepare(
"INSERT INTO file_access_sessions (request_id, session_token, expires_at)
VALUES (?, ?, ?)"
)->execute([$requestId, $sessionToken, $expiresAt]);
return $sessionToken;
}
/**
* Check if a browser session cookie grants valid access to a thesis.
*
* @param string $sessionToken Value from the HttpOnly cookie
* @param int $thesisId
* @return bool True if access is granted
*/
public function hasValidCookieAccess(string $sessionToken, int $thesisId): bool {
$stmt = $this->pdo->prepare(
"SELECT COUNT(*) as count
FROM file_access_sessions fas
JOIN file_access_requests fr ON fas.request_id = fr.id
WHERE fas.session_token = ?
AND fas.is_valid = 1
AND fas.expires_at > CURRENT_TIMESTAMP
AND fr.status = 'approved'
AND fr.thesis_id = ?"
);
$stmt->execute([$sessionToken, $thesisId]);
$result = $stmt->fetch();
return $result && (int)$result['count'] > 0;
}
/**
* Write an entry to the access audit log.
*
* @param int $requestId
* @param string $event 'redeemed' | 'invalid_or_expired'
* @param string $ip
* @param string $ua
*/
public function logAccessAudit(int $requestId, string $event, string $ip, string $ua): void {
$this->pdo->prepare(
"INSERT INTO file_access_audit (request_id, event, ip, user_agent)
VALUES (?, ?, ?, ?)"
)->execute([$requestId, $event, $ip, $ua]);
}
/**
* Get pending file access requests for admin review.
*
* @param int $limit Maximum number of requests to return
* @param int $offset Pagination offset
* @return array List of pending requests with thesis info
*/
public function getPendingAccessRequests(int $limit = 50, int $offset = 0): array {
$sql = "
SELECT
far.id,
far.email,
far.justification,
far.created_at,
t.id as thesis_id,
t.title,
t.subtitle,
a.name as authors,
t.year
FROM file_access_requests far
JOIN theses t ON far.thesis_id = t.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
WHERE far.status = 'pending'
ORDER BY far.created_at DESC
LIMIT ? OFFSET ?
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll();
}
/**
* Approve a file access request and generate a token.
*
* @param int $requestId
* @param int|null $adminId Admin user ID (can be null if admin auth not tracked)
* @param int $expiryDays Token expiry in days
* @return string The generated access token
*/
/**
* Approve a file access request and generate a short-lived one-time email token.
*
* @param int $requestId
* @param int|null $adminId Admin user ID for audit trail
* @param int $expiryHours Hours until email link expires (default: 24)
* @return string The generated one-time access token
*/
public function approveAccessRequest(int $requestId, ?int $adminId = null, int $expiryHours = 24): string {
$this->pdo->beginTransaction();
try {
// Update request status
$stmt = $this->pdo->prepare(
"UPDATE file_access_requests
SET status = 'approved',
approved_at = CURRENT_TIMESTAMP,
approved_by_admin_id = ?
WHERE id = ?"
);
$stmt->execute([$adminId, $requestId]);
// Generate short-lived one-time email token
$token = $this->generateAccessToken($requestId, $expiryHours);
$this->pdo->commit();
return $token;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* Reject a file access request.
*
* @param int $requestId
* @param string $adminNotes Optional rejection notes
*/
public function rejectAccessRequest(int $requestId, ?string $adminNotes = null): void {
$stmt = $this->pdo->prepare(
"UPDATE file_access_requests
SET status = 'rejected',
approved_at = CURRENT_TIMESTAMP,
admin_notes = ?
WHERE id = ?"
);
$stmt->execute([$adminNotes, $requestId]);
}
/**
* Get access request by ID with thesis details.
*
* @param int $requestId
* @return array|null Request data or null if not found
*/
public function getAccessRequestById(int $requestId): ?array {
$stmt = $this->pdo->prepare("
SELECT
far.*,
t.id as thesis_id,
t.title,
t.subtitle,
t.year,
a.name as authors
FROM file_access_requests far
JOIN theses t ON far.thesis_id = t.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id AND ta.author_order = 1
WHERE far.id = ?
");
$stmt->execute([$requestId]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Count pending access requests.
*/
public function countPendingAccessRequests(): int {
$stmt = $this->pdo->query(
"SELECT COUNT(*) as count FROM file_access_requests WHERE status = 'pending'"
);
$result = $stmt->fetch();
return (int)$result['count'];
}
/**
* Check if an access request already exists for this email and thesis.
*
* @param int $thesisId
* @param string $email
* @return array|null Existing request or null
*/
public function getExistingAccessRequest(int $thesisId, string $email): ?array {
$stmt = $this->pdo->prepare(
"SELECT id, status, created_at
FROM file_access_requests
WHERE thesis_id = ? AND email = ?
ORDER BY created_at DESC
LIMIT 1"
);
$stmt->execute([$thesisId, $email]);
$result = $stmt->fetch();
return $result ?: null;
}
}

View File

@@ -48,6 +48,9 @@ class Dispatcher {
* execute the action, and render the view.
*/
public function dispatch(): void {
// Ensure session + CSRF token are initialised for all public requests
require_once APP_ROOT . '/src/App.php';
App::boot();
// 1. Direct-response endpoints (render their own output)
$direct = $this->matchDirect();
if ($direct) {
@@ -125,6 +128,20 @@ class Dispatcher {
};
}
// /validate-access (GET: confirmation page, POST: token redemption)
if ($path === '/validate-access' || $path === '/validate-access.php') {
return function() {
require APP_ROOT . '/public/validate-access.php';
};
}
// /request-access (POST: submit access request)
if ($path === '/request-access' || $path === '/request-access.php') {
return function() {
require APP_ROOT . '/public/request-access.php';
};
}
return null;
}