fix: markdown double-encoding and robust date/API access

This commit is contained in:
2026-07-27 01:55:21 +04:00
parent d9da213341
commit d81dd71a46
3 changed files with 156 additions and 36 deletions
+55 -25
View File
@@ -13,26 +13,30 @@
function markdown_to_html($markdown) {
// Échapper tout le contenu pour éviter les injections XSS
$markdown = htmlspecialchars($markdown, ENT_QUOTES, 'UTF-8');
// Tableau pour stocker les liens convertis
$links = [];
$link_count = 0;
// Note : le texte étant déjà échappé ci-dessus, les URLs extraites le sont
// aussi (« & » est devenu « & »). Il ne faut PAS les ré-échapper dans
// les callbacks ci-dessous, sinon on obtient un double encodage (« & »).
// Conversion des liens Markdown [texte](url)
$markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) {
$text = $matches[1];
$url = $matches[2];
// Assurer que l'URL est correctement formée
if (!preg_match('/^https?:\/\//i', $url)) {
// Ajouter http:// si l'URL ne commence pas par http:// ou https://
$url = 'http://' . $url;
}
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
$links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
@@ -41,29 +45,29 @@ function markdown_to_html($markdown) {
$protocolUrlPattern = '/(https?:\/\/[^\s<]+[^\s<\.)])/i';
$markdown = preg_replace_callback($protocolUrlPattern, function($matches) use (&$links, &$link_count) {
$url = $matches[1];
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
$links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
// 2. Domaines sans protocole (comme "o-k-i.net", "gong.gp", "NUVEL.NU")
// Détecte les domaines avec TLD communs qui ne font pas partie d'autre chose
$domainPattern = '/\b([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z]{2,63})\b/';
$markdown = preg_replace_callback($domainPattern, function($matches) use (&$links, &$link_count) {
$domain = $matches[0];
// Éviter de convertir des éléments qui ressemblent à des versions/numéros ou qui sont déjà dans des liens
if (preg_match('/^v?\d+\.\d+/', $domain) || strpos($domain, '___LINK_') !== false) {
return $domain;
}
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="http://' . htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
$links[$placeholder] = '<a href="http://' . $domain . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
@@ -71,19 +75,45 @@ function markdown_to_html($markdown) {
$markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown);
$markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown);
// Conversion des listes à puces
$markdown = preg_replace('/^- (.*?)$/m', '<li>$1</li>', $markdown);
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ul>$0</ul>', $markdown);
// Conversion des listes numérotées
$markdown = preg_replace('/^\d+\. (.*?)$/m', '<li>$1</li>', $markdown);
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ol>$0</ol>', $markdown);
// Conversion des listes (à puces et numérotées) en une seule passe ligne à
// ligne : les items consécutifs de même type forment une seule liste ; une
// ligne hors liste ou un changement de type ferme la liste courante.
$lines = explode("\n", $markdown);
$markdown = '';
$listType = null; // 'ul', 'ol' ou null (hors liste)
foreach ($lines as $line) {
$itemType = null;
$itemText = null;
if (preg_match('/^- (.*)$/', $line, $matches)) {
$itemType = 'ul';
$itemText = $matches[1];
} elseif (preg_match('/^\d+\. (.*)$/', $line, $matches)) {
$itemType = 'ol';
$itemText = $matches[1];
}
if ($itemType !== $listType) {
if ($listType !== null) {
$markdown .= '</' . $listType . ">\n";
}
if ($itemType !== null) {
$markdown .= '<' . $itemType . ">\n";
}
$listType = $itemType;
}
$markdown .= ($itemType !== null ? '<li>' . $itemText . '</li>' : $line) . "\n";
}
if ($listType !== null) {
$markdown .= '</' . $listType . ">\n";
}
$markdown = rtrim($markdown, "\n");
// Gestion des retours à la ligne
$markdown = nl2br($markdown);
// Nettoyage des balises br dans les listes
$markdown = preg_replace('/<\/li><br \/>/', '</li>', $markdown);
// Nettoyage des balises br autour des listes
$markdown = preg_replace('/(<\/li>|<ul>|<ol>)<br \/>/', '$1', $markdown);
// Restaurer les liens
foreach ($links as $placeholder => $link) {
+67
View File
@@ -56,6 +56,21 @@ assertEquals(
'formatDate affiche les années au pluriel'
);
// --- formatDate : dates malformées (repli sur la chaîne brute) ---------------
assertEquals(
'pas une date',
formatDate('pas une date'),
'formatDate retourne la chaîne brute si la date est invalide'
);
assertEquals(
'2024-13-45T99:99:99Z',
formatDate('2024-13-45T99:99:99Z'),
'formatDate retourne une date ISO malformée telle quelle'
);
assertEquals('', formatDate(''), 'formatDate retourne une chaîne vide telle quelle');
assertEquals(' ', formatDate(' '), 'formatDate retourne une chaîne blanche telle quelle');
// --- formatVideosData -------------------------------------------------------
$rawVideos = [
@@ -125,6 +140,58 @@ assertEquals('', $videos[1]['description'], 'formatVideosData met une descriptio
assertEquals([], $videos[1]['tags'], 'formatVideosData met des tags vides par défaut');
assertFalse($videos[1]['isLive'], 'formatVideosData met isLive à false par défaut');
// --- formatVideosData : données API incomplètes -------------------------------
$incompleteVideos = formatVideosData([
[
// Sans uuid : doit être ignorée
'name' => 'Sans uuid',
'duration' => 10,
],
[
// uuid vide : doit être ignorée aussi
'uuid' => '',
'name' => 'Uuid vide',
],
[
// uuid seul : valeurs par défaut partout ailleurs
'uuid' => 'ghi-789',
],
[
// channel présent mais sans displayName ni avatars
'uuid' => 'jkl-012',
'channel' => ['name' => 'compte'],
],
]);
assertEquals(2, count($incompleteVideos), 'formatVideosData ignore les entrées sans uuid');
assertEquals('ghi-789', $incompleteVideos[0]['id'], 'formatVideosData conserve l\'uuid seul');
assertEquals('', $incompleteVideos[0]['title'], 'formatVideosData met un titre vide par défaut');
assertEquals(0, $incompleteVideos[0]['duration'], 'formatVideosData met une durée à 0 par défaut');
assertEquals('', $incompleteVideos[0]['channel'], 'formatVideosData met une chaîne vide par défaut');
assertEquals(0, $incompleteVideos[0]['views'], 'formatVideosData met les vues à 0 par défaut');
assertEquals('', $incompleteVideos[0]['date'], 'formatVideosData met une date vide par défaut');
assertNull($incompleteVideos[0]['aspectRatio'], 'formatVideosData met aspectRatio à null par défaut');
assertFalse($incompleteVideos[0]['isLive'], 'formatVideosData met isLive à false par défaut');
assertEquals(
'img/default-thumbnail.jpg',
$incompleteVideos[0]['thumbnail'],
'formatVideosData met la vignette par défaut pour une entrée minimale'
);
assertEquals(
'img/default-avatar.png',
$incompleteVideos[0]['channelAvatar'],
'formatVideosData met l\'avatar par défaut pour une entrée minimale'
);
assertEquals('', $incompleteVideos[1]['channel'], 'formatVideosData met une chaîne vide si displayName absent');
assertEquals(
'img/default-avatar.png',
$incompleteVideos[1]['channelAvatar'],
'formatVideosData met l\'avatar par défaut si le tableau avatars est absent'
);
// --- truncateText (includes/structured-data.php) ----------------------------
assertEquals('court', truncateText('court', 200), 'truncateText laisse un texte court intact');
+34 -11
View File
@@ -1,9 +1,6 @@
<?php
/**
* Tests unitaires pour markdown_to_html (includes/lib/markdown.php)
*
* Les sorties attendues reflètent le comportement actuel de la fonction,
* y compris ses particularités (voir la note sur les listes à puces).
*/
// --- Échappement XSS ---------------------------------------------------------
@@ -63,21 +60,47 @@ assertEquals(
'markdown_to_html ne transforme pas un numéro de version en lien'
);
// --- Listes ------------------------------------------------------------------
// --- URLs contenant « & » : pas de double encodage ---------------------------
// Note : la passe des listes numérotées s'applique aussi aux <li> déjà
// produits par la passe des puces, d'où un double enveloppement <ul><ol>.
// C'est le comportement actuel, verrouillé ici contre toute régression.
// Le texte est échappé une seule fois : « & » devient « &amp; », jamais « &amp;amp; »
assertEquals(
"<ul><ol><li>a</li>\n<li>b</li></ol></ul>",
markdown_to_html("- a\n- b"),
'markdown_to_html convertit les listes à puces (double enveloppement actuel)'
'Voir <a href="https://exemple.com/page?a=1&amp;b=2" target="_blank" rel="noopener noreferrer">https://exemple.com/page?a=1&amp;b=2</a> suite',
markdown_to_html('Voir https://exemple.com/page?a=1&b=2 suite'),
'markdown_to_html n\'encode pas deux fois le & des URLs brutes'
);
assertEquals(
"<ol><li>a</li>\n<li>b</li></ol>",
'<a href="https://exemple.com/?x=1&amp;y=2" target="_blank" rel="noopener noreferrer">lien</a>',
markdown_to_html('[lien](https://exemple.com/?x=1&y=2)'),
'markdown_to_html n\'encode pas deux fois le & des liens Markdown'
);
assertNotContains(
'&amp;amp;',
markdown_to_html('https://exemple.com/page?a=1&b=2'),
'markdown_to_html ne produit jamais de &amp;amp;'
);
// --- Listes ------------------------------------------------------------------
assertEquals(
"<ul>\n<li>a</li>\n<li>b</li>\n</ul>",
markdown_to_html("- a\n- b"),
'markdown_to_html convertit les listes à puces en <ul> uniquement'
);
assertEquals(
"<ol>\n<li>a</li>\n<li>b</li>\n</ol>",
markdown_to_html("1. a\n2. b"),
'markdown_to_html convertit les listes numérotées en <ol>'
);
assertEquals(
"<ul>\n<li>a</li>\n</ul><br />\n<ol>\n<li>b</li>\n</ol><br />\n<ul>\n<li>c</li>\n</ul>",
markdown_to_html("- a\n1. b\n- c"),
'markdown_to_html sépare les listes de types différents'
);
assertEquals(
"<ul>\n<li>a</li>\n</ul><br />\ntexte",
markdown_to_html("- a\ntexte"),
'markdown_to_html ferme la liste avant le texte qui suit'
);
// --- Retours à la ligne ------------------------------------------------------