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

@@ -0,0 +1,71 @@
-- ============================================================================
-- FILE ACCESS RESTRICTION SYSTEM
-- ============================================================================
-- Add support for restricting attached files on TFEs with email-based access
-- requests and cookie-based validation.
-- ============================================================================
-- Add new site setting for enabling/disabling file access restriction
INSERT OR IGNORE INTO site_settings (key, value) VALUES
('restricted_files_enabled', '0');
-- ============================================================================
-- FILE ACCESS REQUESTS TABLE
-- ============================================================================
-- Stores requests from users wanting access to restricted TFE files.
-- Supports approval workflow: pending → approved/rejected
-- ============================================================================
CREATE TABLE IF NOT EXISTS file_access_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thesis_id INTEGER NOT NULL,
email TEXT NOT NULL,
justification TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'approved', 'rejected')),
admin_notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_at DATETIME,
approved_by_admin_id INTEGER,
FOREIGN KEY (thesis_id) REFERENCES theses(id) ON DELETE CASCADE
);
-- Index for efficient lookup by thesis and email
CREATE INDEX IF NOT EXISTS idx_file_access_requests_thesis_id
ON file_access_requests(thesis_id);
CREATE INDEX IF NOT EXISTS idx_file_access_requests_email
ON file_access_requests(email);
CREATE INDEX IF NOT EXISTS idx_file_access_requests_status
ON file_access_requests(status);
-- ============================================================================
-- FILE ACCESS TOKENS TABLE
-- ============================================================================
-- Stores tokens for cookie-based access validation.
-- Each token is unique, time-limited, and linked to a specific request.
-- ============================================================================
CREATE TABLE IF NOT EXISTS file_access_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_valid INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (request_id) REFERENCES file_access_requests(id) ON DELETE CASCADE
);
-- Index for token lookup (most common query)
CREATE INDEX IF NOT EXISTS idx_file_access_tokens_token
ON file_access_tokens(token);
-- Index for cleanup of expired tokens
CREATE INDEX IF NOT EXISTS idx_file_access_tokens_expires_at
ON file_access_tokens(expires_at);
-- ============================================================================
-- SAMPLE DATA FOR TESTING (optional, remove in production if not needed)
-- ============================================================================
-- No sample data needed - system starts with restriction disabled by default.

View File

@@ -0,0 +1,50 @@
-- ============================================================================
-- SECURE ACCESS TOKEN IMPROVEMENTS
-- ============================================================================
-- 1. Mark email tokens as one-time-use (used_at timestamp)
-- 2. Add cookie sessions table (separate, long-lived, revocable)
-- 3. Add audit log table for token redemptions
-- ============================================================================
-- Track when a one-time email link was redeemed
ALTER TABLE file_access_tokens ADD COLUMN used_at DATETIME DEFAULT NULL;
-- ============================================================================
-- COOKIE SESSIONS TABLE
-- ============================================================================
-- Stores long-lived browser sessions granted after token redemption.
-- Distinct from email tokens: email token is one-time, session is long-lived.
-- ============================================================================
CREATE TABLE IF NOT EXISTS file_access_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
session_token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_valid INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (request_id) REFERENCES file_access_requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_file_access_sessions_token
ON file_access_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_file_access_sessions_expires
ON file_access_sessions(expires_at);
-- ============================================================================
-- TOKEN REDEMPTION AUDIT LOG
-- ============================================================================
CREATE TABLE IF NOT EXISTS file_access_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
event TEXT NOT NULL, -- 'redeemed', 'expired', 'invalid'
ip TEXT,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (request_id) REFERENCES file_access_requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_file_access_audit_request
ON file_access_audit(request_id);

View File

@@ -0,0 +1,124 @@
<?php
/**
* Access Request Action Handler
*
* Approve or reject file access requests.
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Token de sécurité invalide']);
exit;
}
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
$db = Database::getInstance();
$requestId = isset($_POST['request_id']) ? (int)$_POST['request_id'] : 0;
$action = $_POST['action'] ?? '';
$notes = $_POST['admin_notes'] ?? null;
if ($requestId <= 0 || !in_array($action, ['approve', 'reject'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Données invalides']);
exit;
}
try {
$request = $db->getAccessRequestById($requestId);
if (!$request) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Demande non trouvée']);
exit;
}
if ($action === 'approve') {
// Generate token
$token = $db->approveAccessRequest($requestId);
// Send access email to user
$thesisTitle = $request['title'];
$thesisAuthors = $request['authors'] ?? '';
$accessUrl = "https://{$_SERVER['HTTP_HOST']}/validate-access?token={$token}&thesis={$request['thesis_id']}";
$subject = "Accès accordé - TFE: {$thesisTitle}";
$body = buildApprovalEmail($thesisTitle, $thesisAuthors, $accessUrl, $notes);
$plain = strip_tags($body);
SmtpRelay::send($db, $request['email'], $subject, $body, $plain);
App::flash('success', "Demande approuvée. Email envoyé à {$request['email']}.");
} elseif ($action === 'reject') {
$db->rejectAccessRequest($requestId, $notes);
// Optionally send rejection email (not implemented for now)
App::flash('success', "Demande rejetée.");
}
header('Location: /admin/file-access.php');
exit;
} catch (Exception $e) {
error_log('Access request action failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement']);
}
/**
* Build approval notification email HTML
*/
function buildApprovalEmail(string $title, string $authors, string $accessUrl, ?string $adminNotes): string {
$notesHtml = '';
if (!empty($adminNotes)) {
$notesHtml = "<p><strong>Note de l'administrateur :</strong><br>" . htmlspecialchars($adminNotes) . "</p>";
}
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c5282;">Accès accordé au TFE</h2>
<p>Bonjour,</p>
<p>Votre demande d'accès au TFE suivant a été approuvée par un administrateur :</p>
<div style="background: #f7fafc; padding: 15px; border-left: 4px solid #2c5282; margin: 20px 0;">
<strong>Titre :</strong> {$title}<br>
<strong>Auteur(s) :</strong> {$authors}
</div>
{$notesHtml}
<p>Pour accéder aux fichiers attachés, veuillez cliquer sur le lien ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{$accessUrl}"
style="display: inline-block; padding: 12px 30px; background-color: #2c5282;
color: white; text-decoration: none; border-radius: 5px; font-weight: bold;">
Accéder au TFE
</a>
</div>
<p><strong>Ce lien est valable 24 heures et à usage unique.</strong> L'accès sur votre appareil sera ensuite conservé 30 jours.</p>
<p style="margin-top: 30px; color: #666; font-size: 0.9em;">
Cordialement,<br>
L'équipe XAMXAM - ERG
</p>
</div>
</body>
</html>
HTML;
}

