From 27e1b6828d574dd22180e0ff2847b98a3f832fd7 Mon Sep 17 00:00:00 2001
From: Pontoporeia
Date: Mon, 27 Apr 2026 20:12:43 +0200
Subject: [PATCH] Implement TFE file access restriction feature (complete)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
TODO.md | 72 +-
.../002_add_file_access_restriction.sql | 71 +
.../applied/003_secure_access_tokens.sql | 50 +
app/public/admin/actions/access-request.php | 124 +
app/public/admin/actions/settings.php | 7 +-
app/public/admin/file-access.php | 20 +
app/public/assets/css/file-access.css | 282 ++
app/public/assets/css/tfe.css | 110 +
app/public/request-access.php | 328 ++
app/public/validate-access.php | 204 +
app/src/App.php | 3 +
app/src/Controllers/FileAccessController.php | 137 +
app/src/Controllers/TfeController.php | 24 +-
app/src/Database.php | 330 +-
app/src/Dispatcher.php | 17 +
app/storage/schema.sql | 67 +
app/templates/admin/file-access.php | 199 +
app/templates/admin/parametres.php | 13 +
app/templates/header.php | 6 +
app/templates/public/tfe.php | 94 +
..._019dd00c-edcd-70f7-8ad0-2c3994b48903.html | 4113 +++++++++++++++++
21 files changed, 6256 insertions(+), 15 deletions(-)
create mode 100644 app/migrations/applied/002_add_file_access_restriction.sql
create mode 100644 app/migrations/applied/003_secure_access_tokens.sql
create mode 100644 app/public/admin/actions/access-request.php
create mode 100644 app/public/admin/file-access.php
create mode 100644 app/public/assets/css/file-access.css
create mode 100644 app/public/request-access.php
create mode 100644 app/public/validate-access.php
create mode 100644 app/src/Controllers/FileAccessController.php
create mode 100644 app/templates/admin/file-access.php
create mode 100644 pi-session-2026-04-27T17-46-41-486Z_019dd00c-edcd-70f7-8ad0-2c3994b48903.html
diff --git a/TODO.md b/TODO.md
index 719e759..8695959 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,12 +1,62 @@
-# TODO
+# TFE Access Restriction Feature
-- [x] Update migrate.sh to only handle posterg.db
-- [x] Update Database.php determineDatabasePath to always use posterg.db
-- [x] Update justfile to remove test.db targets and references
-- [x] Remove CreateTestDatabase.php fixture script
-- [x] Update setup-dev.sh to only create posterg.db
-- [x] Update run-tests.php to use posterg.db
-- [x] Remove test.db file
-- [x] Update deploy-db target in justfile
-- [x] Replace "Posterg" with "XAMXAM" in page titles and meta tags
-- [x] Verify XAMXAM branding consistency across user-facing content
+## Overview
+Add access restriction for TFE attached files based on user email domain, with admin validation workflow.
+
+## Implementation Plan
+
+### 1. Database Changes
+- [x] Add `restricted_files_enabled` setting to site_settings table
+- [x] Create `file_access_requests` table
+ - id, thesis_id, email, justification, status (pending/approved/rejected), admin_notes, created_at, approved_at, approved_by_admin_id
+- [x] Create `file_access_tokens` table (short-lived, one-time email links, 24h)
+ - id, request_id, token (unique), expires_at, used_at (one-time mark)
+- [x] Create `file_access_sessions` table (long-lived browser sessions, 30 days)
+ - id, request_id, session_token, expires_at, is_valid
+- [x] Create `file_access_audit` table (IP, UA, timestamp on redemption)
+
+### 2. Configuration
+- [x] Add `restricted_files_enabled` checkbox in parametres.php (Formulaire section)
+- [x] Update settings.php action handler to persist the setting
+
+### 3. Public TFE View (tfe.php) - Restricted Access UI
+- [x] TfeController checks restricted flag + access_type_id=2 + cookie session
+- [x] French text: "Accès restreint — Les fichiers attachés à ce TFE sont réservés aux utilisateurs autorisés."
+- [x] Request form: email input + conditional justification textarea (non-ERG only)
+- [x] JS: shows/hides justification textarea based on email domain (@erg.school / @erg.be)
+- [x] Form submits via fetch POST to /request-access.php with CSRF token
+- [x] Metadata, title, authors, synopsis all remain visible regardless of restriction
+
+### 4. Email Flow
+- [x] @erg.school / @erg.be → auto-approve, generate 24h one-time token, send email immediately
+- [x] External email → create pending request, notify admin by email
+- [x] Admin approves → generate 24h token, send email to requester
+- [x] Email contains link to GET /validate-access (confirmation page) → POST to redeem
+
+### 5. Secure Token Redemption
+- [x] One-time email token (24h, marked used_at on first click, is_valid=0)
+- [x] GET /validate-access → shows confirmation page (no side effects)
+- [x] POST /validate-access → redeems token (CSRF required), creates browser session cookie
+- [x] Cookie: HttpOnly; Secure; SameSite=Strict; 30 days
+- [x] Session stored in file_access_sessions (separate from one-time email token)
+- [x] TfeController checks file_access_sessions via hasValidCookieAccess()
+- [x] Audit trail: IP, User-Agent, timestamp in file_access_audit on every redemption attempt
+
+### 6. Admin Panel - Access Requests Management
+- [x] Admin page: /admin/file-access.php (linked from admin nav with pending badge)
+- [x] List pending/approved/rejected requests with tab filters and pagination
+- [x] Approve dialog (with optional note) → sends 24h access email
+- [x] Reject dialog (with optional note)
+- [x] Bug fix: $vars was never extract()ed — page was blank
+- [x] Bug fix: template included head.php/header.php/footer.php itself (double-include)
+- [x] Bug fix: admin approval URL used $requestId instead of $request['thesis_id']
+
+### 7. Security
+- [x] One-time use tokens (used_at + is_valid=0)
+- [x] POST-based redemption (token in hidden form field, not URL action)
+- [x] 256-bit random tokens, rate limiting on request submission (3/10min per IP)
+- [x] HttpOnly cookie + SameSite=Strict
+- [x] CSRF on all mutations
+- [x] Audit trail (IP, UA, event, timestamp)
+- [x] Short-lived email links (24h), long-lived browser sessions (30 days)
+- [x] App::boot() starts session for all public requests (CSRF token available everywhere)
diff --git a/app/migrations/applied/002_add_file_access_restriction.sql b/app/migrations/applied/002_add_file_access_restriction.sql
new file mode 100644
index 0000000..9cd8ff1
--- /dev/null
+++ b/app/migrations/applied/002_add_file_access_restriction.sql
@@ -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.
diff --git a/app/migrations/applied/003_secure_access_tokens.sql b/app/migrations/applied/003_secure_access_tokens.sql
new file mode 100644
index 0000000..ff3f59c
--- /dev/null
+++ b/app/migrations/applied/003_secure_access_tokens.sql
@@ -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);
diff --git a/app/public/admin/actions/access-request.php b/app/public/admin/actions/access-request.php
new file mode 100644
index 0000000..806569a
--- /dev/null
+++ b/app/public/admin/actions/access-request.php
@@ -0,0 +1,124 @@
+ 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 = "Note de l'administrateur :
" . htmlspecialchars($adminNotes) . "
";
+ }
+
+ return <<
+
+
+
+
+
Accès accordé au TFE
+
+
Bonjour,
+
+
Votre demande d'accès au TFE suivant a été approuvée par un administrateur :
+
+
+ Titre : {$title}
+ Auteur(s) : {$authors}
+
+
+ {$notesHtml}
+
+
Pour accéder aux fichiers attachés, veuillez cliquer sur le lien ci-dessous :
+
+
+
+
Ce lien est valable 24 heures et à usage unique. L'accès sur votre appareil sera ensuite conservé 30 jours.
+
+
+ Cordialement,
+ L'équipe XAMXAM - ERG
+
+
+
+
+HTML;
+}
diff --git a/app/public/admin/actions/settings.php b/app/public/admin/actions/settings.php
index 8d023b4..555ddaa 100644
--- a/app/public/admin/actions/settings.php
+++ b/app/public/admin/actions/settings.php
@@ -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);
diff --git a/app/public/admin/file-access.php b/app/public/admin/file-access.php
new file mode 100644
index 0000000..951834e
--- /dev/null
+++ b/app/public/admin/file-access.php
@@ -0,0 +1,20 @@
+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 '';
+include APP_ROOT . '/templates/admin/file-access.php';
+require_once APP_ROOT . '/templates/admin/footer.php';
diff --git a/app/public/assets/css/file-access.css b/app/public/assets/css/file-access.css
new file mode 100644
index 0000000..de7bd0b
--- /dev/null
+++ b/app/public/assets/css/file-access.css
@@ -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;
+}
diff --git a/app/public/assets/css/tfe.css b/app/public/assets/css/tfe.css
index 056a5c1..cb5ce05 100644
--- a/app/public/assets/css/tfe.css
+++ b/app/public/assets/css/tfe.css
@@ -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 {
diff --git a/app/public/request-access.php b/app/public/request-access.php
new file mode 100644
index 0000000..d456b18
--- /dev/null
+++ b/app/public/request-access.php
@@ -0,0 +1,328 @@
+ 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 <<
+
+
+
+
+
Accès accordé au TFE
+
+
Bonjour,
+
Votre demande d'accès au TFE suivant a été automatiquement approuvée :
+
+
+ Titre : {$safeTitle}
+ Auteur(s) : {$safeAuthors}
+
+
+
Cliquez sur le bouton ci-dessous pour activer l'accès aux fichiers de ce TFE :
+
+
+
+
Ce lien est valable 24 heures et à usage unique.
+ L'accès sur votre appareil sera ensuite conservé 30 jours.
+
+
+ Cordialement,
L'équipe XAMXAM – ERG
+
+
+
+
+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 <<
+
+
+
+
+
Nouvelle demande d'accès
+
+
Une nouvelle demande d'accès a été soumise :
+
+
+ TFE : {$safeTitle}
+ Auteur(s) : {$safeAuthors}
+ Année : {$safeYear}
+
+
+
+ Email du demandeur : {$safeEmail}
+ Justification :
+ {$safeJustification}
+
+
+
+
+
+ Connectez-vous au panneau d'administration pour approuver ou rejeter cette demande.
+
+
+
+
+HTML;
+}
+
+/**
+ * Convert HTML to plain text for the email text/plain part.
+ */
+function htmlToPlain(string $html): string
+{
+ $text = strip_tags(str_replace(['
', '
', '
'], "\n", $html));
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $text = preg_replace('/\n{3,}/', "\n\n", $text);
+ return trim($text);
+}
diff --git a/app/public/validate-access.php b/app/public/validate-access.php
new file mode 100644
index 0000000..f2a8af8
--- /dev/null
+++ b/app/public/validate-access.php
@@ -0,0 +1,204 @@
+ 0
+ ? 'Retour au TFE'
+ : 'Retour à l\'accueil';
+ echo '
+
+
+
+
+ ' . htmlspecialchars($title) . ' - XAMXAM
+
+
+
+
+
' . htmlspecialchars($title) . '
+ ' . $body . '
+
' . $backLink . '
+
+
+';
+ 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',
+ 'Ce lien d\'accès est invalide ou incomplet.
', $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é',
+ 'Ce lien a déjà été utilisé ou a expiré. Veuillez soumettre une nouvelle demande.
',
+ $thesisId);
+ }
+
+ $csrfToken = $_SESSION['csrf_token'];
+ $safeToken = htmlspecialchars($token, ENT_QUOTES);
+?>
+
+
+
+
+
+ Activer l'accès - XAMXAM
+
+
+
+
+
Activer l'accès aux fichiers
+
Cliquez sur le bouton ci-dessous pour activer l'accès aux fichiers de ce TFE sur cet appareil.
+
L'accès sera valide pendant 30 jours sur cet appareil et navigateur.
+
+
+
+
+
+Token de sécurité invalide. Veuillez réessayer.
');
+ }
+
+ $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',
+ 'Les données du formulaire sont invalides.
');
+ }
+
+ $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é',
+ 'Ce lien d\'accès est invalide, a déjà été utilisé, ou ne correspond pas au TFE demandé.
+ Si vous avez besoin d\'un nouvel accès, veuillez soumettre une nouvelle demande.
',
+ $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;
diff --git a/app/src/App.php b/app/src/App.php
index 40a4868..0c8005a 100644
--- a/app/src/App.php
+++ b/app/src/App.php
@@ -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();
}
diff --git a/app/src/Controllers/FileAccessController.php b/app/src/Controllers/FileAccessController.php
new file mode 100644
index 0000000..7d4e403
--- /dev/null
+++ b/app/src/Controllers/FileAccessController.php
@@ -0,0 +1,137 @@
+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'];
+ }
+}
diff --git a/app/src/Controllers/TfeController.php b/app/src/Controllers/TfeController.php
index 436f7ef..e9908e7 100644
--- a/app/src/Controllers/TfeController.php
+++ b/app/src/Controllers/TfeController.php
@@ -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