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).
This commit is contained in:
Pontoporeia
2026-05-10 14:51:37 +02:00
parent ab6e266807
commit 38dc8de9d8
14 changed files with 159 additions and 19 deletions

View File

@@ -2,6 +2,7 @@
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
require_once __DIR__ . '/../../src/ShareLink.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
App::adminGuard();

View File

@@ -8,6 +8,7 @@
* /partage/recapitulatif.php?id=N - Post-submission confirmation page
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
// Parse the requested path from REQUEST_URI
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
@@ -380,7 +381,7 @@ function renderShareLinkForm(string $slug, array $link): void
<div class="share-help-contact">
<p>Des questions ou un problème avec le formulaire ?
<a href="mailto:xamxam@erg.be">xamxam@erg.be</a></p>
<a href="<?= EmailObfuscator::mailto('xamxam@erg.be') ?>"><?= EmailObfuscator::email('xamxam@erg.be') ?></a></p>
</div>
<?php
@@ -527,8 +528,8 @@ function handleShareLinkSubmission(string $slug): void
// Repopulate the form and surface a clear warning to the student.
// Store as plain text — htmlspecialchars() is applied at render time.
$_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré.'
. "\n" . $e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')'
. "\nSi vous pensez qu'il s'agit d'une erreur, vous pouvez contacter l'équipe à xamxam@erg.be.";
. "\n" . $e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')';
$_SESSION['_flash_contact'] = true;
$_SESSION['form_data_share_' . $slug] = $_POST;
storePrimedFiles($slug);
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token
@@ -544,8 +545,8 @@ function handleShareLinkSubmission(string $slug): void
]);
ErrorHandler::log('partage_submit', $e, ['slug' => $slug, 'author' => $authorName]);
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e)
. "\n\nSi le problème persiste, envoyez un e-mail à xamxam@erg.be.";
$_SESSION['_flash_error'] = ErrorHandler::userMessage($e);
$_SESSION['_flash_contact'] = true;
$_SESSION['form_data_share_' . $slug] = $_POST;
storePrimedFiles($slug);
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token

View File