View File

@@ -17,7 +17,12 @@ $db = new Database();
$section = $_POST['section'] ?? '';
if ($section === 'formulaire') {
$allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled'];
$allowed = [
'access_type_libre_enabled',
'access_type_interne_enabled',
'access_type_interdit_enabled',
'restricted_files_enabled'
];
foreach ($allowed as $key) {
$value = isset($_POST[$key]) ? '1' : '0';
$db->setSetting($key, $value);

View File

@@ -0,0 +1,20 @@
<?php
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Controllers/FileAccessController.php';
$controller = FileAccessController::create();
$vars = $controller->handle();
extract($vars);
$pageTitle = 'Demandes d\'accès aux fichiers';
$isAdmin = true;
$bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
echo '<link rel="stylesheet" href="/assets/css/file-access.css">';
include APP_ROOT . '/templates/admin/file-access.php';
require_once APP_ROOT . '/templates/admin/footer.php';

View File

@@ -0,0 +1,282 @@
/* ============================================================
FILE ACCESS REQUESTS — ADMIN PAGE
============================================================ */
@import url("./variables.css");
.access-req-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-m);
margin-bottom: var(--space-l);
}
.access-req-stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-m);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.access-req-stat-number {
font-size: var(--step-2);
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.access-req-stat-label {
font-size: var(--step--1);
color: var(--text-secondary);
margin-top: var(--space-3xs);
}
/* Tab navigation */
.access-req-tabs {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-l);
border-bottom: 2px solid var(--border);
}
.access-req-tab {
padding: var(--space-2xs) var(--space-s);
color: var(--text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s, border-color 0.2s;
}
.access-req-tab:hover {
color: var(--text-primary);
}
.access-req-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
/* Empty state */
.access-req-empty {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-xl);
text-align: center;
color: var(--text-secondary);
}
/* Request list */
.access-req-list {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.access-req-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-m);
transition: box-shadow 0.2s;
}
.access-req-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.access-req-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-m);
margin-bottom: var(--space-s);
}
.access-req-card__thesis h3 {
font-size: var(--step-1);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-3xs) 0;
line-height: 1.3;
}
.access-req-card__authors {
font-size: var(--step--1);
color: var(--text-secondary);
margin: 0;
}
.access-req-card__meta {
flex-shrink: 0;
}
.access-req-badge {
display: inline-block;
padding: var(--space-3xs) var(--space-2xs);
border-radius: 12px;
font-size: var(--step--2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.access-req-badge--pending {
background: #fef3c7;
color: #92400e;
}
.access-req-badge--approved {
background: #d1fae5;
color: #065f46;
}
.access-req-badge--rejected {
background: #fee2e2;
color: #991b1b;
}
.access-req-card__body {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.access-req-card__info {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
font-size: var(--step--1);
color: var(--text-secondary);
}
.access-req-card__info strong {
color: var(--text-primary);
margin-right: var(--space-3xs);
}
.access-req-card__info a {
color: var(--accent);
text-decoration: none;
}
.access-req-card__info a:hover {
text-decoration: underline;
}
.access-req-card__justification,
.access-req-card__admin-notes {
background: var(--background);
padding: var(--space-s);
border-radius: 4px;
font-size: var(--step--1);
line-height: 1.6;
}
.access-req-card__justification strong,
.access-req-card__admin-notes strong {
display: block;
margin-bottom: var(--space-3xs);
color: var(--text-primary);
}
.access-req-card__justification p,
.access-req-card__admin-notes p {
margin: 0;
}
.access-req-card__actions {
display: flex;
gap: var(--space-s);
margin-top: var(--space-s);
}
.access-req-btn {
font-family: inherit;
font-size: var(--step--1);
font-weight: 600;
padding: var(--space-2xs) var(--space-m);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.access-req-btn--approve {
background: var(--accent);
color: white;
}
.access-req-btn--approve:hover {
background: var(--accent-dark);
}
.access-req-btn--reject {
background: #e53e3e;
color: white;
}
.access-req-btn--reject:hover {
background: #c53030;
}
/* Pagination */
.access-req-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-m);
margin-top: var(--space-l);
padding: var(--space-s) 0;
}
.access-req-pagination__link {
padding: var(--space-2xs) var(--space-s);
color: var(--accent);
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.access-req-pagination__link:hover {
color: var(--accent-dark);
text-decoration: underline;
}
.access-req-pagination__info {
font-size: var(--step--1);
color: var(--text-secondary);
}
/* Dialog enhancements */
.admin-dialog textarea {
width: 100%;
font-family: inherit;
font-size: var(--step--1);
padding: var(--space-2xs);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--text-primary);
resize: vertical;
margin: var(--space-s) 0;
}
.admin-dialog label {
display: block;
font-weight: 600;
margin-bottom: var(--space-3xs);
}
.admin-btn--danger {
background: #e53e3e !important;
}
.admin-btn--danger:hover {
background: #c53030 !important;
}

View File

@@ -167,6 +167,116 @@ aside figcaption {
margin: 0;
}
/* ============================================================
RESTRICTED ACCESS UI
============================================================ */
.tfe-restricted-access {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: var(--space-m);
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.tfe-restricted-message {
font-size: var(--step--1);
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.tfe-restricted-message strong {
color: var(--text-primary);
display: block;
margin-bottom: var(--space-3xs);
font-size: var(--step-0);
}
.tfe-access-request-form {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.tfe-access-request-form .form-group {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.tfe-access-request-form label {
font-size: var(--step--1);
font-weight: 600;
color: var(--text-primary);
}
.tfe-access-request-form input[type="email"],
.tfe-access-request-form textarea {
font-family: inherit;
font-size: var(--step--1);
padding: var(--space-2xs) var(--space-3xs);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--text-primary);
transition: border-color 0.2s;
}
.tfe-access-request-form input[type="email"]:focus,
.tfe-access-request-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.tfe-access-request-form textarea {
resize: vertical;
min-height: 80px;
}
.tfe-btn-request-access {
font-family: inherit;
font-size: var(--step--1);
font-weight: 600;
padding: var(--space-2xs) var(--space-s);
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
margin-top: var(--space-3xs);
}
.tfe-btn-request-access:hover:not(:disabled) {
background: var(--accent-dark);
}
.tfe-btn-request-access:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tfe-access-message {
font-size: var(--step--1);
padding: var(--space-2xs);
border-radius: 4px;
margin-top: var(--space-3xs);
}
.tfe-access-success {
background: #f0fff4;
border: 1px solid #48bb78;
color: #22543d;
}
.tfe-access-error {
background: #fff5f5;
border: 1px solid #fc8181;
color: #742a2a;
}
/* Responsive */
@media (max-width: 900px) {
.tfe-layout {

View File

@@ -0,0 +1,328 @@
<?php
/**
* Request Access Endpoint
*
* Handles file access requests for restricted TFEs.
* - Validates CSRF token
* - Rate limiting (per IP)
* - Auto-approves @erg.school and @erg.be emails (sends 24h one-time link)
* - Creates pending requests for external emails (admin approval)
* - Sends notification emails via SMTP
*
* Security:
* - CSRF required (POST only)
* - Rate limit: 3 requests per 10 min per IP
* - Token sent in email body only (never in URL query as a direct action)
* - Token is short-lived (24h) and one-time use
* - Redemption link goes to GET /validate-access (confirmation page) then POST to redeem
*/
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/RateLimit.php';
require_once APP_ROOT . '/src/SmtpRelay.php';
header('Content-Type: application/json');
// Only accept POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Méthode non autorisée']);
exit;
}
// Boot session for CSRF
App::boot();
// Validate CSRF token
if (
empty($_POST['csrf_token'])
|| empty($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Token de sécurité invalide']);
exit;
}
// Validate input
$thesisId = isset($_POST['thesis_id']) ? (int)$_POST['thesis_id'] : 0;
$email = isset($_POST['email']) ? trim($_POST['email']) : '';
$justification = isset($_POST['justification']) ? trim($_POST['justification']) : '';
if ($thesisId <= 0 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Données invalides']);
exit;
}
// Truncate justification to a safe length
if (mb_strlen($justification) > 2000) {
$justification = mb_substr($justification, 0, 2000);
}
$db = Database::getInstance();
// Check if thesis exists and is published
$thesis = $db->getThesis($thesisId);
if (!$thesis || !$thesis['is_published']) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'TFE non trouvé']);
exit;
}
// Check if restricted files feature is enabled
if (!$db->isRestrictedFilesEnabled()) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Cette fonctionnalité est désactivée']);
exit;
}
// Check if thesis actually has restricted access (access_type_id = 2)
$accessTypeId = $db->getThesisAccessTypeId($thesisId);
if ($accessTypeId !== 2) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Ce TFE ne nécessite pas d\'accès restreint']);
exit;
}
// Rate limiting: max 3 requests per 10 minutes per IP
$rateLimitKey = 'access_request_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!RateLimit::check($rateLimitKey, 3, 600)) {
http_response_code(429);
echo json_encode(['success' => false, 'message' => 'Trop de requêtes. Veuillez réessayer dans quelques minutes.']);
exit;
}
// Check for existing request
$existingRequest = $db->getExistingAccessRequest($thesisId, $email);
if ($existingRequest) {
if ($existingRequest['status'] === 'approved') {
// Re-generate and re-send a fresh 24h token
try {
$newToken = $db->generateAccessToken((int)$existingRequest['id'], 24);
$host = $_SERVER['HTTP_HOST'] ?? 'xamxam.erg.be';
$accessUrl = "https://{$host}/validate-access?token={$newToken}&thesis={$thesisId}";
$subject = "Accès (renvoi) - TFE : " . $thesis['title'];
$body = buildAutoApprovalEmail($thesis['title'], $thesis['authors'] ?? '', $accessUrl);
$plain = htmlToPlain($body);
SmtpRelay::send($db, $email, $subject, $body, $plain);
echo json_encode([
'success' => true,
'message' => 'Un nouvel email d\'accès vous a été envoyé.',
'status' => 'resent',
]);
} catch (Exception $e) {
error_log('Access request resend failed: ' . $e->getMessage());
echo json_encode(['success' => true, 'message' => 'Votre accès est déjà approuvé. Si vous n\'avez pas reçu l\'email, contactez l\'administrateur.']);
}
exit;
}
if ($existingRequest['status'] === 'pending') {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Une demande est déjà en cours de traitement pour cet email.',
'status' => 'already_pending',
]);
exit;
}
}
// Determine if auto-approved (ERG domain)
$emailLower = strtolower($email);
$isErgEmail = (
str_ends_with($emailLower, '@erg.school') ||
str_ends_with($emailLower, '@erg.be')
);
$host = $_SERVER['HTTP_HOST'] ?? 'xamxam.erg.be';
try {
if ($isErgEmail) {
// Auto-approve: create request + short-lived 24h token
$requestId = $db->createFileAccessRequest($thesisId, $email, null);
$token = $db->approveAccessRequest($requestId, null, 24);
$accessUrl = "https://{$host}/validate-access?token={$token}&thesis={$thesisId}";
$subject = "Accès accordé - TFE : " . $thesis['title'];
$body = buildAutoApprovalEmail($thesis['title'], $thesis['authors'] ?? '', $accessUrl);
$plain = htmlToPlain($body);
SmtpRelay::send($db, $email, $subject, $body, $plain);
http_response_code(200);
echo json_encode([
'success' => true,
'message' => 'Un email avec le lien d\'accès vous a été envoyé. Le lien est valable 24 heures.',
'status' => 'auto_approved',
]);
} else {
// External email: justification required, create pending request
if (empty($justification)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Une justification est requise pour les emails externes à l\'ERG.',
'status' => 'justification_required',
]);
exit;
}
$requestId = $db->createFileAccessRequest($thesisId, $email, $justification);
// Notify admins
$thesisYear = $thesis['year'] ?? '';
$subject = "Nouvelle demande d'accès - TFE : " . $thesis['title'];
$body = buildAdminNotificationEmail(
$thesis['title'],
$thesis['authors'] ?? '',
$thesisYear,
$email,
$justification,
$host
);
$plain = htmlToPlain($body);
$settings = SmtpRelay::getSettings($db);
if (!empty($settings['from_email'])) {
SmtpRelay::send($db, $settings['from_email'], $subject, $body, $plain);
}
http_response_code(200);
echo json_encode([
'success' => true,
'message' => 'Votre demande a été envoyée et sera examinée par un administrateur.',
'status' => 'pending',
]);
}
} catch (Exception $e) {
error_log('Access request failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur lors du traitement de la demande']);
}
// ─────────────────────────────────────────────────────────────────────────────
// Email template helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Build the auto-approval email sent to ERG-domain users.
* The link leads to a confirmation page (GET) that then requires a POST to redeem.
*/
function buildAutoApprovalEmail(string $title, string $authors, string $accessUrl): string
{
$safeTitle = htmlspecialchars($title, ENT_QUOTES);
$safeAuthors = htmlspecialchars($authors, ENT_QUOTES);
$safeUrl = htmlspecialchars($accessUrl, ENT_QUOTES);
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,Arial,sans-serif;line-height:1.6;color:#333">
<div style="max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#2c5282">Accès accordé au TFE</h2>
<p>Bonjour,</p>
<p>Votre demande d'accès au TFE suivant a été automatiquement approuvée :</p>
<div style="background:#f7fafc;padding:15px;border-left:4px solid #2c5282;margin:20px 0">
<strong>Titre :</strong> {$safeTitle}<br>
<strong>Auteur(s) :</strong> {$safeAuthors}
</div>
<p>Cliquez sur le bouton ci-dessous pour activer l'accès aux fichiers de ce TFE :</p>
<div style="text-align:center;margin:30px 0">
<a href="{$safeUrl}"
style="display:inline-block;padding:12px 30px;background-color:#2c5282;
color:#fff;text-decoration:none;border-radius:5px;font-weight:bold">
Activer l'accès au TFE
</a>
</div>
<p><strong>Ce lien est valable 24 heures et à usage unique.</strong>
L'accès sur votre appareil sera ensuite conservé 30 jours.</p>
<p style="margin-top:30px;color:#666;font-size:.9em">
Cordialement,<br>L'équipe XAMXAM ERG
</p>
</div>
</body>
</html>
HTML;
}
/**
* Build the admin notification email for external (non-ERG) access requests.
*/
function buildAdminNotificationEmail(
string $title,
string $authors,
string $year,
string $email,
string $justification,
string $host
): string {
$safeTitle = htmlspecialchars($title, ENT_QUOTES);
$safeAuthors = htmlspecialchars($authors, ENT_QUOTES);
$safeYear = htmlspecialchars($year, ENT_QUOTES);
$safeEmail = htmlspecialchars($email, ENT_QUOTES);
$safeJustification = nl2br(htmlspecialchars($justification, ENT_QUOTES));
$adminUrl = htmlspecialchars("https://{$host}/admin/file-access.php", ENT_QUOTES);
return <<<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:system-ui,Arial,sans-serif;line-height:1.6;color:#333">
<div style="max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#c53030">Nouvelle demande d'accès</h2>
<p>Une nouvelle demande d'accès a été soumise :</p>
<div style="background:#fff5f5;padding:15px;border-left:4px solid #c53030;margin:20px 0">
<strong>TFE :</strong> {$safeTitle}<br>
<strong>Auteur(s) :</strong> {$safeAuthors}<br>
<strong>Année :</strong> {$safeYear}
</div>
<div style="background:#f7fafc;padding:15px;margin:20px 0">
<strong>Email du demandeur :</strong> {$safeEmail}<br><br>
<strong>Justification :</strong><br>
{$safeJustification}
</div>
<div style="margin:30px 0;text-align:center">
<a href="{$adminUrl}"
style="display:inline-block;padding:12px 30px;background-color:#2c5282;
color:#fff;text-decoration:none;border-radius:5px;font-weight:bold">
Gérer les demandes d'accès
</a>
</div>
<p style="color:#666;font-size:.9em">
Connectez-vous au panneau d'administration pour approuver ou rejeter cette demande.
</p>
</div>
</body>
</html>
HTML;
}
/**
* Convert HTML to plain text for the email text/plain part.
*/
function htmlToPlain(string $html): string
{
$text = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html));
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text);
}

