Files
xamxam/app/public/request-access.php
Pontoporeia 27e1b6828d 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
2026-04-27 20:20:52 +02:00

329 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}