fix(security): systematic output escaping with e() and video-card partial

This commit is contained in:
2026-07-27 01:54:42 +04:00
parent 442c262539
commit a28ba8bfec
8 changed files with 300 additions and 185 deletions
+1 -19
View File
@@ -97,25 +97,7 @@ switch ($type) {
$html = ''; $html = '';
foreach ($videos as $video) { foreach ($videos as $video) {
$html .= '<div class="video-card" data-video-id="' . htmlspecialchars($video['id']) . '">'; $html .= renderVideoCard($video);
$html .= ' <div class="video-thumbnail">';
$html .= ' <img src="' . htmlspecialchars($video['thumbnail']) . '" alt="' . htmlspecialchars($video['title']) . '">';
$html .= ' <div class="video-play-icon">';
$html .= ' <i class="fas fa-play-circle"></i>';
$html .= ' </div>';
$html .= ' <div class="video-duration">' . formatDuration($video['duration']) . '</div>';
$html .= ' </div>';
$html .= ' <div class="video-info">';
$html .= ' <h3 class="video-title">' . htmlspecialchars($video['title']) . '</h3>';
$html .= ' <div class="video-channel">' . htmlspecialchars($video['channel']) . '</div>';
$html .= ' <div class="video-metadata">';
if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS) {
$html .= ' <span class="video-views"><i class="fas fa-eye"></i> ' . formatViewCount($video['views']) . ' vues</span>';
}
$html .= ' <span class="video-date"><i class="far fa-calendar-alt"></i> ' . formatDate($video['date']) . '</span>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
} }
// Retourner la réponse // Retourner la réponse
+4 -31
View File
@@ -59,8 +59,8 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta property="og:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>"> <meta property="og:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
<meta property="og:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif."> <meta property="og:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>"> <meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>"> <meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>"> <meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR"> <meta property="og:locale" content="fr_FR">
@@ -69,7 +69,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>"> <meta name="twitter:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif."> <meta name="twitter:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>"> <meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<!-- Données structurées JSON-LD pour la page de catégorie --> <!-- Données structurées JSON-LD pour la page de catégorie -->
<?php <?php
@@ -127,34 +127,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<?php else: ?> <?php else: ?>
<div class="video-grid category-videos"> <div class="video-grid category-videos">
<?php foreach ($videos as $video): ?> <?php foreach ($videos as $video): ?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>"> <?php echo renderVideoCard($video); ?>
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<span class="video-duration"><?php echo formatDuration($video['duration']); ?></span>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo htmlspecialchars($video['channel']); ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
+8 -3
View File
@@ -45,7 +45,7 @@ if ($heroType === 'none') {
</div> </div>
<div class="hero-video-container"> <div class="hero-video-container">
<iframe <iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1&muted=1" src="<?php echo e(PEERTUBE_URL . '/videos/embed/' . $liveStream['id'] . '?autoplay=1&muted=1'); ?>"
frameborder="0" frameborder="0"
allowfullscreen="allowfullscreen" allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen" allow="autoplay; fullscreen"
@@ -64,7 +64,7 @@ if ($heroType === 'none') {
<i class="fas fa-user-circle" aria-hidden="true"></i> <i class="fas fa-user-circle" aria-hidden="true"></i>
</div> </div>
<?php else: ?> <?php else: ?>
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar"> <img src="<?php echo e($liveStream['channelAvatar']); ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar">
<?php endif; ?> <?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span> <span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span>
</div> </div>
@@ -82,7 +82,12 @@ if ($heroType === 'none') {
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');'; $bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
} }
?> ?>
<div class="hero-next-live" style="<?php echo $bgImageStyle; ?>" nonce="<?php echo getCspNonce(); ?>"> <?php if (!empty($bgImageStyle)): ?>
<style nonce="<?php echo getCspNonce(); ?>">
.hero-next-live { <?php echo $bgImageStyle; ?> }
</style>
<?php endif; ?>
<div class="hero-next-live">
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?> <?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="hero-next-live-image-container"> <div class="hero-next-live-image-container">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>" <img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Partial de rendu d'une carte vidéo.
*
* Centralise le balisage des cartes vidéo (accueil, catégories, recherche,
* endpoint AJAX « Voir plus ») afin de garantir un échappement systématique
* des données issues de l'API PeerTube (titres, chaînes, vignettes, avatars).
*/
/**
* Génère le HTML d'une carte vidéo, entièrement échappé.
*
* @param array $video Données formatées de la vidéo (voir formatVideosData())
* @return string HTML de la carte
*/
function renderVideoCard(array $video) {
$id = e($video['id'] ?? '');
$title = e($video['title'] ?? '');
$thumbnail = e($video['thumbnail'] ?? '');
$duration = e(formatDuration($video['duration'] ?? 0));
$channel = e($video['channel'] ?? '');
$channelAvatar = (string) ($video['channelAvatar'] ?? '');
$views = e(formatViewCount($video['views'] ?? 0));
$date = e(formatDate($video['date'] ?? ''));
ob_start();
?>
<article class="video-card" data-video-id="<?php echo $id; ?>">
<div class="video-thumbnail">
<img src="<?php echo $thumbnail; ?>" alt="<?php echo $title; ?>">
<div class="video-play-icon" aria-hidden="true">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration"><?php echo $duration; ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $title; ?></h3>
<div class="video-channel">
<?php if ($channelAvatar === '' || strpos($channelAvatar, 'default-avatar') !== false): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo e($channelAvatar); ?>" alt="<?php echo $channel; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $channel; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo $views; ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo $date; ?></span>
</div>
</div>
</article>
<?php
return ob_get_clean();
}
+90 -2
View File
@@ -3,6 +3,20 @@
* Fonctions de sécurité pour la validation et l'assainissement des entrées * Fonctions de sécurité pour la validation et l'assainissement des entrées
*/ */
/**
* Échappe une valeur pour une sortie HTML (texte ou attribut).
*
* Raccourci pour htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8') :
* les guillemets simples et doubles sont encodés, ce qui rend la sortie
* sûre aussi bien dans le contenu que dans les attributs.
*
* @param mixed $value Valeur à échapper
* @return string Valeur échappée
*/
function e($value) {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/** /**
* Valide et assainit un ID de vidéo UUID * Valide et assainit un ID de vidéo UUID
* *
@@ -141,6 +155,44 @@ function validateHttpHeaders() {
return true; return true;
} }
/**
* Valeur par défaut livrée dans config.default.php : si CSRF_SECRET vaut
* encore cette valeur, le secret n'a pas été configuré pour l'instance.
*/
if (!defined('CSRF_SECRET_PLACEHOLDER')) {
define('CSRF_SECRET_PLACEHOLDER', 'change-me-in-config-local-php');
}
/**
* Retourne le secret CSRF effectif utilisé pour signer les tokens.
*
* Si CSRF_SECRET est absent, vide ou vaut encore la valeur par défaut, un
* avertissement critique est enregistré et un secret éphémère propre au
* processus est généré (bin2hex(random_bytes(32))) : les tokens restent
* signés, mais sont invalidés à chaque redémarrage du processus PHP.
*
* @return string Secret CSRF effectif
*/
function getCsrfSecret() {
static $secret = null;
if ($secret !== null) {
return $secret;
}
if (defined('CSRF_SECRET') && CSRF_SECRET !== '' && CSRF_SECRET !== CSRF_SECRET_PLACEHOLDER) {
$secret = CSRF_SECRET;
return $secret;
}
error_log('SECURITY CRITICAL: CSRF_SECRET is not configured (default value in use). '
. 'An ephemeral per-process secret was generated: CSRF tokens will be invalidated '
. 'on every process restart. Set CSRF_SECRET in config.local.php (bin2hex(random_bytes(32))).');
$secret = bin2hex(random_bytes(32));
return $secret;
}
/** /**
* Génère un token CSRF stateless (HMAC + timestamp). * Génère un token CSRF stateless (HMAC + timestamp).
* *
@@ -152,7 +204,7 @@ function validateHttpHeaders() {
*/ */
function generateCSRFToken() { function generateCSRFToken() {
$timestamp = time(); $timestamp = time();
$hash = hash_hmac('sha256', (string) $timestamp, CSRF_SECRET); $hash = hash_hmac('sha256', (string) $timestamp, getCsrfSecret());
return $timestamp . ':' . $hash; return $timestamp . ':' . $hash;
} }
@@ -183,7 +235,7 @@ function validateCSRFToken($token) {
return false; return false;
} }
$expectedHash = hash_hmac('sha256', $timestamp, CSRF_SECRET); $expectedHash = hash_hmac('sha256', $timestamp, getCsrfSecret());
return hash_equals($expectedHash, $hash); return hash_equals($expectedHash, $hash);
} }
@@ -347,4 +399,40 @@ function validateAjaxOrigin() {
return false; return false;
} }
/**
* Valide une URL distante (PeerTube, Castopod, Funkwhale…) pour prévenir
* les attaques SSRF avant tout appel sortant.
*
* @param string $url URL à valider
* @return bool True si l'URL est valide et sûre
*/
function isValidRemoteUrl($url) {
// Vérifier que l'URL est bien formée
$parsed = parse_url($url);
if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) {
return false;
}
// Autoriser uniquement HTTPS (ou HTTP en développement)
if (!in_array($parsed['scheme'], ['https', 'http'])) {
return false;
}
// Bloquer les adresses IP privées et locales
$host = $parsed['host'];
if (filter_var($host, FILTER_VALIDATE_IP)) {
if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false;
}
}
// Bloquer localhost et autres domaines dangereux
$blockedHosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', 'metadata.google.internal'];
if (in_array(strtolower($host), $blockedHosts)) {
return false;
}
return true;
}
?> ?>
+12 -99
View File
@@ -108,7 +108,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
<!-- ------ Script Plausible ------ --> <!-- ------ Script Plausible ------ -->
<!-- <script defer data-domain="<?php echo $_SERVER['HTTP_HOST'] ?>" src="https://plausible.io/js/script.hash.outbound-links.pageview-props.tagged-events.js"></script> --> <!-- <script defer data-domain="<?php echo htmlspecialchars(APP_HOST_NAME); ?>" src="https://plausible.io/js/script.hash.outbound-links.pageview-props.tagged-events.js"></script> -->
<!-- <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script> --> <!-- <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script> -->
<!-- ------ Script Plausible ------ --> <!-- ------ Script Plausible ------ -->
@@ -421,9 +421,9 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
foreach ($shorts as $video): foreach ($shorts as $video):
?> ?>
<div class="carousel-item"> <div class="carousel-item">
<article class="video-card short-card" data-video-id="<?php echo $video['id']; ?>"> <article class="video-card short-card" data-video-id="<?php echo e($video['id']); ?>">
<div class="video-thumbnail short-thumbnail"> <div class="video-thumbnail short-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>"> <img src="<?php echo e($video['thumbnail']); ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>"> <div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
<?php echo formatDuration($video['duration']); ?> <?php echo formatDuration($video['duration']); ?>
</div> </div>
@@ -474,38 +474,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>'; echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
} else { } else {
foreach ($recentVideos as $video): foreach ($recentVideos as $video):
?> echo renderVideoCard($video);
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon" aria-hidden="true">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
<?php echo formatDuration($video['duration']); ?>
</div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $video['channel']; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</article>
<?php
endforeach; endforeach;
} }
?> ?>
@@ -536,36 +505,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>'; echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
} else { } else {
foreach ($trendingVideos as $video): foreach ($trendingVideos as $video):
?> echo renderVideoCard($video);
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo htmlspecialchars($video['thumbnail']); ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $video['title']; ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $video['channel']; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</article>
<?php
endforeach; endforeach;
} }
?> ?>
@@ -586,45 +526,18 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
foreach ($displayCategories as $category): foreach ($displayCategories as $category):
if (!empty($category['videos'])): if (!empty($category['videos'])):
?> ?>
<!-- Section Catégorie: <?php echo $category['name']; ?> --> <!-- Section Catégorie: <?php echo e($category['name']); ?> -->
<section class="video-section" data-category-id="<?php echo $category['id']; ?>" aria-labelledby="category-heading-<?php echo $category['id']; ?>"> <section class="video-section" data-category-id="<?php echo e($category['id']); ?>" aria-labelledby="category-heading-<?php echo e($category['id']); ?>">
<header class="section-header"> <header class="section-header">
<div class="section-logo"> <div class="section-logo">
<img src="img/logo.png" alt="" aria-hidden="true"> <img src="img/logo.png" alt="" aria-hidden="true">
</div> </div>
<h2 id="category-heading-<?php echo $category['id']; ?>" class="section-title"><?php echo htmlspecialchars($category['name']); ?></h2> <h2 id="category-heading-<?php echo e($category['id']); ?>" class="section-title"><?php echo htmlspecialchars($category['name']); ?></h2>
</header> </header>
<div class="video-grid"> <div class="video-grid">
<?php foreach ($category['videos'] as $video): ?> <?php foreach ($category['videos'] as $video): ?>
<article class="video-card" data-video-id="<?php echo $video['id']; ?>"> <?php echo renderVideoCard($video); ?>
<div class="video-thumbnail">
<img src="<?php echo htmlspecialchars($video['thumbnail']); ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $video['title']; ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $video['channel']; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</article>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -707,7 +620,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
// Afficher le bouton d'installation s'il existe // Afficher le bouton d'installation s'il existe
if (installButton) { if (installButton) {
installButton.style.display = 'block'; installButton.classList.remove('is-hidden');
installButton.addEventListener('click', function() { installButton.addEventListener('click', function() {
deferredPrompt.prompt(); deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) { deferredPrompt.userChoice.then(function(choiceResult) {
@@ -715,7 +628,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
console.log('PWA installée'); console.log('PWA installée');
} }
deferredPrompt = null; deferredPrompt = null;
installButton.style.display = 'none'; installButton.classList.add('is-hidden');
}); });
}); });
} }
@@ -725,7 +638,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
window.addEventListener('appinstalled', function() { window.addEventListener('appinstalled', function() {
console.log('PWA installée avec succès'); console.log('PWA installée avec succès');
if (installButton) { if (installButton) {
installButton.style.display = 'none'; installButton.classList.add('is-hidden');
} }
}); });
</script> </script>
+4 -31
View File
@@ -74,8 +74,8 @@ if ($resultsCount > 0) {
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta property="og:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>"> <meta property="og:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
<meta property="og:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour « ' . htmlspecialchars($query) . ' » sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. ' . SITE_DESCRIPTION; ?>"> <meta property="og:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour « ' . htmlspecialchars($query) . ' » sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. ' . SITE_DESCRIPTION; ?>">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>"> <meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>"> <meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>"> <meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR"> <meta property="og:locale" content="fr_FR">
@@ -84,7 +84,7 @@ if ($resultsCount > 0) {
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>"> <meta name="twitter:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour « ' . htmlspecialchars($query) . ' » sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. ' . SITE_DESCRIPTION; ?>"> <meta name="twitter:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour « ' . htmlspecialchars($query) . ' » sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. ' . SITE_DESCRIPTION; ?>">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>"> <meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<?php if (!empty($query) && !empty($currentPageVideos)): ?> <?php if (!empty($query) && !empty($currentPageVideos)): ?>
<!-- Données structurées JSON-LD pour la page de recherche --> <!-- Données structurées JSON-LD pour la page de recherche -->
@@ -160,34 +160,7 @@ if ($resultsCount > 0) {
<div class="video-grid category-videos"> <div class="video-grid category-videos">
<?php foreach ($currentPageVideos as $video): ?> <?php foreach ($currentPageVideos as $video): ?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>"> <?php echo renderVideoCard($video); ?>
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<span class="video-duration"><?php echo formatDuration($video['duration']); ?></span>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo htmlspecialchars($video['channel']); ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* Tests unitaires pour le helper e() (includes/security.php)
* et le partial renderVideoCard() (includes/partials/video-card.php)
*
* Non-régression XSS (SEC-1) : les données de l'API PeerTube (titres,
* chaînes, vignettes, avatars) ne doivent jamais ressortir telles quelles
* dans le HTML d'une carte vidéo.
*/
// --- e() ---------------------------------------------------------------------
assertEquals(
'&lt;script&gt;alert(1)&lt;/script&gt;',
e('<script>alert(1)</script>'),
'e() échappe les balises HTML'
);
assertEquals(
'&quot;guillemets&quot; &#039;apostrophes&#039;',
e('"guillemets" \'apostrophes\''),
'e() échappe guillemets et apostrophes (ENT_QUOTES)'
);
assertEquals('&amp;', e('&'), 'e() échappe l\'esperluette');
assertEquals('', e(null), 'e() convertit null en chaîne vide');
assertEquals('42', e(42), 'e() convertit les nombres en chaîne');
// --- renderVideoCard : structure de base -------------------------------------
$video = [
'id' => 'abc-123',
'title' => 'Ma vidéo',
'thumbnail' => 'https://videos.example/lazy/abc.jpg',
'duration' => 125,
'channel' => 'Ma chaîne',
'channelAvatar' => 'https://videos.example/avatars/a.png',
'views' => 42,
'date' => date('Y-m-d H:i:s'),
];
$html = renderVideoCard($video);
assertContains('class="video-card"', $html, 'renderVideoCard génère une carte vidéo');
assertContains('data-video-id="abc-123"', $html, 'renderVideoCard expose l\'identifiant vidéo');
assertContains('src="https://videos.example/lazy/abc.jpg"', $html, 'renderVideoCard affiche la vignette');
assertContains('<h3 class="video-title">Ma vidéo</h3>', $html, 'renderVideoCard affiche le titre');
assertContains('<span class="channel-name">Ma chaîne</span>', $html, 'renderVideoCard affiche la chaîne');
assertContains('class="channel-avatar"', $html, 'renderVideoCard affiche l\'avatar personnalisé');
assertContains('2:05', $html, 'renderVideoCard affiche la durée formatée');
// --- renderVideoCard : avatar par défaut --------------------------------------
$videoDefaultAvatar = $video;
$videoDefaultAvatar['channelAvatar'] = 'img/default-avatar.png';
$htmlDefault = renderVideoCard($videoDefaultAvatar);
assertContains('channel-avatar-placeholder', $htmlDefault, 'renderVideoCard utilise un placeholder pour l\'avatar par défaut');
assertNotContains('class="channel-avatar"', $htmlDefault, 'renderVideoCard n\'affiche pas l\'image d\'avatar par défaut');
$videoEmptyAvatar = $video;
$videoEmptyAvatar['channelAvatar'] = '';
assertContains(
'channel-avatar-placeholder',
renderVideoCard($videoEmptyAvatar),
'renderVideoCard utilise un placeholder si l\'avatar est vide'
);
// --- renderVideoCard : échappement XSS (SEC-1) ---------------------------------
$maliciousTitle = '"><img src=x onerror=alert(1)><script>alert(2)</script>';
$maliciousChannel = '<svg onload=alert(3)>';
$maliciousThumbnail = 'https://videos.example/x.jpg" onerror="alert(4)';
$maliciousAvatar = 'https://videos.example/a.png\' onerror=\'alert(5)';
$htmlXss = renderVideoCard([
'id' => $maliciousTitle,
'title' => $maliciousTitle,
'thumbnail' => $maliciousThumbnail,
'duration' => 60,
'channel' => $maliciousChannel,
'channelAvatar' => $maliciousAvatar,
'views' => 1,
'date' => date('Y-m-d H:i:s'),
]);
assertNotContains($maliciousTitle, $htmlXss, 'renderVideoCard ne ressort pas le titre brut');
assertNotContains($maliciousChannel, $htmlXss, 'renderVideoCard ne ressort pas la chaîne brute');
assertNotContains($maliciousThumbnail, $htmlXss, 'renderVideoCard ne ressort pas la vignette brute');
assertNotContains($maliciousAvatar, $htmlXss, 'renderVideoCard ne ressort pas l\'avatar brut');
assertNotContains('<script>', $htmlXss, 'renderVideoCard ne génère aucune balise script injectée');
assertNotContains('onerror="', $htmlXss, 'renderVideoCard ne laisse passer aucun attribut d\'événement actif');
assertContains(
htmlspecialchars($maliciousTitle, ENT_QUOTES, 'UTF-8'),
$htmlXss,
'renderVideoCard affiche le titre échappé'
);
assertContains(
htmlspecialchars($maliciousChannel, ENT_QUOTES, 'UTF-8'),
$htmlXss,
'renderVideoCard affiche la chaîne échappée'
);
// L'échappement ENT_QUOTES doit aussi protéger les attributs à apostrophes
$htmlAttr = renderVideoCard([
'id' => 'xyz',
'title' => "Titre avec 'apostrophe'",
'thumbnail' => 'https://videos.example/x.jpg',
'duration' => 10,
'channel' => 'Chaîne',
'channelAvatar' => 'img/default-avatar.png',
'views' => 0,
'date' => date('Y-m-d H:i:s'),
]);
assertContains('&#039;', $htmlAttr, 'renderVideoCard échappe les apostrophes dans les attributs');
assertNotContains("'apostrophe'", $htmlAttr, 'renderVideoCard ne laisse pas d\'apostrophe brute');
// --- renderVideoCard : données incomplètes --------------------------------------
$htmlMinimal = renderVideoCard(['id' => 'solo-1']);
assertContains('class="video-card"', $htmlMinimal, 'renderVideoCard tolère une vidéo quasi vide');
assertContains('data-video-id="solo-1"', $htmlMinimal, 'renderVideoCard conserve l\'id minimal');
assertContains('channel-avatar-placeholder', $htmlMinimal, 'renderVideoCard met un placeholder sans avatar');