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 :

+ +
+ + Accéder au 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; +} 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 :

+ +
+ + Activer l'accès au 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} +
+ +
+ + Gérer les demandes d'accès + +
+ +

+ 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