View File

@@ -0,0 +1,204 @@
<?php
/**
* Validate Access Token Endpoint
*
* Security model:
* - GET → show a confirmation page (token in hidden form field, never in logs as a meaningful action)
* - POST → redeem the one-time token, create a long-lived HttpOnly session cookie, redirect
*
* Mitigations implemented:
* - Token is one-time use: marked used_at on first redemption
* - Token short-lived: 24h by default
* - Redemption via POST: avoids token appearing in server-side access logs as a GET action
* - CSRF token required on POST
* - Cookie: HttpOnly; Secure; SameSite=Strict; 30-day session
* - Audit trail: IP, User-Agent, timestamp logged on redemption
*/
require_once __DIR__ . '/../bootstrap.php';
require_once APP_ROOT . '/src/Database.php';
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderError(int $code, string $title, string $body, int $thesisId = 0): never
{
http_response_code($code);
$backLink = $thesisId > 0
? '<a href="/tfe?id=' . $thesisId . '">Retour au TFE</a>'
: '<a href="/">Retour à l\'accueil</a>';
echo '<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>' . htmlspecialchars($title) . ' - XAMXAM</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.box{background:#fff;padding:40px;border-radius:8px;
box-shadow:0 2px 10px rgba(0,0,0,.1);max-width:500px;width:90%}
h1{color:#c53030;font-size:1.5rem;margin:0 0 1rem}
p{color:#555;line-height:1.6;margin:.5rem 0}
a{color:#2c5282}
</style>
</head>
<body>
<div class="box">
<h1>' . htmlspecialchars($title) . '</h1>
' . $body . '
<p>' . $backLink . '</p>
</div>
</body>
</html>';
exit;
}
// ── Route: GET — show confirmation page ──────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$token = isset($_GET['token']) ? trim($_GET['token']) : '';
$thesisId = isset($_GET['thesis']) ? (int)$_GET['thesis'] : 0;
if (empty($token) || $thesisId <= 0) {
renderError(400, 'Lien invalide',
'<p>Ce lien d\'accès est invalide ou incomplet.</p>', $thesisId);
}
// Generate (or reuse) CSRF token in session
App::boot();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Minimal pre-check: does the token exist and look valid?
// (Full redemption + one-time mark only happens on POST)
$db = Database::getInstance();
$check = $db->getPDO()->prepare(
"SELECT fat.expires_at, 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'
AND fr.thesis_id = ?
LIMIT 1"
);
$check->execute([$token, $thesisId]);
$valid = $check->fetch();
if (!$valid) {
renderError(403, 'Lien d\'accès invalide ou expiré',
'<p>Ce lien a déjà été utilisé ou a expiré. Veuillez soumettre une nouvelle demande.</p>',
$thesisId);
}
$csrfToken = $_SESSION['csrf_token'];
$safeToken = htmlspecialchars($token, ENT_QUOTES);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Activer l'accès - XAMXAM</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.box{background:#fff;padding:40px;border-radius:8px;
box-shadow:0 2px 10px rgba(0,0,0,.1);max-width:500px;width:90%}
h1{font-size:1.5rem;margin:0 0 1rem;color:#1a202c}
p{color:#555;line-height:1.6;margin:.5rem 0}
.btn{display:inline-block;margin-top:1.5rem;padding:12px 28px;
background:#2c5282;color:#fff;border:none;border-radius:5px;
font-size:1rem;cursor:pointer;text-decoration:none}
.btn:hover{background:#2a4365}
.note{font-size:.85rem;color:#888;margin-top:1rem}
</style>
</head>
<body>
<div class="box">
<h1>Activer l'accès aux fichiers</h1>
<p>Cliquez sur le bouton ci-dessous pour activer l'accès aux fichiers de ce TFE sur cet appareil.</p>
<p class="note">L'accès sera valide pendant 30 jours sur cet appareil et navigateur.</p>
<form method="POST" action="/validate-access">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<input type="hidden" name="token" value="<?= $safeToken ?>">
<input type="hidden" name="thesis_id" value="<?= (int)$thesisId ?>">
<button type="submit" class="btn">Activer l'accès</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
// ── Route: POST — redeem token, set cookie, redirect ─────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
App::boot();
// CSRF check
if (
empty($_POST['csrf_token'])
|| empty($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
renderError(403, 'Erreur de sécurité',
'<p>Token de sécurité invalide. Veuillez réessayer.</p>');
}
$token = isset($_POST['token']) ? trim($_POST['token']) : '';
$thesisId = isset($_POST['thesis_id']) ? (int)$_POST['thesis_id'] : 0;
if (empty($token) || $thesisId <= 0) {
renderError(400, 'Données invalides',
'<p>Les données du formulaire sont invalides.</p>');
}
$db = Database::getInstance();
// Redeem the one-time token (marks it used, logs audit trail)
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512);
$redemption = $db->redeemAccessToken($token, $ip, $ua);
if (!$redemption || $redemption['thesis_id'] !== $thesisId) {
renderError(403, 'Lien invalide ou déjà utilisé',
'<p>Ce lien d\'accès est invalide, a déjà été utilisé, ou ne correspond pas au TFE demandé.</p>
<p>Si vous avez besoin d\'un nouvel accès, veuillez soumettre une nouvelle demande.</p>',
$thesisId);
}
// Create a long-lived browser session (separate from the one-time email token)
$sessionToken = $db->createAccessSession($redemption['request_id'], 30);
// Set HttpOnly, Secure, SameSite=Strict cookie (long-lived session)
$cookieName = 'tfe_access_' . $thesisId;
$cookieSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie($cookieName, $sessionToken, [
'expires' => time() + 30 * 86400,
'path' => '/',
'domain' => '',
'secure' => $cookieSecure,
'httponly' => true,
'samesite' => 'Strict',
]);
// Rotate CSRF token after successful action
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// Redirect to TFE page (no token in URL)
header('Location: /tfe?id=' . $thesisId);
exit;
}
// ── Any other method ──────────────────────────────────────────────────────────
http_response_code(405);
header('Allow: GET, POST');
echo 'Méthode non autorisée';
exit;

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;
}

View File

@@ -535,3 +535,70 @@ GROUP BY t.id;
CREATE VIEW IF NOT EXISTS v_theses_public AS
SELECT * FROM v_theses_full
WHERE is_published = 1;
-- ============================================================================
-- FILE ACCESS RESTRICTION SYSTEM
-- ============================================================================
-- Add support for restricting attached files on TFEs with email-based access
-- requests and cookie-based validation.
-- ============================================================================
-- Add new site setting for enabling/disabling file access restriction
INSERT OR IGNORE INTO site_settings (key, value) VALUES
('restricted_files_enabled', '0');
-- ============================================================================
-- FILE ACCESS REQUESTS TABLE
-- ============================================================================
-- Stores requests from users wanting access to restricted TFE files.
-- Supports approval workflow: pending → approved/rejected
-- ============================================================================
CREATE TABLE IF NOT EXISTS file_access_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thesis_id INTEGER NOT NULL,
email TEXT NOT NULL,
justification TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'approved', 'rejected')),
admin_notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_at DATETIME,
approved_by_admin_id INTEGER,
FOREIGN KEY (thesis_id) REFERENCES theses(id) ON DELETE CASCADE
);
-- Index for efficient lookup by thesis and email
CREATE INDEX IF NOT EXISTS idx_file_access_requests_thesis_id
ON file_access_requests(thesis_id);
CREATE INDEX IF NOT EXISTS idx_file_access_requests_email
ON file_access_requests(email);
CREATE INDEX IF NOT EXISTS idx_file_access_requests_status
ON file_access_requests(status);
-- ============================================================================
-- FILE ACCESS TOKENS TABLE
-- ============================================================================
-- Stores tokens for cookie-based access validation.
-- Each token is unique, time-limited, and linked to a specific request.
-- ============================================================================
CREATE TABLE IF NOT EXISTS file_access_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_valid INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (request_id) REFERENCES file_access_requests(id) ON DELETE CASCADE
);
-- Index for token lookup (most common query)
CREATE INDEX IF NOT EXISTS idx_file_access_tokens_token
ON file_access_tokens(token);
-- Index for cleanup of expired tokens
CREATE INDEX IF NOT EXISTS idx_file_access_tokens_expires_at
ON file_access_tokens(expires_at);

