mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
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:
72
TODO.md
72
TODO.md
@@ -1,12 +1,62 @@
|
|||||||
# TODO
|
# TFE Access Restriction Feature
|
||||||
|
|
||||||
- [x] Update migrate.sh to only handle posterg.db
|
## Overview
|
||||||
- [x] Update Database.php determineDatabasePath to always use posterg.db
|
Add access restriction for TFE attached files based on user email domain, with admin validation workflow.
|
||||||
- [x] Update justfile to remove test.db targets and references
|
|
||||||
- [x] Remove CreateTestDatabase.php fixture script
|
## Implementation Plan
|
||||||
- [x] Update setup-dev.sh to only create posterg.db
|
|
||||||
- [x] Update run-tests.php to use posterg.db
|
### 1. Database Changes
|
||||||
- [x] Remove test.db file
|
- [x] Add `restricted_files_enabled` setting to site_settings table
|
||||||
- [x] Update deploy-db target in justfile
|
- [x] Create `file_access_requests` table
|
||||||
- [x] Replace "Posterg" with "XAMXAM" in page titles and meta tags
|
- id, thesis_id, email, justification, status (pending/approved/rejected), admin_notes, created_at, approved_at, approved_by_admin_id
|
||||||
- [x] Verify XAMXAM branding consistency across user-facing content
|
- [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)
|
||||||
|
|||||||
71
app/migrations/applied/002_add_file_access_restriction.sql
Normal file
71
app/migrations/applied/002_add_file_access_restriction.sql
Normal 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.
|
||||||
50
app/migrations/applied/003_secure_access_tokens.sql
Normal file
50
app/migrations/applied/003_secure_access_tokens.sql
Normal 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);
|
||||||
124
app/public/admin/actions/access-request.php
Normal file
124
app/public/admin/actions/access-request.php
Normal 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;
|
||||||
|
}
|
||||||
@@ -17,7 +17,12 @@ $db = new Database();
|
|||||||
$section = $_POST['section'] ?? '';
|
$section = $_POST['section'] ?? '';
|
||||||
|
|
||||||
if ($section === 'formulaire') {
|
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) {
|
foreach ($allowed as $key) {
|
||||||
$value = isset($_POST[$key]) ? '1' : '0';
|
$value = isset($_POST[$key]) ? '1' : '0';
|
||||||
$db->setSetting($key, $value);
|
$db->setSetting($key, $value);
|
||||||
|
|||||||
20
app/public/admin/file-access.php
Normal file
20
app/public/admin/file-access.php
Normal 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';
|
||||||
282
app/public/assets/css/file-access.css
Normal file
282
app/public/assets/css/file-access.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -167,6 +167,116 @@ aside figcaption {
|
|||||||
margin: 0;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.tfe-layout {
|
.tfe-layout {
|
||||||
|
|||||||
328
app/public/request-access.php
Normal file
328
app/public/request-access.php
Normal 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);
|
||||||
|
}
|
||||||
204
app/public/validate-access.php
Normal file
204
app/public/validate-access.php
Normal 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;
|
||||||
@@ -22,6 +22,9 @@ class App
|
|||||||
require_once APP_ROOT . '/src/Database.php';
|
require_once APP_ROOT . '/src/Database.php';
|
||||||
self::$booted = true;
|
self::$booted = true;
|
||||||
}
|
}
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
self::ensureCsrf();
|
self::ensureCsrf();
|
||||||
return Database::getInstance();
|
return Database::getInstance();
|
||||||
}
|
}
|
||||||
|
|||||||
137
app/src/Controllers/FileAccessController.php
Normal file
137
app/src/Controllers/FileAccessController.php
Normal 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,8 +72,23 @@ class TfeController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Access type (1 = open, 2 = restricted, 3 = forbidden)
|
// Access type (1 = open, 2 = restricted, 3 = forbidden)
|
||||||
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
|
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
|
||||||
$isInterdit = ($accessTypeId === 3);
|
$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>
|
// Caption (WebVTT) files — N-th VTT is paired with the N-th <video>
|
||||||
$captionFiles = $this->collectCaptionPaths($data['files'] ?? []);
|
$captionFiles = $this->collectCaptionPaths($data['files'] ?? []);
|
||||||
@@ -101,6 +116,11 @@ class TfeController
|
|||||||
'promoteursExternes' => $juryByRole['externes'],
|
'promoteursExternes' => $juryByRole['externes'],
|
||||||
'juryLecteurs' => $juryByRole['lecteurs'],
|
'juryLecteurs' => $juryByRole['lecteurs'],
|
||||||
|
|
||||||
|
// Restricted files access
|
||||||
|
'restrictedEnabled' => $restrictedEnabled,
|
||||||
|
'hasRestrictedAccess' => $hasRestrictedAccess,
|
||||||
|
'shouldHideFiles' => $shouldHideFiles,
|
||||||
|
|
||||||
// Page meta
|
// Page meta
|
||||||
'pageTitle' => $pageTitle,
|
'pageTitle' => $pageTitle,
|
||||||
'metaDescription' => $metaDescription,
|
'metaDescription' => $metaDescription,
|
||||||
|
|||||||
@@ -1907,4 +1907,332 @@ class Database {
|
|||||||
// phpcs:ignore
|
// phpcs:ignore
|
||||||
trigger_error('Cannot unserialize singleton ' . static::class, E_USER_ERROR);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ class Dispatcher {
|
|||||||
* execute the action, and render the view.
|
* execute the action, and render the view.
|
||||||
*/
|
*/
|
||||||
public function dispatch(): void {
|
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)
|
// 1. Direct-response endpoints (render their own output)
|
||||||
$direct = $this->matchDirect();
|
$direct = $this->matchDirect();
|
||||||
if ($direct) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -535,3 +535,70 @@ GROUP BY t.id;
|
|||||||
CREATE VIEW IF NOT EXISTS v_theses_public AS
|
CREATE VIEW IF NOT EXISTS v_theses_public AS
|
||||||
SELECT * FROM v_theses_full
|
SELECT * FROM v_theses_full
|
||||||
WHERE is_published = 1;
|
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);
|
||||||
|
|||||||
199
app/templates/admin/file-access.php
Normal file
199
app/templates/admin/file-access.php
Normal 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">
|
||||||
|
← 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 →
|
||||||
|
</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()">×</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()">×</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>
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +102,19 @@
|
|||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</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>
|
<button type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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/" <?= $_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/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/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/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/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>
|
<li><a href="/admin/parametres.php" <?= $_currentPage === 'parametres.php' ? 'aria-current="page"' : '' ?>>Paramètres</a></li>
|
||||||
|
|||||||
@@ -193,6 +193,100 @@
|
|||||||
<p class="tfe-restricted">
|
<p class="tfe-restricted">
|
||||||
Ce TFE n'est pas disponible en ligne.
|
Ce TFE n'est pas disponible en ligne.
|
||||||
</p>
|
</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 elseif (!empty($data['files'])): ?>
|
||||||
<?php foreach ($data['files'] as $file): ?>
|
<?php foreach ($data['files'] as $file): ?>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user