Files
xamxam/app/templates/public/tfe.php
Pontoporeia 38dc8de9d8 feat: obfuscate all email addresses and mailto links as HTML entities
Added EmailObfuscator class (src/EmailObfuscator.php) that converts
email addresses to HTML decimal entities (e.g. foo@...)
so browsers render them correctly but bots and scrapers see gibberish.

Methods:
- email($addr): obfuscate for display in HTML content
- mailto($addr): return obfuscated mailto: href
- obfuscateHtml($html): post-process rendered HTML to obfuscate all
  mailto: links (used after Parsedown/Markdown rendering)

Applied to:
- partage/index.php: mailto link at top + error scenarios via _flash_contact
  flag rendered in form.php (outside htmlspecialchars to avoid double-escape)
- admin/acces.php: request email mailto links
- admin/file-access.php: request email mailto links
- public/about.php: contact email mailto links
- public/tfe.php: author contact mailto links
- AboutController: Parsedown output post-processing
- LicenceController: Parsedown output post-processing
- Dispatcher::render(): require_once EmailObfuscator for all public views

Also fixed _flash_contact session flag in form.php partial to show
contact email line on share link validation errors (separate from
flash_error/warning to bypass htmlspecialchars double-escaping).
2026-05-19 00:08:05 +02:00

