Files
xamxam/app/public/request-access.php
Pontoporeia 89b7ab476e Handle SMTP 550 recipient-rejected errors with structured SmtpSendException
- Add SmtpSendException with smtpCode/smtpResponse/isRecipientRejected()
- smtpSend() $expect closure throws SmtpSendException (with code) instead of RuntimeException
- SmtpRelay::send() re-throws SmtpSendException so callers can inspect it
- request-access.php (new): catch 550 → roll back token+approval, return HTTP 422 with FR user message
- request-access.php (resend): catch 550 → HTTP 422 instead of silently claiming success
- StudentEmail::sendConfirmation(): catch SmtpSendException → log+false (submission not aborted)
- admin/actions/access-request.php: catch SmtpSendException post-approval → flash warning (recipient-rejected vs transient)
2026-05-05 11:04:52 +02:00

370 lines
14 KiB
PHP
Raw Permalink 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';
// SmtpSendException is defined in 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 (!(new RateLimit(3, 600))->checkKey($rateLimitKey)) {
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 (SmtpSendException $e) {
error_log('Access request resend failed: ' . $e->getMessage());
if ($e->isRecipientRejected()) {
http_response_code(422);
echo json_encode([
'success' => false,
'message' => "L'adresse e-mail « {$email} » est introuvable sur le serveur de messagerie de l'ERG. Vérifiez l'orthographe ou utilisez une autre adresse.",
'status' => 'recipient_rejected',
]);
} else {
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.']);
}
} 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);
try {
SmtpRelay::send($db, $email, $subject, $body, $plain);
} catch (SmtpSendException $e) {
if ($e->isRecipientRejected()) {
// SMTP server does not know this address — roll back the approval
// so the user can retry with a valid address.
$db->getPDO()->exec(
"DELETE FROM file_access_tokens WHERE request_id = {$requestId}"
);
$db->getPDO()->exec(
"UPDATE file_access_requests
SET status = 'rejected', admin_notes = 'Adresse e-mail inconnue du serveur de messagerie (550)'
WHERE id = {$requestId}"
);
http_response_code(422);
echo json_encode([
'success' => false,
'message' => "L'adresse e-mail « {$email} » est introuvable sur le serveur de messagerie de l'ERG. Vérifiez l'orthographe ou utilisez une autre adresse.",
'status' => 'recipient_rejected',
]);
exit;
}
// Transient send failure — access is approved, email may arrive later
error_log("[request-access] Email delivery failed for approved request #{$requestId}: " . $e->getMessage());
}
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);
$notifyEmail = SmtpRelay::getNotifyEmail($db);
if ($notifyEmail !== '') {
SmtpRelay::send($db, $notifyEmail, $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 (SmtpSendException $e) {
error_log('Access request SMTP failure: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur lors de l\'envoi de l\'email. Veuillez réessayer.']);
} 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);
}