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:
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'] ?? '';
|
||||
|
||||
if ($section === 'formulaire') {
|
||||
$allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled'];
|
||||
$allowed = [
|
||||
'access_type_libre_enabled',
|
||||
'access_type_interne_enabled',
|
||||
'access_type_interdit_enabled',
|
||||
'restricted_files_enabled'
|
||||
];
|
||||
foreach ($allowed as $key) {
|
||||
$value = isset($_POST[$key]) ? '1' : '0';
|
||||
$db->setSetting($key, $value);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RESTRICTED ACCESS UI
|
||||
============================================================ */
|
||||
.tfe-restricted-access {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-m);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
|
||||
.tfe-restricted-message {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tfe-restricted-message strong {
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: var(--space-3xs);
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
|
||||
.tfe-access-request-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.tfe-access-request-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
.tfe-access-request-form label {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tfe-access-request-form input[type="email"],
|
||||
.tfe-access-request-form textarea {
|
||||
font-family: inherit;
|
||||
font-size: var(--step--1);
|
||||
padding: var(--space-2xs) var(--space-3xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.tfe-access-request-form input[type="email"]:focus,
|
||||
.tfe-access-request-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tfe-access-request-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.tfe-btn-request-access {
|
||||
font-family: inherit;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
padding: var(--space-2xs) var(--space-s);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
margin-top: var(--space-3xs);
|
||||
}
|
||||
|
||||
.tfe-btn-request-access:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
.tfe-btn-request-access:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tfe-access-message {
|
||||
font-size: var(--step--1);
|
||||
padding: var(--space-2xs);
|
||||
border-radius: 4px;
|
||||
margin-top: var(--space-3xs);
|
||||
}
|
||||
|
||||
.tfe-access-success {
|
||||
background: #f0fff4;
|
||||
border: 1px solid #48bb78;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.tfe-access-error {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fc8181;
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.tfe-layout {
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user