fix(security): systematic output escaping with e() and video-card partial
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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); ?>"
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
e('<script>alert(1)</script>'),
|
||||||
|
'e() échappe les balises HTML'
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
'"guillemets" 'apostrophes'',
|
||||||
|
e('"guillemets" \'apostrophes\''),
|
||||||
|
'e() échappe guillemets et apostrophes (ENT_QUOTES)'
|
||||||
|
);
|
||||||
|
assertEquals('&', 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(''', $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');
|
||||||
Reference in New Issue
Block a user