@@ -3,6 +3,7 @@
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
class AboutController
{
@@ -35,7 +36,7 @@ class AboutController
return [
'currentNav' => 'apropos',
'aboutHtml' => $pd->text($rawContent),
'aboutHtml' => EmailObfuscator::obfuscateHtml($pd->text($rawContent)),
'contacts' => $contacts,
'pageTitle' => 'À Propos XAMXAM',
'metaDescription' => "À propos de XAMXAM, le répertoire des mémoires de fin d'études de l'erg École de Recherches Graphiques de Bruxelles.",

View File

@@ -3,6 +3,7 @@
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Parsedown.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
class LicenceController
{
@@ -26,7 +27,7 @@ class LicenceController
$pd = new Parsedown();
$pd->setSafeMode(true);
$html = $pd->text($content);
$html = EmailObfuscator::obfuscateHtml($pd->text($content));
return [
'content' => $content,

View File

@@ -185,6 +185,8 @@ class Dispatcher
*/
private function render(string $view, array $vars): void
{
require_once APP_ROOT . '/src/EmailObfuscator.php';
$viewPath = APP_ROOT . '/templates/' . $view . '.php';
if (!file_exists($viewPath)) {
http_response_code(500);

119
app/src/EmailObfuscator.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
/**
* EmailObfuscator
*
* Obfuscates email addresses as HTML decimal entities so they render
* correctly in browsers but are invisible to most bots and scrapers.
*
* Example:
* obfuscateEmail('foo@example.com')
* → &#102;&#111;&#111;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;
*
* obfuscateMailto('foo@example.com')
* → &#109;&#97;&#105;&#108;&#116;&#111;&#58;&#102;&#111;&#111;&#64;&#101;&#120;...
* (usable inside href="...")
*
* obfuscateEmailText('Contact xamxam@erg.be for help')
* → 'Contact &#120;&#97;...@&#101;&#114;... for help'
* (replaces email addresses found in plain text)
*
* Usage:
* require_once APP_ROOT . '/src/EmailObfuscator.php';
* echo '<a href="' . EmailObfuscator::mailto('name@domain.com') . '">' . EmailObfuscator::email('name@domain.com') . '</a>';
*/
class EmailObfuscator
{
/**
* Obfuscate an email address for display in HTML content.
*/
public static function email(string $address): string
{
return self::encode($address);
}
/**
* Obfuscate a mailto: link for use in an href attribute.
* Returns 'mailto:' followed by the obfuscated email.
*/
public static function mailto(string $address): string
{
return 'mailto:' . self::encode($address);
}
/**
* Replace plain-text email addresses in a string with obfuscated versions.
* Only replaces addresses that appear as bare text (not already inside HTML entities).
*/
public static function emailText(string $text): string
{
return preg_replace_callback(
'/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/',
fn(array $m) => self::encode($m[0]),
$text
);
}
/**
* Obfuscate a mailto: URL found in text (e.g. from Markdown or user input).
* Matches 'mailto:user@domain.com' pattern.
*/
public static function mailtoInText(string $text): string
{
return preg_replace_callback(
'/mailto:([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/i',
fn(array $m) => 'mailto:' . self::encode($m[1]),
$text
);
}
/**
* Post-process rendered HTML: obfuscate all mailto: links and their
* visible text (email addresses) so bots can't scrape them.
*
* Matches patterns like:
* <a href="mailto:foo@bar.com">foo@bar.com</a>
* <a href="mailto:foo@bar.com">some text</a>
*
* In the second case, only the href is obfuscated (the link text is kept).
*/
public static function obfuscateHtml(string $html): string
{
// Match <a> tags whose href starts with mailto:
return preg_replace_callback(
'/<a\s[^>]*href="mailto:([^"]+)"[^>]*>(.*?)<\/a>/is',
function (array $m): string {
$email = $m[1];
$linkText = $m[2];
$obfuscated = self::encode($email);
// If the link text is the email itself, replace it too
$text = strip_tags($linkText);
if ($text === $email) {
$linkText = self::encode($linkText);
}
// Rebuild the tag with obfuscated values
return str_replace(
['mailto:' . $email, '>' . $m[2] . '<'],
['mailto:' . $obfuscated, '>' . $linkText . '<'],
$m[0]
);
},
$html
);
}
// ── Private ───────────────────────────────────────────────────────────
private static function encode(string $s): string
{
$out = '';
$len = strlen($s);
for ($i = 0; $i < $len; $i++) {
$out .= '&#' . ord($s[$i]) . ';';
}
return $out;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -181,6 +181,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: kvyyvksn c5873f06 "fix: add help email, preserve file names on validation error, license fix" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: kvyyvksn c5873f06 "fix: add help email, preserve file names on validation error, license fix" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: roqtyzln d714ae9b "feat: obfuscate all email addresses and mailto links as HTML entities" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: roqtyzln 34d91340 "feat: obfuscate all email addresses and mailto links as HTML entities" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?>
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
@@ -367,9 +380,7 @@
<div class="access-req-card__info">
<div>
<strong>Email :</strong>
<a href="mailto:<?= htmlspecialchars($req['email']) ?>">
<?= htmlspecialchars($req['email']) ?>
</a>
<a href="<?= EmailObfuscator::mailto($req['email']) ?>"><?= htmlspecialchars($req['email']) ?></a>
</div>
<div>
<strong>Date :</strong>

View File

@@ -59,9 +59,7 @@
<div class="access-req-card__info">
<div>
<strong>Email :</strong>
<a href="mailto:<?= htmlspecialchars($req['email']) ?>">
<?= htmlspecialchars($req['email']) ?>
</a>
<a href="<?= EmailObfuscator::mailto($req['email']) ?>"><?= htmlspecialchars($req['email']) ?></a>
</div>
<div>
<strong>Date :</strong>

View File

@@ -110,10 +110,12 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$flashError = $_SESSION["_flash_error"] ?? null;
$flashWarning = $_SESSION["_flash_warning"] ?? null;
$flashSuccess = $_SESSION["_flash_success"] ?? null;
$flashContact = $_SESSION["_flash_contact"] ?? false;
unset(
$_SESSION["_flash_error"],
$_SESSION["_flash_warning"],
$_SESSION["_flash_success"],
$_SESSION["_flash_contact"],
);
?>
<?php if ($flashError): ?>
@@ -127,6 +129,13 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
) ?></div>
<script>document.addEventListener('DOMContentLoaded',function(){var el=document.getElementById('flash-warning');if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.focus();}});</script>
<?php endif; ?>
<?php if ($flashContact && $mode === 'partage'): ?>
<?php require_once APP_ROOT . '/src/EmailObfuscator.php'; ?>
<div class="flash-info" role="alert">
Si le problème persiste, envoyez un e-mail à
<a href="<?= EmailObfuscator::mailto('xamxam@erg.be') ?>"><?= EmailObfuscator::email('xamxam@erg.be') ?></a>.
</div>
<?php endif; ?>
<?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars(
$flashSuccess,

View File

@@ -83,9 +83,7 @@ function renderEntries(array $entries): string
fn($e) => !empty($e),
);
foreach ($emails as $email): ?>
<a href="mailto:<?= htmlspecialchars(
$email,
) ?>"><?= htmlspecialchars($email) ?></a>
<a href="<?= EmailObfuscator::mailto($email) ?>"><?= htmlspecialchars($email) ?></a>
<?php endforeach;
?>
</address>

View File

@@ -252,9 +252,7 @@
<span class="sr-only">(ouvre dans un nouvel onglet)</span>
</a>
<?php elseif ($_isEmail): ?>
<a href="mailto:<?= htmlspecialchars(
$_contact,
) ?>"><?= htmlspecialchars($_contact) ?></a>
<a href="<?= EmailObfuscator::mailto($_contact) ?>"><?= htmlspecialchars($_contact) ?></a>
<?php else: ?>
<?= htmlspecialchars($_contact) ?>
<?php endif;