From 8181dbd57c67874ce50c9dd49572f7c6903bc490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Mon, 27 Jul 2026 09:14:52 +0400 Subject: [PATCH] chore: remove dead code and unused legacy files --- ajax/load-more-videos.php | 24 +- includes/config.default.php | 2 - includes/config.local.php.sample | 6 - includes/config.php | 1027 +----------------------------- includes/featured-videos.php | 77 --- includes/pwa-init.php | 82 --- includes/recent-videos.php | 29 - includes/security.php | 307 ++++----- js/main.js | 2 - sw.js | 30 +- 10 files changed, 210 insertions(+), 1376 deletions(-) delete mode 100644 includes/featured-videos.php delete mode 100644 includes/pwa-init.php delete mode 100644 includes/recent-videos.php diff --git a/ajax/load-more-videos.php b/ajax/load-more-videos.php index d0ac534..882235b 100644 --- a/ajax/load-more-videos.php +++ b/ajax/load-more-videos.php @@ -2,6 +2,13 @@ // Inclure la configuration require_once '../includes/config.php'; +// Limitation de débit : 30 requêtes / minute par IP (l'endpoint proxifie PeerTube) +if (!checkRateLimit($_SERVER['REMOTE_ADDR'] ?? 'unknown', 30, 60)) { + http_response_code(429); // Trop de requêtes + echo json_encode(['error' => 'Trop de requêtes, réessayez plus tard']); + exit; +} + // Vérifier que la requête est faite par AJAX if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') { http_response_code(403); // Accès non autorisé @@ -25,11 +32,13 @@ if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) { // Récupérer les paramètres $type = isset($_GET['type']) ? $_GET['type'] : ''; -$page = isset($_GET['page']) ? intval($_GET['page']) : 1; +// Borner la page à [1, 1000] : au-delà il n'y a plus de contenu réel, et des +// pages arbitrairement grandes contourneraient le cache de l'API PeerTube. +$page = min(1000, validatePageNumber($_GET['page'] ?? 1)); $categoryId = isset($_GET['category']) ? intval($_GET['category']) : 0; // Vérifier que le type est valide -if (!in_array($type, ['recent', 'trending', 'independence', 'category'])) { +if (!in_array($type, ['recent', 'trending', 'category'])) { http_response_code(400); // Requête incorrecte echo json_encode(['error' => 'Type de vidéos non valide']); exit; @@ -69,17 +78,6 @@ switch ($type) { $videos = formatVideosData($data['data'] ?? []); break; - case 'independence': - // Récupérer les vidéos sur l'indépendance - $data = callPeerTubeApi('videos', [ - 'tagsOneOf' => TAG_INDEPENDENCE, - 'count' => LOAD_MORE_COUNT, - 'start' => $offset, - 'isLocal' => true - ]); - $videos = formatVideosData($data['data'] ?? []); - break; - case 'category': // Récupérer les vidéos de la catégorie $data = callPeerTubeApi('videos', [ diff --git a/includes/config.default.php b/includes/config.default.php index cfc7185..036f111 100644 --- a/includes/config.default.php +++ b/includes/config.default.php @@ -16,7 +16,6 @@ if (!defined('ORGANIZATION_NAME')) define('ORGANIZATION_NAME', 'ANNU KUTE CED'); if (!defined('PEERTUBE_URL')) define('PEERTUBE_URL', 'https://gade.o-k-i.net'); if (!defined('PEERTUBE_DISPLAY_NAME')) define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net'); if (!defined('API_KEY')) define('API_KEY', ''); -if (!defined('TAG_INDEPENDENCE')) define('TAG_INDEPENDENCE', 'indépendance'); if (!defined('SHORTS_MAX_DURATION')) define('SHORTS_MAX_DURATION', 180); // 3 minutes max pour les shorts // Pagination et affichage @@ -27,7 +26,6 @@ if (!defined('RECENT_VIDEOS_COUNT')) define('RECENT_VIDEOS_COUNT', 6); if (!defined('SHORTS_COUNT')) define('SHORTS_COUNT', 6); if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100); if (!defined('TRENDING_VIDEOS_COUNT')) define('TRENDING_VIDEOS_COUNT', 6); -if (!defined('INDEPENDENCE_VIDEOS_COUNT')) define('INDEPENDENCE_VIDEOS_COUNT', 6); if (!defined('CATEGORY_VIDEOS_COUNT')) define('CATEGORY_VIDEOS_COUNT', 6); if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6); diff --git a/includes/config.local.php.sample b/includes/config.local.php.sample index 614ed8a..423d15f 100644 --- a/includes/config.local.php.sample +++ b/includes/config.local.php.sample @@ -71,9 +71,6 @@ define('APP_HOST_NAME', 'example.com'); // Filtres et tags // ========================================= -// Tag pour les vidéos sur l'indépendance -// define('TAG_INDEPENDANCE', 'indépendance'); - // Tag pour les shorts // define('TAG_SHORT', 'short'); @@ -113,9 +110,6 @@ define('SHORTS_MAX_DURATION', 180); // 3 minutes // Nombre de vidéos tendances // define('TRENDING_VIDEOS_COUNT', 6); -// Nombre de vidéos indépendance -// define('INDEPENDENCE_VIDEOS_COUNT', 6); - // Nombre de vidéos par catégorie // define('CATEGORY_VIDEOS_COUNT', 6); diff --git a/includes/config.php b/includes/config.php index 217602f..1f497fd 100644 --- a/includes/config.php +++ b/includes/config.php @@ -1,4 +1,20 @@ 'Musique', - 'Films' => 'Films', - 'Vehicles' => 'Véhicules', - 'Art' => 'Art', - 'Sports' => 'Sports', - 'Travels' => 'Voyages', - 'Gaming' => 'Jeux vidéo', - 'People' => 'Personnes', - 'Comedy' => 'Humour', - 'Entertainment' => 'Divertissement', - 'News & Politics' => 'Actualités & Politique', - 'How To' => 'Tutoriels', - 'Education' => 'Éducation', - 'Activism' => 'Activisme', - 'Science & Technology' => 'Science & Technologie', - 'Animals' => 'Animaux', - 'Kids' => 'Enfants', - 'Food' => 'Cuisine', - ]; - - // Si une constante PRIORITY_CATEGORIES est définie, utiliser ces traductions - if (defined('PRIORITY_CATEGORIES')) { - $priorityCategories = PRIORITY_CATEGORIES; - foreach ($priorityCategories as $id => $name) { - // Trouver la clé anglaise correspondant à l'ID - $englishName = array_search($id, array_keys($categories)); - if ($englishName !== false) { - $translations[$englishName] = $name; - } - } - } - - $result = []; - foreach ($categories as $key => $name) { - // Utiliser la traduction si disponible, sinon garder le nom original - $translatedName = isset($translations[$name]) ? $translations[$name] : $name; - $result[$key] = $translatedName; - } - - return $result; -} - -/** - * Version originale pour appeler l'API PeerTube (sans cache) - * Cette fonction est maintenant utilisée en interne par callPeerTubeApi - * - * @param string $endpoint Point de terminaison de l'API - * @param array $params Paramètres optionnels pour la requête - * @return array Données retournées par l'API - */ -function callPeerTubeApiOriginal($endpoint, $params = []) { - // Validation de l'URL de base PeerTube pour prévenir SSRF - if (!isValidPeerTubeUrl(PEERTUBE_URL)) { - error_log('SECURITY: Invalid PeerTube URL detected: ' . PEERTUBE_URL); - return []; - } - - // Nettoyer et valider l'endpoint - $endpoint = ltrim($endpoint, '/'); - if (!isValidApiEndpoint($endpoint)) { - error_log('SECURITY: Invalid API endpoint detected: ' . $endpoint); - return []; - } - - $url = PEERTUBE_URL . '/api/v1/' . $endpoint; - - // Ajouter les paramètres à l'URL - if (!empty($params)) { - $url .= '?' . http_build_query($params); - } - - // Initialiser cURL avec options de sécurité - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); - curl_setopt($ch, CURLOPT_MAXREDIRS, 0); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - curl_setopt($ch, CURLOPT_USERAGENT, 'fediverse-oki/1.0'); - - // Ajouter la clé API si définie - if (defined('API_KEY') && !empty(API_KEY)) { - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: ApiKey ' . API_KEY - ]); - } - - // Exécuter la requête - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - // Traiter la réponse - if ($response === false || !empty($error)) { - error_log('PeerTube API error: ' . $error); - return []; - } - - if ($httpCode < 200 || $httpCode >= 300) { - error_log('PeerTube API HTTP error: ' . $httpCode); - return []; - } - - // Décoder la réponse JSON - $data = json_decode($response, true); - - return $data ?: []; -} - -/** - * Fonction utilitaire pour appeler l'API PeerTube avec cache - * - * @param string $endpoint Point de terminaison de l'API - * @param array $params Paramètres optionnels pour la requête - * @return array Données retournées par l'API - */ -function callPeerTubeApi($endpoint, $params = []) { - // Utiliser la fonction cachée si disponible - if (function_exists('callPeerTubeApiCached')) { - return callPeerTubeApiCached($endpoint, $params); - } - - // Fallback vers la version originale - return callPeerTubeApiOriginal($endpoint, $params); -} - -/** - * Valide l'URL PeerTube pour prévenir les attaques SSRF - * Alias historique de isValidRemoteUrl() (includes/security.php). - * - * @param string $url URL à valider - * @return bool True si l'URL est valide et sûre - */ -function isValidPeerTubeUrl($url) { - return isValidRemoteUrl($url); -} - -/** - * Valide l'endpoint API pour prévenir l'injection de chemins - * - * @param string $endpoint Endpoint à valider - * @return bool True si l'endpoint est valide - */ -function isValidApiEndpoint($endpoint) { - // Bloquer les tentatives de path traversal - if (strpos($endpoint, '..') !== false || strpos($endpoint, '//') !== false) { - return false; - } - - // Autoriser uniquement les caractères alphanumériques, tirets, underscores et slashes - if (!preg_match('/^[a-zA-Z0-9\/_-]+$/', $endpoint)) { - return false; - } - - // Liste blanche des endpoints autorisés - $allowedEndpoints = [ - 'videos', - 'videos/categories', - 'search/videos', - 'videos/.*', // Pour les endpoints dynamiques comme videos/{id} - 'videos/.*/comment-threads', // Pour les commentaires - 'accounts', - 'accounts/.*/videos', // Pour les vidéos d'un compte spécifique - 'video-channels/.*/videos' // Pour les vidéos d'une chaîne spécifique - ]; - - foreach ($allowedEndpoints as $pattern) { - // Remplacer les .* par des marqueurs temporaires - $tempPattern = str_replace('.*', '__WILDCARD__', $pattern); - // Échapper les caractères spéciaux regex - $escapedPattern = preg_quote($tempPattern, '/'); - // Remettre les wildcards en place - $regexPattern = str_replace('__WILDCARD__', '.*', $escapedPattern); - - if (preg_match('/^' . $regexPattern . '$/', $endpoint)) { - return true; - } - } - - return false; -} - -/** - * Récupère les catégories depuis l'API PeerTube - * - * @return array Liste des catégories - */ -function getCategories() { - // Utiliser les catégories déjà récupérées - $categories = PEERTUBE_CATEGORIES; - - $result = []; - foreach ($categories as $key => $name) { - $result[] = [ - 'id' => $key, - 'name' => $name - ]; - } - - return $result; -} - -/** - * Récupère les vidéos récentes depuis l'API PeerTube - * - * @param int $count Nombre de vidéos à récupérer - * @return array Liste des vidéos récentes - */ -function getRecentVideos($count = RECENT_VIDEOS_COUNT) { - // Récupérer les vidéos récentes - $data = callPeerTubeApi('videos', [ - 'sort' => '-publishedAt', - 'count' => $count, - 'isLocal' => true - ]); - - return formatVideosData($data['data'] ?? []); -} - -/** - * Récupère les vidéos tendances depuis l'API PeerTube - * - * @param int $count Nombre de vidéos à récupérer - * @return array Liste des vidéos tendances - */ -function getTrendingVideos($count = TRENDING_VIDEOS_COUNT) { - // Récupérer les vidéos tendances - $data = callPeerTubeApi('videos', [ - 'sort' => '-trending', - 'count' => $count, - 'isLocal' => true - ]); - - return formatVideosData($data['data'] ?? []); -} - -/** - * Récupère les vidéos avec un tag spécifique depuis l'API PeerTube - * - * @param string $tag Tag à filtrer - * @param int $count Nombre de vidéos à récupérer - * @return array Liste des vidéos - */ -function getVideosByTag($tag, $count) { - // Récupérer les vidéos par tag - $data = callPeerTubeApi('videos', [ - 'tagsOneOf' => $tag, - 'count' => $count, - 'isLocal' => true - ]); - - return formatVideosData($data['data'] ?? []); -} - -/** - * Récupère les shorts (vidéos courtes) depuis l'API PeerTube - * Les shorts sont des vidéos locales de moins de 2 minutes - * - * @param int $count Nombre de shorts à récupérer - * @return array Liste des shorts - */ -function getShorts($count = SHORTS_COUNT) { - // Récupérer plus de vidéos que nécessaire pour pouvoir filtrer - $data = callPeerTubeApi('videos', [ - 'sort' => '-publishedAt', // Les plus récentes d'abord - 'count' => SHORTS_COUNT_SEARCH, - 'isLocal' => true - ]); - - // Formater les données - $allVideos = formatVideosData($data['data'] ?? []); - - // Filtrer pour ne garder que les vidéos de moins de 2 minutes (120 secondes) et en mode portrait - $shortVideos = array_filter($allVideos, function($video) { - // Vérifier la durée (moins de 2 minutes) - $durationOk = $video['duration'] < SHORTS_MAX_DURATION; - - // Vérifier le ratio (mode portrait) - $ratioOk = isset($video['aspectRatio']) && $video['aspectRatio'] <= 1; - - return $durationOk && $ratioOk; - }); - - // Limiter au nombre demandé - return array_slice($shortVideos, 0, $count); -} - -/** - * Récupère les vidéos sur l'indépendance depuis l'API PeerTube - * - * @param int $count Nombre de vidéos à récupérer - * @return array Liste des vidéos sur l'indépendance - */ -function getIndependenceVideos($count = INDEPENDENCE_VIDEOS_COUNT) { - // Récupérer les vidéos sur l'indépendance - return getVideosByTag(TAG_INDEPENDENCE, $count); -} - -/** - * Vérifie s'il y a un direct en cours du compte LIVE_ACCOUNT_NAME sur l'instance PeerTube - * - * @return array|null Informations sur le direct en cours ou null si aucun direct - */ -function getLiveStream() { - // Récupérer les lives du compte spécifié - $accountName = LIVE_ACCOUNT_NAME; - $data = callPeerTubeApi('accounts/' . $accountName . '/videos', [ - 'count' => 1, - 'isLocal' => true, - 'isLive' => true, // Filtrer uniquement les lives - 'sort' => '-publishedAt' // Les plus récents en premier - ]); - - // Vérifier si on a des résultats - if (empty($data['data']) || count($data['data']) === 0) { - return null; - } - - // Formater les données du live - $liveData = formatVideosData($data['data']); - - // Filtrer pour ne garder que les lives en cours - $activeLives = array_filter($liveData, function($video) { - return isset($video['isLive']) && $video['isLive'] === true; - }); - - // Retourner le premier live trouvé - return !empty($activeLives) ? reset($activeLives) : null; -} - -/** - * Formate les données brutes des vidéos venant de l'API - * - * @param array $videosData Données brutes des vidéos - * @return array Données formatées - */ -function formatVideosData($videosData) { - $videos = []; - - foreach ($videosData as $video) { - // Ignorer les entrées sans uuid : l'identifiant est indispensable - if (empty($video['uuid'])) { - continue; - } - - // Récupérer la vignette (thumbnail) - $thumbnail = isset($video['previewPath']) - ? PEERTUBE_URL . $video['previewPath'] - : 'img/default-thumbnail.jpg'; - - // Récupérer l'avatar de la chaîne - $channelAvatar = isset($video['channel']['avatars'][0]['path']) - ? PEERTUBE_URL . $video['channel']['avatars'][0]['path'] - : 'img/default-avatar.png'; - - // Formater les données (valeurs par défaut pour les champs absents) - $videos[] = [ - 'id' => $video['uuid'], - 'title' => $video['name'] ?? '', - 'thumbnail' => $thumbnail, - 'duration' => $video['duration'] ?? 0, - 'channel' => $video['channel']['displayName'] ?? '', - 'channelAvatar' => $channelAvatar, - 'views' => $video['views'] ?? 0, - 'date' => $video['publishedAt'] ?? '', - 'aspectRatio' => $video['aspectRatio'] ?? null, - 'description' => $video['description'] ?? '', - 'tags' => $video['tags'] ?? [], - 'isLive' => $video['isLive'] ?? false - ]; - } - - return $videos; -} - -// Fonctions utilitaires pour formater les données d'affichage -function formatDuration($seconds) { - $hours = floor($seconds / 3600); - $minutes = floor(($seconds % 3600) / 60); - $remainingSeconds = $seconds % 60; - - if ($hours > 0) { - return sprintf('%d:%02d:%02d', $hours, $minutes, $remainingSeconds); - } else { - return sprintf('%d:%02d', $minutes, $remainingSeconds); - } -} - -function formatViewCount($views) { - if ($views >= 1000000) { - return round($views / 1000000, 1) . 'M'; - } elseif ($views >= 1000) { - return round($views / 1000, 1) . 'K'; - } else { - return $views; - } -} - -function formatDate($dateString) { - // Chaîne vide : rien à formater (new DateTime('') renverrait « maintenant ») - if (!is_string($dateString) || trim($dateString) === '') { - return (string) $dateString; - } - - try { - $date = new DateTime($dateString); - } catch (Exception $e) { - // Date malformée (données API inattendues) : afficher la chaîne brute - return $dateString; - } - - $now = new DateTime(); - $interval = $now->diff($date); - - if ($interval->days == 0) { - return 'Aujourd\'hui'; - } elseif ($interval->days == 1) { - return 'Hier'; - } elseif ($interval->days < 7) { - return 'Il y a ' . $interval->days . ' jours'; - } elseif ($interval->days < 30) { - $weeks = floor($interval->days / 7); - return 'Il y a ' . $weeks . ' semaine' . ($weeks > 1 ? 's' : ''); - } elseif ($interval->days < 365) { - $months = floor($interval->days / 30); - return 'Il y a ' . $months . ' mois'; - } else { - $years = floor($interval->days / 365); - return 'Il y a ' . $years . ' an' . ($years > 1 ? 's' : ''); - } -} - -/** - * Récupère les vidéos d'une catégorie spécifique depuis l'API PeerTube - * - * @param int $categoryId Identifiant de la catégorie - * @param int $count Nombre de vidéos à récupérer - * @return array Liste des vidéos de la catégorie - */ -function getVideosByCategory($categoryId, $count = CATEGORY_VIDEOS_COUNT) { - // Récupérer les vidéos par catégorie - $data = callPeerTubeApi('videos', [ - 'categoryOneOf' => $categoryId, - 'count' => $count, - 'sort' => '-publishedAt', // Les plus récentes d'abord - 'isLocal' => true - ]); - - return formatVideosData($data['data'] ?? []); -} - -/** - * Récupère la liste des catégories à afficher (triées selon les priorités) - * - * @return array Liste des catégories avec id, name et videos - */ -function getDisplayCategories() { - $categories = []; - $priorityCategories = PRIORITY_CATEGORIES; - $allCategories = PEERTUBE_CATEGORIES; - - // Ajouter uniquement les catégories prioritaires dans l'ordre défini - foreach ($priorityCategories as $catId => $categoryName) { - $videos = getVideosByCategory($catId); - - // N'ajouter que les catégories qui ont des vidéos - if (!empty($videos)) { - $categories[] = [ - 'id' => $catId, - 'name' => $categoryName, - 'videos' => $videos - ]; - } - } - - return $categories; -} - -/** - * Récupère les commentaires d'une vidéo - * @param string $videoId ID de la vidéo - * @return array Tableau des commentaires - */ -function getVideoComments($videoId) { - $endpoint = "videos/{$videoId}/comment-threads"; - $response = callPeerTubeApi($endpoint); - - if (!$response || !isset($response['data'])) { - return []; - } - - return $response['data']; -} - -/** - * Récupère les options de téléchargement pour une vidéo - * @param string $videoId ID de la vidéo - * @return array Options de téléchargement - */ -function getVideoDownloadOptions($videoId) { - // Récupérer les informations complètes de la vidéo - $videoData = callPeerTubeApi('videos/' . $videoId); - - $downloadOptions = []; - - // Ajouter les fichiers directs s'ils existent - if (isset($videoData['files']) && !empty($videoData['files'])) { - foreach ($videoData['files'] as $file) { - if (isset($file['fileDownloadUrl']) && !empty($file['fileDownloadUrl'])) { - $downloadOptions[] = [ - 'type' => 'direct', - 'url' => PEERTUBE_URL . $file['fileDownloadUrl'], - 'resolution' => isset($file['resolution']['label']) ? $file['resolution']['label'] : 'Original', - 'size' => isset($file['size']) ? formatFileSize($file['size']) : 'Inconnu' - ]; - } - } - } - - // Ajouter les playlists de streaming s'ils existent - if (isset($videoData['streamingPlaylists']) && !empty($videoData['streamingPlaylists'])) { - foreach ($videoData['streamingPlaylists'] as $playlist) { - if (isset($playlist['files']) && !empty($playlist['files'])) { - foreach ($playlist['files'] as $file) { - if (isset($file['fileDownloadUrl']) && !empty($file['fileDownloadUrl'])) { - $downloadOptions[] = [ - 'type' => 'hls', - 'url' => $file['fileDownloadUrl'], - 'resolution' => isset($file['resolution']['label']) ? $file['resolution']['label'] : 'Original', - 'size' => isset($file['size']) ? formatFileSize($file['size']) : 'Inconnu' - ]; - } - } - } - } - } - - return $downloadOptions; -} - -/** - * Formate la taille d'un fichier en format lisible - * @param int $bytes Taille en octets - * @return string Taille formatée - */ -function formatFileSize($bytes) { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - - $bytes /= (1 << (10 * $pow)); - - return round($bytes, 2) . ' ' . $units[$pow]; -} - -/** - * Recherche des vidéos selon un critère - * - * @param string $query Terme de recherche - * @param int $count Nombre de vidéos à récupérer - * @param int $start Index de départ pour la pagination - * @return array Liste des vidéos correspondant à la recherche - */ -function searchVideos($query, $count = COUNT_VIDEO_SEARCH, $start = 0) { - if (empty($query)) { - return []; - } - - // Vérifier si la recherche concerne un tag (commence par #) - $isTagSearch = false; - if (substr($query, 0, 1) === '#') { - $isTagSearch = true; - $tag = substr($query, 1); // Enlever le # du début - - // Récupérer les vidéos avec ce tag via l'API - $data = callPeerTubeApi('videos', [ - 'tagsOneOf' => $tag, - 'count' => $count, - 'start' => $start, - 'isLocal' => true, // Uniquement les vidéos locales - 'sort' => '-publishedAt' // Les plus récentes d'abord - ]); - - return formatVideosData($data['data'] ?? []); - } - - // Recherche normale (pas un tag) - $data = callPeerTubeApi('search/videos', [ - 'search' => $query, - 'count' => $count, - 'start' => $start, - 'isLocal' => true, // Uniquement les vidéos locales - 'sort' => '-publishedAt' // Les plus récentes d'abord - ]); - return formatVideosData($data['data'] ?? []); -} - -/** - * Récupère les derniers épisodes d'un ou plusieurs podcasts Castopod via leurs feeds RSS - * - * @param string $castopodUrl URL de l'instance Castopod - * @param array|string $podcastSlugs Slug(s) du/des podcast(s) - tableau ou chaîne unique - * @param int $count Nombre d'épisodes à récupérer (total) - * @return array Liste des épisodes formatés, triés par date - */ -function getCastopodEpisodes($castopodUrl = null, $podcastSlugs = null, $count = 5) { - if (!defined('CASTOPOD_ENABLED') || !CASTOPOD_ENABLED) { - return []; - } - - $castopodUrl = $castopodUrl ?? CASTOPOD_URL; - $podcastSlugs = $podcastSlugs ?? (defined('CASTOPOD_PODCAST_SLUGS') ? CASTOPOD_PODCAST_SLUGS : ['annu_kute_cedric']); - $count = $count ?? CASTOPOD_EPISODES_COUNT; - - // Validation de l'URL Castopod pour prévenir SSRF - if (!isValidRemoteUrl($castopodUrl)) { - error_log('SECURITY: Invalid Castopod URL detected: ' . $castopodUrl); - return []; - } - - // Convertir en tableau si c'est une chaîne unique (rétrocompatibilité) - if (is_string($podcastSlugs)) { - $podcastSlugs = [$podcastSlugs]; - } - - // Clé de cache unique pour la combinaison de podcasts - $cacheKey = 'castopod_' . md5($castopodUrl . implode('_', $podcastSlugs) . '_' . $count); - - // Vérifier le cache - if (defined('CACHE_ENABLED') && CACHE_ENABLED) { - $cachedData = getFromCache($cacheKey); - if ($cachedData !== null) { - return $cachedData; - } - } - - $allEpisodes = []; - - // Itérer sur chaque podcast - foreach ($podcastSlugs as $index => $podcastSlug) { - try { - // Construire l'URL du feed RSS pour ce podcast - $feedUrl = rtrim($castopodUrl, '/') . '/@' . $podcastSlug . '/feed'; - - // Récupérer le feed RSS avec backoff uniquement sur rate limit (HTTP 429) - $maxAttempts = 3; - $attempt = 0; - $xmlContent = false; - $httpCode = 0; - - while ($attempt < $maxAttempts) { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $feedUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_MAXREDIRS, 3); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - $xmlContent = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - // Succès ou erreur définitive : on sort de la boucle - if ($httpCode !== 429) { - break; - } - - // Rate limit : attendre avant de réessayer (backoff exponentiel simple) - $attempt++; - if ($attempt < $maxAttempts) { - sleep(5 * $attempt); - } - } - - if ($httpCode !== 200 || !$xmlContent) { - continue; // Passer au podcast suivant en cas d'erreur - } - - $episodes = []; - - // Vérifier si SimpleXML est disponible - if (function_exists('simplexml_load_string')) { - // Parser avec SimpleXML (méthode recommandée) - $xml = simplexml_load_string($xmlContent); - if (!$xml) { - continue; // Passer au podcast suivant - } - - // Enregistrer les namespaces - $namespaces = $xml->getNamespaces(true); - - // Extraire les informations du podcast - $podcastTitle = (string) $xml->channel->title; - $podcastImage = (string) $xml->channel->image->url; - $podcastLink = (string) $xml->channel->link; - - // Parcourir les items (épisodes) - on récupère tous pour trier après - foreach ($xml->channel->item as $item) { - - // Extraire les infos de l'épisode - $itunesNs = $item->children($namespaces['itunes'] ?? 'http://www.itunes.com/dtds/podcast-1.0.dtd'); - - // Récupérer l'image de l'épisode - $episodeImage = $podcastImage; - if (isset($itunesNs->image)) { - $imageAttrs = $itunesNs->image->attributes(); - if (isset($imageAttrs['href'])) { - $episodeImage = (string) $imageAttrs['href']; - } - } - - $episode = [ - 'title' => (string) $item->title, - 'link' => (string) $item->link, - 'pubDate' => (string) $item->pubDate, - 'description' => strip_tags((string) $item->description), - 'duration' => (string) $itunesNs->duration ?? '', - 'image' => $episodeImage, - 'audioUrl' => (string) $item->enclosure['url'] ?? '', - 'podcastTitle' => $podcastTitle, - 'podcastLink' => $podcastLink, - 'podcastSlug' => $podcastSlug, - ]; - - // Formater la date - if ($episode['pubDate']) { - try { - $date = new DateTime($episode['pubDate']); - $episode['formattedDate'] = $date->format('d/m/Y'); - $episode['timestamp'] = $date->getTimestamp(); - } catch (Exception $e) { - $episode['formattedDate'] = ''; - $episode['timestamp'] = 0; - } - } - - // Formater la durée - if ($episode['duration']) { - $episode['formattedDuration'] = formatDuration($episode['duration']); - } else { - $episode['formattedDuration'] = ''; - } - - // Limiter la description - if (strlen($episode['description']) > 150) { - $episode['description'] = substr($episode['description'], 0, 150) . '...'; - } - - $episodes[] = $episode; - } - } else { - // Fallback: Parser avec regex (moins fiable mais fonctionne sans SimpleXML) - // Extraire les infos du podcast - preg_match('/.*?(.*?)<\/title>/s', $xmlContent, $podcastTitleMatch); - preg_match('/<channel>.*?<link>(.*?)<\/link>/s', $xmlContent, $podcastLinkMatch); - preg_match('/<image>.*?<url>(.*?)<\/url>/s', $xmlContent, $podcastImageMatch); - - $podcastTitle = $podcastTitleMatch[1] ?? ''; - $podcastLink = $podcastLinkMatch[1] ?? ''; - $podcastImage = $podcastImageMatch[1] ?? ''; - - // Extraire tous les items - preg_match_all('/<item>(.*?)<\/item>/s', $xmlContent, $items); - - foreach ($items[1] as $itemContent) { - // Extraire les données de chaque item - preg_match('/<title>(.*?)<\/title>/', $itemContent, $titleMatch); - preg_match('/<link>(.*?)<\/link>/', $itemContent, $linkMatch); - preg_match('/<pubDate>(.*?)<\/pubDate>/', $itemContent, $dateMatch); - preg_match('/<description>(.*?)<\/description>/s', $itemContent, $descMatch); - preg_match('/<itunes:duration>(.*?)<\/itunes:duration>/', $itemContent, $durationMatch); - preg_match('/<itunes:image[^>]*href=["\']([^"\']*)["\']/', $itemContent, $imageMatch); - preg_match('/<enclosure[^>]*url=["\']([^"\']*)["\']/', $itemContent, $audioMatch); - - $episode = [ - 'title' => $titleMatch[1] ?? '', - 'link' => $linkMatch[1] ?? '', - 'pubDate' => $dateMatch[1] ?? '', - 'description' => strip_tags($descMatch[1] ?? ''), - 'duration' => $durationMatch[1] ?? '', - 'image' => $imageMatch[1] ?? $podcastImage, - 'audioUrl' => $audioMatch[1] ?? '', - 'podcastTitle' => $podcastTitle, - 'podcastLink' => $podcastLink, - 'podcastSlug' => $podcastSlug, - ]; - - // Formater la date - if ($episode['pubDate']) { - try { - $date = new DateTime($episode['pubDate']); - $episode['formattedDate'] = $date->format('d/m/Y'); - $episode['timestamp'] = $date->getTimestamp(); - } catch (Exception $e) { - $episode['formattedDate'] = ''; - $episode['timestamp'] = 0; - } - } - - // Formater la durée - if ($episode['duration']) { - $episode['formattedDuration'] = formatDuration($episode['duration']); - } else { - $episode['formattedDuration'] = ''; - } - - // Limiter la description - if (strlen($episode['description']) > 150) { - $episode['description'] = substr($episode['description'], 0, 150) . '...'; - } - - $episodes[] = $episode; - } - } - - // Ajouter les épisodes de ce podcast à la liste globale - $allEpisodes = array_merge($allEpisodes, $episodes); - - } catch (Exception $e) { - error_log('Erreur lors de la récupération des épisodes Castopod pour ' . $podcastSlug . ': ' . $e->getMessage()); - // Continuer avec le podcast suivant - } - } - - // Trier tous les épisodes par date (du plus récent au plus ancien) - usort($allEpisodes, function($a, $b) { - return ($b['timestamp'] ?? 0) - ($a['timestamp'] ?? 0); - }); - - // Limiter au nombre demandé - $allEpisodes = array_slice($allEpisodes, 0, $count); - - // Mettre en cache (uniquement si non vide : une erreur temporaire - // — rate limit HTTP 429, timeout… — ne doit pas être figée en cache) - if (!empty($allEpisodes) && defined('CACHE_ENABLED') && CACHE_ENABLED) { - saveToCache($cacheKey, $allEpisodes); - } - - return $allEpisodes; -} - -/** - * Récupère des morceaux aléatoires depuis Funkwhale via l'API - * - * @param string $funkwhaleUrl URL de l'instance Funkwhale - * @param int $count Nombre de morceaux à récupérer - * @return array Liste des morceaux formatés - */ -function getFunkwhaleTracks($funkwhaleUrl = null, $count = null) { - if (!defined('FUNKWHALE_ENABLED') || !FUNKWHALE_ENABLED) { - return []; - } - - $funkwhaleUrl = $funkwhaleUrl ?? FUNKWHALE_URL; - $count = $count ?? FUNKWHALE_TRACKS_COUNT; - - // Validation de l'URL Funkwhale pour prévenir SSRF - if (!isValidRemoteUrl($funkwhaleUrl)) { - error_log('SECURITY: Invalid Funkwhale URL detected: ' . $funkwhaleUrl); - return []; - } - - // Clé de cache - on récupère un grand nombre de morceaux pour le cache - $cacheKey = 'funkwhale_' . md5($funkwhaleUrl); - $cacheFetchSize = 50; // Nombre de morceaux à mettre en cache - - // Vérifier le cache - $allTracks = null; - if (defined('CACHE_ENABLED') && CACHE_ENABLED) { - $allTracks = getFromCache($cacheKey); - } - - // Si pas de cache, récupérer depuis l'API - if ($allTracks === null) { - try { - // Construire l'URL de l'API avec filtre pour morceaux locaux uniquement - // Le paramètre scope=me récupère uniquement les morceaux de l'instance locale - $apiUrl = rtrim($funkwhaleUrl, '/') . '/api/v1/tracks?ordering=random&page_size=' . $cacheFetchSize . '&scope=all'; - - // Récupérer les morceaux via cURL - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $apiUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_MAXREDIRS, 3); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - $jsonContent = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode !== 200 || !$jsonContent) { - return []; - } - - // Parser le JSON - $data = json_decode($jsonContent, true); - if (!$data || !isset($data['results'])) { - return []; - } - - $allTracks = []; - - // Extraire le domaine de l'instance locale pour filtrer - $localDomain = parse_url($funkwhaleUrl, PHP_URL_HOST); - - // Parcourir les morceaux et filtrer pour garder uniquement les morceaux locaux - foreach ($data['results'] as $item) { - // Vérifier si le morceau est local en comparant le domaine - // Les morceaux fédérés ont généralement un domaine différent dans leur URL - $isLocal = true; - - // Vérifier via l'URL de l'artiste ou de l'album - if (isset($item['artist']['channel'])) { - $artistDomain = parse_url($item['artist']['channel'], PHP_URL_HOST); - if ($artistDomain && $artistDomain !== $localDomain) { - $isLocal = false; - } - } - - // Ou vérifier via le champ is_local si disponible - if (isset($item['is_local']) && !$item['is_local']) { - $isLocal = false; - } - - // Ne garder que les morceaux locaux - if ($isLocal) { - // Récupérer l'URL d'écoute depuis l'API (peut être relative) - $listenUrl = $item['listen_url'] ?? ''; - - // Si l'URL est relative, la transformer en URL absolue - if (!empty($listenUrl) && strpos($listenUrl, 'http') !== 0) { - $listenUrl = rtrim($funkwhaleUrl, '/') . $listenUrl; - } - - $track = [ - 'title' => $item['title'] ?? '', - 'artist' => $item['artist']['name'] ?? 'Artiste inconnu', - 'album' => $item['album']['title'] ?? '', - 'cover' => $item['album']['cover']['urls']['medium_square_crop'] ?? '', - 'duration' => $item['uploads'][0]['duration'] ?? 0, - 'link' => rtrim($funkwhaleUrl, '/') . '/library/tracks/' . ($item['id'] ?? ''), - 'trackId' => $item['id'] ?? 0, - 'audioUrl' => $listenUrl, - ]; - - // Formater la durée - if ($track['duration']) { - $track['formattedDuration'] = formatDuration($track['duration']); - } else { - $track['formattedDuration'] = ''; - } - - $allTracks[] = $track; - } - } - - // Mettre en cache tous les morceaux locaux - if (defined('CACHE_ENABLED') && CACHE_ENABLED) { - saveToCache($cacheKey, $allTracks); - } - - } catch (Exception $e) { - error_log('Erreur lors de la récupération des morceaux Funkwhale: ' . $e->getMessage()); - return []; - } - } - - // Si pas assez de morceaux, retourner ce qu'on a - if (empty($allTracks)) { - return []; - } - - // Sélectionner aléatoirement $count morceaux parmi tous les morceaux en cache - $trackCount = count($allTracks); - if ($trackCount <= $count) { - return $allTracks; - } - - // Mélanger et prendre les N premiers - shuffle($allTracks); - return array_slice($allTracks, 0, $count); -} -?> +// Charger les modules métier +require_once __DIR__ . '/lib/http.php'; +require_once __DIR__ . '/lib/format.php'; +require_once __DIR__ . '/lib/peertube-api.php'; +require_once __DIR__ . '/lib/castopod.php'; +require_once __DIR__ . '/lib/funkwhale.php'; diff --git a/includes/featured-videos.php b/includes/featured-videos.php deleted file mode 100644 index 8a6f62d..0000000 --- a/includes/featured-videos.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php -// Inclure la configuration si ce n'est pas déjà fait -if (!function_exists('getTrendingVideos')) { - require_once __DIR__ . '/config.php'; -} - -// Récupérer les vidéos tendances depuis l'API PeerTube -$featuredVideos = getTrendingVideos(FEATURED_VIDEOS_COUNT); - -// Affichage des vidéos -foreach ($featuredVideos as $video): -?> - <div class="video-card" data-video-id="<?php echo $video['id']; ?>"> - <div class="video-thumbnail"> - <img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>" data-src="<?php echo $video['thumbnail']; ?>"> - <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 echo $video['channel']; ?></div> - <div class="video-metadata"> - <?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?> - <span class="video-views"><?php echo formatViewCount($video['views']); ?> vues</span> - <?php endif; ?> - <span class="video-date"><?php echo formatDate($video['date']); ?></span> - </div> - </div> - </div> -<?php endforeach; ?> - -<?php -// Fonctions utilitaires (dans un vrai projet, ces fonctions seraient dans un fichier séparé) -function formatDuration($seconds) { - $hours = floor($seconds / 3600); - $minutes = floor(($seconds % 3600) / 60); - $remainingSeconds = $seconds % 60; - - if ($hours > 0) { - return sprintf('%d:%02d:%02d', $hours, $minutes, $remainingSeconds); - } else { - return sprintf('%d:%02d', $minutes, $remainingSeconds); - } -} - -function formatViewCount($views) { - if ($views >= 1000000) { - return round($views / 1000000, 1) . 'M'; - } elseif ($views >= 1000) { - return round($views / 1000, 1) . 'K'; - } else { - return $views; - } -} - -function formatDate($dateString) { - $date = new DateTime($dateString); - $now = new DateTime(); - $interval = $now->diff($date); - - if ($interval->days == 0) { - return 'Aujourd\'hui'; - } elseif ($interval->days == 1) { - return 'Hier'; - } elseif ($interval->days < 7) { - return 'Il y a ' . $interval->days . ' jours'; - } elseif ($interval->days < 30) { - $weeks = floor($interval->days / 7); - return 'Il y a ' . $weeks . ' semaine' . ($weeks > 1 ? 's' : ''); - } elseif ($interval->days < 365) { - $months = floor($interval->days / 30); - return 'Il y a ' . $months . ' mois'; - } else { - $years = floor($interval->days / 365); - return 'Il y a ' . $years . ' an' . ($years > 1 ? 's' : ''); - } -} -?> \ No newline at end of file diff --git a/includes/pwa-init.php b/includes/pwa-init.php deleted file mode 100644 index 07634a1..0000000 --- a/includes/pwa-init.php +++ /dev/null @@ -1,82 +0,0 @@ -<?php -// Fichier d'initialisation PWA à inclure dans toutes les pages -function addPWAHeaders() { - // Meta tags PWA - echo '<meta name="mobile-web-app-capable" content="yes">' . "\n"; - echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n"; - echo '<meta name="apple-mobile-web-app-status-bar-style" content="default">' . "\n"; - echo '<meta name="apple-mobile-web-app-title" content="' . SITE_NAME . '">' . "\n"; - echo '<meta name="application-name" content="' . SITE_NAME . '">' . "\n"; - echo '<meta name="msapplication-TileColor" content="#FF0000">' . "\n"; - echo '<meta name="msapplication-config" content="browserconfig.xml">' . "\n"; - echo '<meta name="theme-color" content="#FF0000">' . "\n"; - - // Manifest - echo '<link rel="manifest" href="site.webmanifest">' . "\n"; -} - -function addPWAScripts() { - ?> - <!-- PWA Service Worker --> - <script nonce="<?php echo getCspNonce(); ?>"> - if ('serviceWorker' in navigator) { - window.addEventListener('load', function() { - navigator.serviceWorker.register('/sw.js') - .then(function(registration) { - console.log('Service Worker enregistré avec succès:', registration.scope); - - // Écouter les mises à jour - registration.addEventListener('updatefound', function() { - const newWorker = registration.installing; - newWorker.addEventListener('statechange', function() { - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - // Nouvelle version disponible - console.log('Nouvelle version disponible'); - if (confirm('Une nouvelle version est disponible. Voulez-vous recharger la page ?')) { - window.location.reload(); - } - } - }); - }); - }) - .catch(function(err) { - console.log('Échec de l\'enregistrement du Service Worker:', err); - }); - }); - } - - // Gestion de l'installation PWA - let deferredPrompt; - const installButton = document.getElementById('install-pwa'); - - window.addEventListener('beforeinstallprompt', function(e) { - e.preventDefault(); - deferredPrompt = e; - - // Afficher le bouton d'installation s'il existe - if (installButton) { - installButton.classList.remove('is-hidden'); - installButton.addEventListener('click', function() { - deferredPrompt.prompt(); - deferredPrompt.userChoice.then(function(choiceResult) { - if (choiceResult.outcome === 'accepted') { - console.log('PWA installée'); - } - deferredPrompt = null; - installButton.classList.add('is-hidden'); - }); - }); - } - }); - - // Masquer le bouton après installation - window.addEventListener('appinstalled', function() { - console.log('PWA installée avec succès'); - if (installButton) { - installButton.classList.add('is-hidden'); - } - }); - </script> - <?php -} -?> \ No newline at end of file diff --git a/includes/recent-videos.php b/includes/recent-videos.php deleted file mode 100644 index eeba88c..0000000 --- a/includes/recent-videos.php +++ /dev/null @@ -1,29 +0,0 @@ -<?php -// Inclure la configuration si ce n'est pas déjà fait -if (!function_exists('getRecentVideos')) { - require_once __DIR__ . '/config.php'; -} - -// Récupérer les vidéos récentes depuis l'API PeerTube -$recentVideos = getRecentVideos(); - -// Affichage des vidéos -foreach ($recentVideos as $video): -?> - <div class="video-card" data-video-id="<?php echo $video['id']; ?>"> - <div class="video-thumbnail"> - <img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>" data-src="<?php echo $video['thumbnail']; ?>"> - <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 echo $video['channel']; ?></div> - <div class="video-metadata"> - <?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?> - <span class="video-views"><?php echo formatViewCount($video['views']); ?> vues</span> - <?php endif; ?> - <span class="video-date"><?php echo formatDate($video['date']); ?></span> - </div> - </div> - </div> -<?php endforeach; ?> \ No newline at end of file diff --git a/includes/security.php b/includes/security.php index d80dab6..9c177c1 100644 --- a/includes/security.php +++ b/includes/security.php @@ -92,69 +92,6 @@ function validateCategoryId($categoryId) { return $categoryId; } -/** - * Valide et assainit un User-Agent - * - * @param string $userAgent User-Agent à valider - * @return bool True si valide - */ -function validateUserAgent($userAgent) { - if (empty($userAgent)) { - return false; - } - - // Bloquer les User-Agents suspects - $blockedPatterns = [ - '/curl/i', - '/wget/i', - '/python/i', - '/bot/i', - '/scanner/i', - '/sqlmap/i' - ]; - - foreach ($blockedPatterns as $pattern) { - if (preg_match($pattern, $userAgent)) { - return false; - } - } - - return true; -} - -/** - * Valide les en-têtes HTTP pour détecter les tentatives d'attaque - * - * @return bool True si les en-têtes sont sûrs - */ -function validateHttpHeaders() { - // Vérifier le User-Agent - $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; - if (!validateUserAgent($userAgent)) { - error_log('SECURITY: Suspicious User-Agent detected: ' . $userAgent); - return false; - } - - // Vérifier les en-têtes suspects - $suspiciousHeaders = [ - 'HTTP_X_FORWARDED_FOR', - 'HTTP_X_REAL_IP', - 'HTTP_CLIENT_IP' - ]; - - foreach ($suspiciousHeaders as $header) { - if (isset($_SERVER[$header])) { - $value = $_SERVER[$header]; - // Bloquer les IPs privées dans les en-têtes de forwarding - if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { - error_log('SECURITY: Suspicious IP in header ' . $header . ': ' . $value); - } - } - } - - 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. @@ -252,6 +189,104 @@ function getCspNonce() { return $GLOBALS['csp_nonce']; } +/** + * Extrait l'origine « scheme://host » d'une URL configurée (pour la CSP). + * + * @param string $url URL à analyser + * @return string Origine normalisée, ou chaîne vide si l'URL est vide/invalide + */ +function cspOriginFromUrl($url) { + if (empty($url)) { + return ''; + } + $parsed = parse_url($url); + if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) { + return ''; + } + return $parsed['scheme'] . '://' . $parsed['host']; +} + +/** + * Construit la Content Security Policy de la requête courante. + * + * img-src et media-src sont restreints aux domaines réellement utilisés : + * PeerTube (vignettes), Mastodon (avatars/médias) et son S3 éventuel, + * Castopod (pochettes/flux audio), Funkwhale (pochettes/flux audio) et + * WordPress (images mises en avant). En développement local, HTTP(S) général + * reste autorisé pour faciliter les tests avec du contenu fédéré. + * + * @param string $nonce Nonce CSP de la requête + * @return string Politique CSP complète + */ +function buildContentSecurityPolicy($nonce) { + $mastodonDomain = defined('MASTODON_INSTANCE_URL') ? cspOriginFromUrl(MASTODON_INSTANCE_URL) : ''; + $peertubeDomain = defined('PEERTUBE_URL') ? cspOriginFromUrl(PEERTUBE_URL) : ''; + $castopodDomain = (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL')) + ? cspOriginFromUrl(CASTOPOD_URL) : ''; + $funkwhaleDomain = (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED && defined('FUNKWHALE_URL')) + ? cspOriginFromUrl(FUNKWHALE_URL) : ''; + $wordpressDomain = defined('WORDPRESS_URL') ? cspOriginFromUrl(WORDPRESS_URL) : ''; + $s3Domain = defined('MASTODON_S3_MEDIA_URL') ? cspOriginFromUrl(MASTODON_S3_MEDIA_URL) : ''; + + // Détecter si on est en développement local + $isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']); + + $csp = "default-src 'self'; "; + $csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; "; + $csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; "; + + // Images : uniquement les services réellement affichés (https: général en dev uniquement) + $imgSrc = "'self' data:"; + foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $wordpressDomain, $s3Domain] as $domain) { + if ($domain !== '') { + $imgSrc .= ' ' . $domain; + } + } + if ($isLocalDev) { + $imgSrc .= ' https: http:'; + } + $csp .= "img-src " . $imgSrc . "; "; + + $csp .= "font-src 'self' https://cdnjs.cloudflare.com; "; + + // Frames : autoriser PeerTube uniquement + $frameSrc = "'self'" . ($peertubeDomain !== '' ? ' ' . $peertubeDomain : ''); + if ($isLocalDev) { + $frameSrc .= ' https: http:'; + } + $csp .= "frame-src " . $frameSrc . "; "; + + // Connexions : autoriser Mastodon, PeerTube et Plausible + $connectSrc = "'self' https://plausible.io"; + foreach ([$mastodonDomain, $peertubeDomain] as $domain) { + if ($domain !== '') { + $connectSrc .= ' ' . $domain; + } + } + if ($isLocalDev) { + $connectSrc .= ' ws: wss:'; // WebSockets pour le dev + } + $csp .= "connect-src " . $connectSrc . "; "; + + // Médias : flux audio Castopod/Funkwhale, médias Mastodon (instance ou S3) + $mediaSrc = "'self'"; + foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $s3Domain] as $domain) { + if ($domain !== '') { + $mediaSrc .= ' ' . $domain; + } + } + if ($isLocalDev) { + $mediaSrc .= ' https: http:'; + } + $csp .= "media-src " . $mediaSrc . "; "; + + $csp .= "object-src 'none'; "; + $csp .= "base-uri 'self'; "; + $csp .= "frame-ancestors 'self';"; + + return $csp; +} + /** * Applique des en-têtes de sécurité HTTP */ @@ -262,8 +297,8 @@ function setSecurityHeaders() { // Protection contre le MIME sniffing header('X-Content-Type-Options: nosniff'); - // Protection XSS basique - header('X-XSS-Protection: 1; mode=block'); + // X-XSS-Protection volontairement absent : en-tête obsolète, supplanté par + // la CSP et pouvant introduire des vulnérabilités dans les anciens navigateurs. // Politique de référent header('Referrer-Policy: strict-origin-when-cross-origin'); @@ -277,91 +312,8 @@ function setSecurityHeaders() { // Permissions Policy (feature policy) header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()'); - // Content Security Policy avec support Mastodon et PeerTube - $nonce = getCspNonce(); - $mastodonDomain = ''; - $peertubeDomain = ''; - - // Extraire le domaine Mastodon si configuré - if (defined('MASTODON_INSTANCE_URL')) { - $mastodonParsed = parse_url(MASTODON_INSTANCE_URL); - if ($mastodonParsed && isset($mastodonParsed['host'])) { - $mastodonDomain = $mastodonParsed['scheme'] . '://' . $mastodonParsed['host']; - } - } - - // Extraire le domaine PeerTube si configuré - if (defined('PEERTUBE_URL')) { - $peertubeParsed = parse_url(PEERTUBE_URL); - if ($peertubeParsed && isset($peertubeParsed['host'])) { - $peertubeDomain = $peertubeParsed['scheme'] . '://' . $peertubeParsed['host']; - } - } - - // Détecter si on est en développement local - $isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']); - - $csp = "default-src 'self'; "; - $csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; "; - $csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; "; - - // Images : autoriser les domaines connus plus HTTPS général pour le contenu fédéré - $imgSrc = "'self' data: " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : ''); - if ($isLocalDev) { - $imgSrc .= " https: http:"; - } else { - $imgSrc .= " https:"; - } - $csp .= "img-src " . $imgSrc . "; "; - - $csp .= "font-src 'self' https://cdnjs.cloudflare.com; "; - - // Frames : autoriser PeerTube uniquement - $frameSrc = "'self' " . ($peertubeDomain ? $peertubeDomain : ''); - if ($isLocalDev) { - $frameSrc .= " https: http:"; - } - $csp .= "frame-src " . $frameSrc . "; "; - - // Connexions : autoriser Mastodon, PeerTube et Plausible - $connectSrc = "'self' https://plausible.io " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : ''); - if ($isLocalDev) { - $connectSrc .= " ws: wss:"; // WebSockets pour le dev - } - $csp .= "connect-src " . $connectSrc . "; "; - - // Médias : autoriser 'self', Mastodon, PeerTube et S3 Mastodon - $mediaSrc = "'self'"; - - if ($mastodonDomain) { - $mediaSrc .= " " . $mastodonDomain; - } - - if ($peertubeDomain) { - $mediaSrc .= " " . $peertubeDomain; - } - - // Ajouter l'URL S3 Mastodon si configurée (pour les médias externalisés) - if (defined('MASTODON_S3_MEDIA_URL') && !empty(MASTODON_S3_MEDIA_URL)) { - $s3Parsed = parse_url(MASTODON_S3_MEDIA_URL); - if ($s3Parsed && isset($s3Parsed['host'])) { - $s3Domain = $s3Parsed['scheme'] . '://' . $s3Parsed['host']; - $mediaSrc .= " " . $s3Domain; - } - } - - if ($isLocalDev) { - $mediaSrc .= " https: http:"; - } else { - $mediaSrc .= " https:"; - } - $csp .= "media-src " . $mediaSrc . "; "; - - $csp .= "object-src 'none'; "; - $csp .= "base-uri 'self'; "; - $csp .= "frame-ancestors 'self';"; - - header('Content-Security-Policy: ' . $csp); + // Content Security Policy (domaines réellement utilisés uniquement) + header('Content-Security-Policy: ' . buildContentSecurityPolicy(getCspNonce())); // HTTPS strict transport security (seulement si HTTPS) if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { @@ -400,6 +352,61 @@ function validateAjaxOrigin() { return false; } +/** + * Limitation de débit simple par identifiant (typiquement l'IP cliente). + * + * Fenêtre fixe stockée dans un fichier par identifiant (cache/rate-limit/), + * verrouillée par flock() pour rester cohérente entre requêtes concurrentes. + * En cas d'indisponibilité du stockage, la requête est autorisée (fail-open) : + * l'endpoint reste protégé par les gardes AJAX, Origin et CSRF. + * + * @param string $identifier Identifiant du client (ex. REMOTE_ADDR) + * @param int $maxRequests Nombre maximal de requêtes dans la fenêtre + * @param int $windowSeconds Durée de la fenêtre en secondes + * @param string|null $dir Répertoire de stockage (surtout pour les tests) + * @return bool True si la requête est autorisée, false si la limite est atteinte + */ +function checkRateLimit($identifier, $maxRequests = 30, $windowSeconds = 60, $dir = null) { + $dir = $dir ?? (__DIR__ . '/../cache/rate-limit'); + if (!is_dir($dir) && !mkdir($dir, 0755, true)) { + error_log('SECURITY: rate limit storage unavailable: ' . $dir); + return true; + } + + $file = $dir . '/rl_' . hash('sha256', (string) $identifier) . '.json'; + $now = time(); + + $handle = fopen($file, 'c+'); + if ($handle === false) { + error_log('SECURITY: rate limit file unavailable: ' . $file); + return true; + } + + $allowed = true; + if (flock($handle, LOCK_EX)) { + $raw = stream_get_contents($handle); + $state = $raw !== false ? json_decode($raw, true) : null; + + if (!is_array($state) || !isset($state['reset']) || $now >= $state['reset']) { + $state = ['count' => 0, 'reset' => $now + $windowSeconds]; + } + + $state['count']++; + if ($state['count'] > $maxRequests) { + $allowed = false; + } + + rewind($handle); + ftruncate($handle, 0); + fwrite($handle, json_encode($state)); + fflush($handle); + flock($handle, LOCK_UN); + } + fclose($handle); + + return $allowed; +} + /** * Valide une URL distante (PeerTube, Castopod, Funkwhale…) pour prévenir * les attaques SSRF avant tout appel sortant. diff --git a/js/main.js b/js/main.js index af8cfb3..5130452 100644 --- a/js/main.js +++ b/js/main.js @@ -428,8 +428,6 @@ document.addEventListener('DOMContentLoaded', function() { videoType = 'recent'; } else if (sectionTitle.includes('tendances')) { videoType = 'trending'; - } else if (sectionTitle.includes('indépendance')) { - videoType = 'independence'; } else { // Vérifier si c'est une section de catégorie const categorySection = section.querySelector('[data-category-id]'); diff --git a/sw.js b/sw.js index 564118d..a7395a1 100644 --- a/sw.js +++ b/sw.js @@ -5,6 +5,10 @@ const STATIC_CACHE_NAME = 'annu-kute-ced-static-26072026-1946'; const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-26072026-1946'; +// Nombre maximal d'entrées conservées dans le cache dynamique (LRU : +// les entrées les moins récemment utilisées sont évincées en premier). +const DYNAMIC_CACHE_LIMIT = 50; + // Ressources à mettre en cache immédiatement const STATIC_ASSETS = [ '/', @@ -29,15 +33,6 @@ const STATIC_ASSETS = [ 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css' ]; -// Pages à mettre en cache -const PAGES_TO_CACHE = [ - '/', - '/index.php', - '/categories.php', - '/recherche.php', - '/mentions-legales.php' -]; - // Installation du Service Worker self.addEventListener('install', event => { console.log('Service Worker: Installation'); @@ -133,7 +128,13 @@ self.addEventListener('fetch', event => { if (response.status === 200) { const responseClone = response.clone(); caches.open(DYNAMIC_CACHE_NAME) - .then(cache => cache.put(request, responseClone)); + .then(cache => { + // cache.put() replace une entrée existante en fin de liste + // (clés ordonnées par insertion) : la taille limitée évince + // donc bien les entrées les moins récemment utilisées. + return cache.put(request, responseClone) + .then(() => limitCacheSize(cache, DYNAMIC_CACHE_LIMIT)); + }); } return response; }) @@ -175,6 +176,15 @@ self.addEventListener('fetch', event => { }); // Fonctions utilitaires +// Évince les plus anciennes clés tant que le cache dépasse maxItems entrées. +function limitCacheSize(cache, maxItems) { + return cache.keys().then(keys => { + if (keys.length > maxItems) { + return cache.delete(keys[0]).then(() => limitCacheSize(cache, maxItems)); + } + }); +} + function isStaticAsset(url) { return url.includes('/css/') || url.includes('/js/') ||