mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
- 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)
370 lines
14 KiB
PHP
370 lines
14 KiB
PHP
<?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);
|
||
}
|