557 lines
26 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.
<main class="tfe-main" id="main-content">
<article class="tfe-layout">
<!-- LEFT: info article header -->
<section class="tfe-left">
<!-- Author above title -->
<p class="tfe-author"><?= htmlspecialchars(
$data["authors"] ?? "Auteur inconnu",
) ?></p>
<h1 class="tfe-title">
<?= htmlspecialchars($data["title"]) ?>
<?php if (!empty($data["subtitle"])): ?>
<?= htmlspecialchars($data["subtitle"]) ?>
<?php endif; ?>
</h1>
<dl>
<?php if (!empty($data["orientation"])): ?>
<div>
<dt>Orientation :</dt>
<dd><a href="/repertoire?or[]=<?= urlencode(
$data["orientation"],
) ?>"><?= htmlspecialchars($data["orientation"]) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["ap_program"])): ?>
<div>
<dt>Atelier pluridisciplinaire :</dt>
<dd><a href="/repertoire?ap[]=<?= urlencode(
$data["ap_program"],
) ?>"><?= htmlspecialchars($data["ap_program"]) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["year"])): ?>
<div>
<dt>Date :</dt>
<dd><a href="/repertoire?fy[]=<?= urlencode(
$data["year"],
) ?>"><?= htmlspecialchars($data["year"]) ?></a></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["languages"])): ?>
<div>
<dt>Langue :</dt>
<dd><?php
$langs = array_map(
"trim",
explode(",", $data["languages"]),
);
$langLinks = array_map(
fn($l) => '<a href="/search?query=' .
urlencode($l) .
'">' .
htmlspecialchars($l) .
"</a>",
$langs,
);
echo implode(", ", $langLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["formats"])): ?>
<div>
<dt>Format :</dt>
<dd><?php
$fmts = array_map("trim", explode(",", $data["formats"]));
$fmtLinks = array_map(
fn($f) => '<a href="/search?query=' .
urlencode($f) .
'">' .
htmlspecialchars($f) .
"</a>",
$fmts,
);
echo implode(", ", $fmtLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["keywords"])): ?>
<div>
<dt>Mots-clés :</dt>
<dd><?php
$kws = array_map("trim", explode(",", $data["keywords"]));
$kwLinks = array_map(
fn($k) => '<a href="/repertoire?kw[]=' .
urlencode($k) .
'">' .
htmlspecialchars($k) .
"</a>",
$kws,
);
echo implode(", ", $kwLinks);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursInternes)): ?>
<div>
<dt>Promoteur·ice interne :</dt>
<dd><?php
$links = array_map(
fn($n) => '<a href="/search?query=' .
urlencode($n) .
'">' .
htmlspecialchars($n) .
"</a>",
$promoteursInternes,
);
echo implode(", ", $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursExternes)): ?>
<div>
<dt>Promoteur·ice externe :</dt>
<dd><?php
$links = array_map(
fn($n) => '<a href="/search?query=' .
urlencode($n) .
'">' .
htmlspecialchars($n) .
"</a>",
$promoteursExternes,
);
echo implode(", ", $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($promoteursUlb)): ?>
<div>
<dt>Promoteur·ice ULB :</dt>
<dd><?php
$links = array_map(
fn($n) => '<a href="/search?query=' .
urlencode($n) .
'">' .
htmlspecialchars($n) .
"</a>",
$promoteursUlb,
);
echo implode(", ", $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryPresidents)): ?>
<div>
<dt>Président·e du jury :</dt>
<dd><?php
$links = array_map(
fn($n) => '<a href="/search?query=' .
urlencode($n) .
'">' .
htmlspecialchars($n) .
"</a>",
$juryPresidents,
);
echo implode(", ", $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryLecteursInternes)): ?>
<div>
<dt>Lecteur·ice(s) interne :</dt>
<dd><?php
$links = array_map(
fn($n) => '<a href="/search?query=' .
urlencode($n) .
'">' .
htmlspecialchars($n) .
"</a>",
$juryLecteursInternes,
);
echo implode(", ", $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($juryLecteursExternes)): ?>
<div>
<dt>Lecteur·ice(s) externe :</dt>
<dd><?php
$links = array_map(
fn($n) => '<a href="/search?query=' .
urlencode($n) .
'">' .
htmlspecialchars($n) .
"</a>",
$juryLecteursExternes,
);
echo implode(", ", $links);
?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["access_type"])): ?>
<div>
<dt>Accès :</dt>
<dd><?= htmlspecialchars($data["access_type"]) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["license_type"])): ?>
<div>
<dt>Licence :</dt>
<dd><?= htmlspecialchars($data["license_type"]) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($data["context_note"])): ?>
<div class="tfe-meta-note">
<dt>Note :</dt>
<dd class="tfe-note-value"><?= nl2br(
htmlspecialchars($data["context_note"]),
) ?></dd>
</div>
<?php endif; ?>
<?php if (
!empty($data["contact_interne"]) &&
!empty($data["contact_public"])
): ?>
<div>
<dt>Contact :</dt>
<dd>
<?php
$_contact = $data["contact_interne"];
$_isUrl =
filter_var($_contact, FILTER_VALIDATE_URL) !==
false;
$_isEmail = !$_isUrl && str_contains($_contact, "@");
if ($_isUrl): ?>
<a href="<?= htmlspecialchars(
$_contact,
) ?>" target="_blank" rel="noopener">
<?= htmlspecialchars(
preg_replace(
"#^https?://#i",
"",
rtrim($_contact, "/"),
),
) ?>
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
<?php elseif ($_isEmail): ?>
<a href="<?= EmailObfuscator::mailto($_contact) ?>"><?= htmlspecialchars($_contact) ?></a>
<?php else: ?>
<?= htmlspecialchars($_contact) ?>
<?php endif;
?>
</dd>
</div>
<?php endif; ?>
<?php if (!empty($data["baiu_link"])): ?>
<?php
$_baiuHref = htmlspecialchars($data["baiu_link"]);
$_baiuLabel = preg_replace(
"#^https?://#i",
"",
rtrim($data["baiu_link"], "/"),
);
?>
<div>
<dt>Lien :</dt>
<dd>
<a href="<?= $_baiuHref ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($_baiuLabel) ?>
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
</dd>
</div>
<?php endif; ?>
</dl>
<?php if (!empty($data["synopsis"])): ?>
<p class="tfe-synopsis-text">
<?= nl2br(htmlspecialchars($data["synopsis"])) ?>
</p>
<?php endif; ?>
</section>
<!-- RIGHT: media — supplementary aside -->
<section class="tfe-right">
<?php $_videoIndex = 0; ?>
<?php if ($isInterdit): ?>
<p class="tfe-restricted">
Ce TFE n'est pas disponible en ligne.
</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="btn btn--primary 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;
});
function showRetryPrompt(rejectedEmail, serverMessage) {
messageDiv.style.display = 'block';
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.innerHTML =
'<strong>Adresse e-mail introuvable sur le serveur de l\'ERG.</strong><br>' +
'<small>' + serverMessage.replace(/</g, '&lt;') + '</small><br><br>' +
'Corrigez votre adresse e-mail et réessayez.';
// Highlight the email field and let the user fix it
emailInput.value = rejectedEmail;
emailInput.classList.add('input-error');
emailInput.focus();
emailInput.select();
// Remove error highlight once they start typing
emailInput.addEventListener('input', function clearError() {
emailInput.classList.remove('input-error');
emailInput.removeEventListener('input', clearError);
});
}
// 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 submittedEmail = emailInput.value.trim();
const formData = new FormData(form);
formData.append('thesis_id', '<?= $thesisId ?>');
fetch('/request-access', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
if (data.status === 'recipient_rejected') {
showRetryPrompt(submittedEmail, data.message);
return;
}
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 foreach ($data["files"] as $file): ?>
<?php
$ext = strtolower(pathinfo($file["file_path"] ?? '', PATHINFO_EXTENSION));
$fileType = $file["file_type"] ?? '';
// Skip helper/internal types
if ($ext === 'vtt' || $fileType === 'caption') continue;
if ($fileType === 'cover') continue;
// Determine display category
$isImage = in_array($ext, ['jpg','jpeg','png','gif','bmp','webp'], true) || $fileType === 'image';
$isVideo = in_array($ext, ['mp4','webm','mov','ogv'], true) || $fileType === 'video';
$isAudio = in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true) || $fileType === 'audio';
$isPdf = ($ext === 'pdf') || $fileType === 'main';
$isWebsite = ($fileType === 'website');
$isPeerTube = ($isExternalUrl && str_contains($filePath, '/videos/watch/'));
$isOther = !($isImage || $isVideo || $isAudio || $isPdf || $isWebsite);
$_vttPath = null;
if ($isVideo) {
$_vttPath = $captionFiles[$_videoIndex] ?? null;
$_videoIndex++;
}
$caption = !empty($file["display_label"]) ? $file["display_label"] : ($file["description"] ?? '');
$filePath = $file['file_path'] ?? '';
$isExternalUrl = str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://');
$mediaUrl = $isExternalUrl ? htmlspecialchars($filePath) : ('/media?path=' . urlencode($filePath));
$fileName = htmlspecialchars($file["file_name"] ?? basename($filePath));
?>
<figure>
<?php if ($isPdf): ?>
<iframe src="<?= $mediaUrl ?>"
width="100%" height="700px"
style="border:none"
title="<?= $fileName ?>">
</iframe>
<p class="tfe-pdf-fallback">
<a href="<?= $mediaUrl ?>&download=1">Télécharger le PDF</a>
</p>
<?php elseif ($isWebsite): ?>
<iframe src="<?= $mediaUrl ?>"
width="100%" height="700px"
style="border:none"
title="<?= $fileName ?>"
sandbox="allow-scripts allow-same-origin"
loading="lazy">
</iframe>
<p class="tfe-pdf-fallback">
<a href="<?= $mediaUrl ?>" target="_blank" rel="noopener">
Ouvrir le site dans un nouvel onglet
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
</p>
<?php elseif ($isImage): ?>
<img src="<?= $mediaUrl ?>"
alt="<?= htmlspecialchars($caption !== '' ? $caption : $data['title'] . ' — ' . ($data['authors'] ?? '')) ?>">
<?php elseif ($isVideo): ?>
<?php if ($isPeerTube): ?>
<iframe src="<?= $mediaUrl ?>embed"
width="100%" height="400px"
style="border:none"
title="<?= $fileName ?>"
sandbox="allow-same-origin allow-scripts"
allowfullscreen
loading="lazy">
</iframe>
<p class="tfe-pdf-fallback">
<a href="<?= $mediaUrl ?>" target="_blank" rel="noopener">
Ouvrir dans PeerTube
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
</p>
<?php else: ?>
<video width="100%" controls>
<source src="<?= $mediaUrl ?>" type="video/<?= htmlspecialchars($ext === 'mov' ? 'mp4' : $ext) ?>">
<?php if ($_vttPath): ?>
<track kind="captions"
src="/media?path=<?= urlencode($_vttPath) ?>"
srclang="fr" label="Sous-titres" default>
<?php endif; ?>
</video>
<?php endif; ?>
<?php elseif ($isAudio): ?>
<?php if ($isPeerTube): ?>
<iframe src="<?= $mediaUrl ?>embed"
width="100%" height="170px"
style="border:none"
title="<?= $fileName ?>"
sandbox="allow-same-origin allow-scripts"
loading="lazy">
</iframe>
<p class="tfe-pdf-fallback">
<a href="<?= $mediaUrl ?>" target="_blank" rel="noopener">
Ouvrir dans PeerTube
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
</p>
<?php else: ?>
<audio controls class="tfe-audio">
<source src="<?= $mediaUrl ?>" type="audio/<?= htmlspecialchars(match($ext) {
'mp3' => 'mpeg',
'ogg', 'oga' => 'ogg',
'wav' => 'wav',
'flac' => 'flac',
'aac' => 'aac',
'm4a' => 'mp4',
default => $ext,
}) ?>">
Votre navigateur ne supporte pas la lecture audio.
</audio>
<?php endif; ?>
<?php else: /* other — download only */ ?>
<div class="tfe-download-file">
<a href="<?= $mediaUrl ?>&download=1" class="tfe-download-link">
<span class="tfe-download-icon">📎</span>
<span><?= $fileName ?></span>
</a>
<?php if (!empty($file['file_size'])): ?>
<small class="tfe-download-size"><?= number_format($file['file_size'] / 1024 / 1024, 2) ?> MB</small>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($caption !== '' && !$isOther): ?>
<figcaption><?= htmlspecialchars($caption) ?></figcaption>
<?php endif; ?>
</figure>
<?php endforeach; ?>
<?php else: ?>
<p class="tfe-no-files">Aucun fichier disponible pour ce TFE.</p>
<?php endif; ?>
</section>
</article>
</main>