View File

@@ -0,0 +1,199 @@
<main id="main-content">
<h1>Demandes d'accès aux fichiers</h1>
<div class="access-req-stats">
<div class="access-req-stat-card">
<span class="access-req-stat-number"><?= $pendingCount ?></span>
<span class="access-req-stat-label">En attente</span>
</div>
<div class="access-req-stat-card">
<span class="access-req-stat-number"><?= $approvedCount ?></span>
<span class="access-req-stat-label">Approuvées</span>
</div>
<div class="access-req-stat-card">
<span class="access-req-stat-number"><?= $rejectedCount ?></span>
<span class="access-req-stat-label">Rejetées</span>
</div>
</div>
<nav class="access-req-tabs">
<a href="?status=pending" class="access-req-tab <?= $status === 'pending' ? 'active' : '' ?>">
En attente <?= $pendingCount > 0 ? "({$pendingCount})" : '' ?>
</a>
<a href="?status=approved" class="access-req-tab <?= $status === 'approved' ? 'active' : '' ?>">
Approuvées
</a>
<a href="?status=rejected" class="access-req-tab <?= $status === 'rejected' ? 'active' : '' ?>">
Rejetées
</a>
</nav>
<?php if (empty($requests)): ?>
<div class="access-req-empty">
<p>Aucune demande <?= $status === 'pending' ? 'en attente' : ($status === 'approved' ? 'approuvée' : 'rejetée') ?>.</p>
</div>
<?php else: ?>
<div class="access-req-list">
<?php foreach ($requests as $req): ?>
<div class="access-req-card">
<div class="access-req-card__header">
<div class="access-req-card__thesis">
<h3><?= htmlspecialchars($req['title']) ?></h3>
<p class="access-req-card__authors">
<?php if (!empty($req['authors'])): ?>
par <?= htmlspecialchars($req['authors']) ?>
<?php endif; ?>
<?php if (!empty($req['year'])): ?>
— <?= htmlspecialchars($req['year']) ?>
<?php endif; ?>
</p>
</div>
<div class="access-req-card__meta">
<span class="access-req-badge access-req-badge--<?= $status ?>">
<?= $status === 'pending' ? 'En attente' : ($status === 'approved' ? 'Approuvée' : 'Rejetée') ?>
</span>
</div>
</div>
<div class="access-req-card__body">
<div class="access-req-card__info">
<div>
<strong>Email :</strong>
<a href="mailto:<?= htmlspecialchars($req['email']) ?>">
<?= htmlspecialchars($req['email']) ?>
</a>
</div>
<div>
<strong>Date :</strong>
<?= date('d/m/Y à H:i', strtotime($req['created_at'])) ?>
</div>
<?php if ($status === 'approved' && !empty($req['approved_at'])): ?>
<div>
<strong>Approuvée le :</strong>
<?= date('d/m/Y à H:i', strtotime($req['approved_at'])) ?>
</div>
<?php endif; ?>
<?php if ($status === 'rejected' && !empty($req['approved_at'])): ?>
<div>
<strong>Rejetée le :</strong>
<?= date('d/m/Y à H:i', strtotime($req['approved_at'])) ?>
</div>
<?php endif; ?>
</div>
<?php if (!empty($req['justification'])): ?>
<div class="access-req-card__justification">
<strong>Justification :</strong>
<p><?= nl2br(htmlspecialchars($req['justification'])) ?></p>
</div>
<?php endif; ?>
<?php if ($status === 'rejected' && !empty($req['admin_notes'])): ?>
<div class="access-req-card__admin-notes">
<strong>Note de l'administrateur :</strong>
<p><?= nl2br(htmlspecialchars($req['admin_notes'])) ?></p>
</div>
<?php endif; ?>
<?php if ($status === 'pending'): ?>
<div class="access-req-card__actions">
<button type="button"
class="access-req-btn access-req-btn--approve"
onclick="openApproveDialog(<?= $req['id'] ?>)">
Approuver
</button>
<button type="button"
class="access-req-btn access-req-btn--reject"
onclick="openRejectDialog(<?= $req['id'] ?>)">
Rejeter
</button>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($totalPages > 1): ?>
<nav class="access-req-pagination">
<?php if ($page > 1): ?>
<a href="?status=<?= $status ?>&page=<?= $page - 1 ?>" class="access-req-pagination__link">
&larr; Précédent
</a>
<?php endif; ?>
<span class="access-req-pagination__info">
Page <?= $page ?> sur <?= $totalPages ?>
</span>
<?php if ($page < $totalPages): ?>
<a href="?status=<?= $status ?>&page=<?= $page + 1 ?>" class="access-req-pagination__link">
Suivant &rarr;
</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</main>
<!-- Approve Dialog -->
<dialog id="approve-dialog" class="admin-dialog">
<div class="admin-dialog__header">
<h2>Approuver la demande</h2>
<button type="button" class="admin-dialog__close"
onclick="document.getElementById('approve-dialog').close()">&times;</button>
</div>
<form method="post" action="/admin/actions/access-request.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="request_id" id="approve-request-id">
<input type="hidden" name="action" value="approve">
<label for="approve-notes">Note optionnelle (inclus dans l'email) :</label>
<textarea name="admin_notes" id="approve-notes" rows="3"
placeholder="Message personnalisé pour le demandeur..."></textarea>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Approuver et envoyer email</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('approve-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<!-- Reject Dialog -->
<dialog id="reject-dialog" class="admin-dialog">
<div class="admin-dialog__header">
<h2>Rejeter la demande</h2>
<button type="button" class="admin-dialog__close"
onclick="document.getElementById('reject-dialog').close()">&times;</button>
</div>
<form method="post" action="/admin/actions/access-request.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="request_id" id="reject-request-id">
<input type="hidden" name="action" value="reject">
<label for="reject-notes">Raison du rejet (optionnel) :</label>
<textarea name="admin_notes" id="reject-notes" rows="3"
placeholder="Raison du rejet..."></textarea>
<div class="admin-form-footer">
<button type="submit" class="admin-btn admin-btn--danger">Rejeter</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('reject-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<script>
function openApproveDialog(requestId) {
document.getElementById('approve-request-id').value = requestId;
document.getElementById('approve-dialog').showModal();
}
function openRejectDialog(requestId) {
document.getElementById('reject-request-id').value = requestId;
document.getElementById('reject-dialog').showModal();
}
</script>

View File

@@ -102,6 +102,19 @@
</label>
</fieldset>
<fieldset>
<legend>Restriction d'accès aux fichiers</legend>
<label class="param-checkbox">
<input type="checkbox" name="restricted_files_enabled" value="1"
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<span>
<strong>Activer la restriction d'accès</strong><br>
<small>Pour les TFE de type "Interne", masquer les fichiers et exiger une demande d'accès par email. Les métadonnées et le résumé restent visibles publiquement.</small>
</span>
</label>
</fieldset>
<button type="submit">Enregistrer</button>
</form>
</section>

View File

@@ -19,6 +19,12 @@ $_thesisId = $_GET['id'] ?? null;
<li><a href="/admin/" <?= $_currentPage === 'index.php' ? 'aria-current="page"' : '' ?>>Liste des TFE</a></li>
<li><a href="/admin/contenus.php" <?= in_array($_currentPage, ['contenus.php', 'contenus-edit.php']) ? 'aria-current="page"' : '' ?>>Contenus</a></li>
<li><a href="/admin/tags.php" <?= $_currentPage === 'tags.php' ? 'aria-current="page"' : '' ?>>Mots-clés</a></li>
<li><a href="/admin/file-access.php" <?= $_currentPage === 'file-access.php' ? 'aria-current="page"' : '' ?>>
Demandes d'accès
<?php if (isset($pendingCount) && $pendingCount > 0): ?>
<span class="admin-nav-badge"><?= $pendingCount ?></span>
<?php endif; ?>
</a></li>
<li><a href="/admin/system.php" <?= in_array($_currentPage, ['system.php', 'status.php', 'logs.php']) ? 'aria-current="page"' : '' ?>>Système</a></li>
<li><a href="/admin/acces-etudiante.php" <?= $_currentPage === 'acces-etudiante.php' ? 'aria-current="page"' : '' ?>>Accès étudiant·e</a></li>
<li><a href="/admin/parametres.php" <?= $_currentPage === 'parametres.php' ? 'aria-current="page"' : '' ?>>Paramètres</a></li>

View File

@@ -193,6 +193,100 @@
<p class="tfe-restricted">
Ce TFE n'est pas disponible en ligne.
</p>
<?php elseif ($shouldHideFiles): ?>
<div class="tfe-restricted-access">
<p class="tfe-restricted-message">
<strong>Accès restreint</strong><br>
Les fichiers attachés à ce TFE sont réservés aux utilisateurs autorisés.
</p>
<form id="access-request-form" class="tfe-access-request-form"
data-thesis-id="<?= $thesisId ?>">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<div class="form-group">
<label for="access-email">Votre adresse email :</label>
<input type="email"
id="access-email"
name="email"
required
placeholder="votre@email.com">
</div>
<div id="justification-container" class="form-group" style="display: none;">
<label for="access-justification">Pourquoi souhaitez-vous accéder à ce TFE ?</label>
<textarea id="access-justification"
name="justification"
rows="4"
placeholder="Décrivez brièvement votre motivation (recherche, collaboration, etc.)"></textarea>
</div>
<button type="submit" class="tfe-btn-request-access">
Demander l'accès
</button>
<div id="access-request-message" class="tfe-access-message" style="display: none;"></div>
</form>
</div>
<script>
(function() {
const form = document.getElementById('access-request-form');
const emailInput = document.getElementById('access-email');
const justificationContainer = document.getElementById('justification-container');
const justificationInput = document.getElementById('access-justification');
const messageDiv = document.getElementById('access-request-message');
// Show/hide justification based on email domain
emailInput.addEventListener('input', function() {
const email = this.value.trim().toLowerCase();
const isErg = email.endsWith('@erg.school') || email.endsWith('@erg.be');
justificationContainer.style.display = isErg ? 'none' : 'block';
justificationInput.required = !isErg;
});
// Form submission
form.addEventListener('submit', function(e) {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Envoi en cours...';
messageDiv.style.display = 'none';
const formData = new FormData(form);
formData.append('thesis_id', '<?= $thesisId ?>');
fetch('/request-access.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
messageDiv.style.display = 'block';
if (data.success) {
messageDiv.className = 'tfe-access-message tfe-access-success';
messageDiv.textContent = data.message;
form.reset();
} else {
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.textContent = data.message || 'Une erreur est survenue. Veuillez réessayer.';
}
})
.catch(error => {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
messageDiv.style.display = 'block';
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.textContent = 'Erreur de connexion. Veuillez réessayer.';
});
});
})();
</script>
<?php elseif (!empty($data['files'])): ?>
<?php foreach ($data['files'] as $file): ?>
<?php