17 Commits
Author SHA1 Message Date
cedric 297f4a276a test: make dons-sync tests work without dons.php instance copy
Déploiement PROD / check (push) Successful in 3m27s
Déploiement PROD / deploy (push) Successful in 9s
2026-07-27 22:11:10 +04:00
cedric 745726dad7 feat: add performance benchmark script
Déploiement PROD / check (push) Failing after 3m36s
Déploiement PROD / deploy (push) Has been skipped
2026-07-27 09:16:05 +04:00
cedric d0f9ee4e6b feat: real API-driven search pagination 2026-07-27 09:15:47 +04:00
cedric 0e6506b636 refactor: split config.php into modules and lazy-load categories 2026-07-27 09:15:38 +04:00
cedric 7cbb7334f0 security: SRI Font Awesome, CSP restrictions, remove X-XSS-Protection, rate limiting 2026-07-27 09:15:20 +04:00
cedric ddd82fdff6 fix: ISO countdown date, IntlDateFormatter and UTF-8 truncation 2026-07-27 09:15:10 +04:00
cedric cb13fcfb9f perf: cache locking, TTL map, purge script and SW LRU limit 2026-07-27 09:15:00 +04:00
cedric 8181dbd57c chore: remove dead code and unused legacy files 2026-07-27 09:14:52 +04:00
cedric b994383bf4 perf: replace sleep(5) with 429 backoff and add cache warm-up script 2026-07-27 02:06:06 +04:00
cedric 3b074f00d8 test(e2e): configure CSRF_SECRET for E2E tests 2026-07-27 01:55:36 +04:00
cedric 71e367b86f fix: block doc artifacts and add error templates 2026-07-27 01:55:30 +04:00
cedric d81dd71a46 fix: markdown double-encoding and robust date/API access 2026-07-27 01:55:21 +04:00
cedric d9da213341 fix: warn on default CSRF_SECRET and add SSRF guards 2026-07-27 01:55:04 +04:00
cedric df55445480 fix: CSP compliance for inline styles and scripts 2026-07-27 01:54:57 +04:00
cedric 4778f0e9e3 fix(security): safe JSON-LD encoding and URL escaping 2026-07-27 01:54:49 +04:00
cedric a28ba8bfec fix(security): systematic output escaping with e() and video-card partial 2026-07-27 01:54:42 +04:00
cedric 442c262539 fix: load markdown in direct.php, allow video-channels, guard modals 2026-07-27 01:54:34 +04:00
61 changed files with 4110 additions and 1957 deletions
+5
View File
@@ -8,6 +8,8 @@ robots.txt
site.webmanifest site.webmanifest
mentions-legales.php mentions-legales.php
dons.php dons.php
404.php
500.php
# Fichiers de l'IDE/éditeur # Fichiers de l'IDE/éditeur
.vscode/ .vscode/
@@ -37,6 +39,9 @@ uploads/*
# vendor/ # vendor/
# node_modules/ # node_modules/
# Rapport d'audit interne OKI (ne pas committer)
audit/
# Artefacts de tests locaux # Artefacts de tests locaux
.pytest_cache/ .pytest_cache/
__pycache__/ __pycache__/
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* Page d'erreur 404 - Modèle pour ANNU KUTE CED
*
* Pour l'activer : copiez ce fichier vers 404.php
*
* Utilisée automatiquement par dons.php lorsque les dons sont désactivés.
* Peut aussi servir de page d'erreur globale, par exemple :
* - Apache : ErrorDocument 404 /404.php
* - nginx : error_page 404 /404.php;
*/
// Chargement autonome si la page est appelée directement
// (dons.php a déjà chargé ces fichiers avant d'inclure celui-ci)
if (!defined('SITE_NAME')) {
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/security.php';
}
http_response_code(404);
// Incluse depuis dons.php, la page est servie avant setSecurityHeaders() :
// on applique les en-têtes de sécurité ici (aucune sortie n'a encore eu lieu).
if (function_exists('setSecurityHeaders')) {
setSecurityHeaders();
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page introuvable - <?php echo SITE_NAME; ?></title>
<meta name="description" content="La page demandée est introuvable ou n'est pas disponible.">
<meta name="robots" content="noindex">
<!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime(__DIR__ . '/css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#FF0000">
<!-- Script pour éviter le flash en mode sombre -->
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php include __DIR__ . '/includes/sidebar.php'; ?>
<main class="main-content">
<?php include __DIR__ . '/includes/header.php'; ?>
<div class="container">
<section class="error-page">
<p class="error-code" aria-hidden="true">404</p>
<h1>Page introuvable</h1>
<p>La page que vous recherchez n'existe pas, a été déplacée ou n'est pas disponible pour le moment.</p>
<p class="error-actions">
<a href="index.php" class="error-home-link"><i class="fas fa-home" aria-hidden="true"></i> Retour à l'accueil</a>
</p>
</section>
</div>
</main>
<?php include __DIR__ . '/includes/footer.php'; ?>
<?php include __DIR__ . '/includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* Page d'erreur 500 - Modèle pour ANNU KUTE CED
*
* Pour l'activer : copiez ce fichier vers 500.php
*
* Utilisée automatiquement par dons.php lorsqu'aucune plateforme de don
* n'est configurée. Peut aussi servir de page d'erreur globale, par exemple :
* - Apache : ErrorDocument 500 /500.php
* - nginx : error_page 500 /500.php;
*
* N'affichez jamais le détail de l'erreur (trace, chemin serveur…) sur
* cette page : il est consigné dans le journal d'erreurs du serveur.
*/
// Chargement autonome si la page est appelée directement
// (dons.php a déjà chargé ces fichiers avant d'inclure celui-ci)
if (!defined('SITE_NAME')) {
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/security.php';
}
http_response_code(500);
// Incluse depuis dons.php, la page est servie avant setSecurityHeaders() :
// on applique les en-têtes de sécurité ici (aucune sortie n'a encore eu lieu).
if (function_exists('setSecurityHeaders')) {
setSecurityHeaders();
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Erreur interne - <?php echo SITE_NAME; ?></title>
<meta name="description" content="Une erreur interne est survenue. Veuillez réessayer plus tard.">
<meta name="robots" content="noindex">
<!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime(__DIR__ . '/css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#FF0000">
<!-- Script pour éviter le flash en mode sombre -->
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php include __DIR__ . '/includes/sidebar.php'; ?>
<main class="main-content">
<?php include __DIR__ . '/includes/header.php'; ?>
<div class="container">
<section class="error-page">
<p class="error-code" aria-hidden="true">500</p>
<h1>Une erreur est survenue</h1>
<p>Le site rencontre un problème temporaire. L'incident a été consigné
et sera examiné par l'équipe technique. Merci de réessayer dans quelques instants.</p>
<p class="error-actions">
<a href="index.php" class="error-home-link"><i class="fas fa-home" aria-hidden="true"></i> Retour à l'accueil</a>
</p>
</section>
</div>
</main>
<?php include __DIR__ . '/includes/footer.php'; ?>
<?php include __DIR__ . '/includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
</body>
</html>
+15
View File
@@ -319,6 +319,21 @@ grep STATIC_CACHE_NAME sw.js # le suffixe de version a été bumpé à l'heure d
Le bump de version dans `sw.js` est fait *sur le serveur uniquement* : le dépôt garde sa valeur de référence, le working tree du serveur est nettoyé (`git checkout -- sw.js`) avant chaque pull pour garantir le fast-forward. Le bump de version dans `sw.js` est fait *sur le serveur uniquement* : le dépôt garde sa valeur de référence, le working tree du serveur est nettoyé (`git checkout -- sw.js`) avant chaque pull pour garantir le fast-forward.
== 🔥 Préchauffage du cache
Pour éviter que le premier visiteur ne paye le coût des appels API externes (PeerTube, Castopod) sur cache froid, préchauffez le cache par cron :
[source,bash]
----
# Toutes les 5 minutes, avec l'utilisateur deploy
sudo -u deploy crontab -e
# Ajouter :
*/5 * * * * php /var/www/annu-kute-ced/scripts/warm-cache.php >/dev/null 2>&1
----
Le script `scripts/warm-cache.php` appelle les fonctions de récupération des vidéos, catégories, podcasts et live, ce qui remplit `cache/api/` avant l'arrivée des visiteurs.
== 🧰 Dépannage == 🧰 Dépannage
[cols="1,3",options="header"] [cols="1,3",options="header"]
+48 -22
View File
@@ -182,9 +182,6 @@ Toutes ces sources sont modifiables dans `includes/config.local.php` (voir <<fr-
│ ├── sidebar.php # Navigation latérale │ ├── sidebar.php # Navigation latérale
│ ├── mobile-menu.php # Menu mobile coulissant │ ├── mobile-menu.php # Menu mobile coulissant
│ ├── hero-section.php # Bannière d'accueil (live/vidéo/playlist) │ ├── hero-section.php # Bannière d'accueil (live/vidéo/playlist)
│ ├── featured-videos.php # ⚠️ legacy, non utilisé par les pages actuelles
│ ├── recent-videos.php # ⚠️ legacy, non utilisé par les pages actuelles
│ ├── pwa-init.php # ⚠️ legacy, PWA désormais inline dans index.php
│ └── lib/ │ └── lib/
│ └── markdown.php # Rendu Markdown (sous-ensemble PeerTube) │ └── markdown.php # Rendu Markdown (sous-ensemble PeerTube)
├── js/ ├── js/
@@ -197,7 +194,9 @@ Toutes ces sources sont modifiables dans `includes/config.local.php` (voir <<fr-
│ ├── pleroma-adapter.js # Adaptateur Pleroma → API Mastodon │ ├── pleroma-adapter.js # Adaptateur Pleroma → API Mastodon
│ └── pwa-update.js # Enregistrement SW + modal de mise à jour │ └── pwa-update.js # Enregistrement SW + modal de mise à jour
├── scripts/ ├── scripts/
── check.sh # Vérifications qualité en local (avant push) ── benchmark.php # Benchmark TTFB froid/chaud des pages clés
│ ├── check.sh # Vérifications qualité en local (avant push)
│ └── warm-cache.php # Préchauffage du cache API (cron)
├── uploads/ # Images d'annonces (non versionné, hors .gitkeep) ├── uploads/ # Images d'annonces (non versionné, hors .gitkeep)
├── index.php # Page d'accueil (agrégateur) ├── index.php # Page d'accueil (agrégateur)
├── video.php # Page de lecture d'une vidéo ├── video.php # Page de lecture d'une vidéo
@@ -415,7 +414,7 @@ Valeurs par défaut issues de `includes/config.default.php`.
| Nombre total d'épisodes affichés | Nombre total d'épisodes affichés
|=== |===
WARNING: Castopod applique un *rate limit* strict (HTTP 429) sur les flux RSS. Le code patiente 5 s entre chaque podcast. *Activez le cache* (`CACHE_ENABLED`) pour éviter ces délais à chaque page. WARNING: Castopod applique un *rate limit* strict (HTTP 429) sur les flux RSS. Le code applique un backoff exponentiel (5 s, 10 s, 15 s) uniquement en cas de 429. *Activez le cache* (`CACHE_ENABLED`) et préchauffez-le avec `scripts/warm-cache.php` pour éviter ces délais à chaque page.
.Funkwhale (musique, optionnel) .Funkwhale (musique, optionnel)
[cols="2,2,3",options="header"] [cols="2,2,3",options="header"]
@@ -632,10 +631,6 @@ TIP: Si vous déployez une instance indépendante, remplacez `LIBERAPAY_URL` par
|=== |===
| Constante | Défaut | Description | Constante | Défaut | Description
| `TAG_INDEPENDENCE`
| `'indépendance'`
| Tag de la fonction `getIndependenceVideos()` (pas de section d'accueil associée actuellement)
| `SHORTS_MAX_DURATION` | `SHORTS_MAX_DURATION`
| `180` | `180`
| Durée max d'un short (s) ; ratio portrait (`aspectRatio ≤ 1`) aussi requis | Durée max d'un short (s) ; ratio portrait (`aspectRatio ≤ 1`) aussi requis
@@ -674,9 +669,9 @@ TIP: Si vous déployez une instance indépendante, remplacez `LIBERAPAY_URL` par
| `6` | `6`
| Vidéos chargées par clic sur « Voir plus » | Vidéos chargées par clic sur « Voir plus »
| `FEATURED_VIDEOS_COUNT` / `INDEPENDENCE_VIDEOS_COUNT` | `FEATURED_VIDEOS_COUNT`
| `6` | `6`
| Réservés (sections non affichées actuellement) | Réservé (section non affichée actuellement)
|=== |===
.Cache .Cache
@@ -695,6 +690,8 @@ TIP: Si vous déployez une instance indépendante, remplacez `LIBERAPAY_URL` par
NOTE: Le cache de l'API PeerTube et de WordPress (`includes/simple-cache.php`) est *toujours actif*, indépendamment de `CACHE_ENABLED`, avec des TTL par endpoint (catégories 1 h, vidéos/recherche 10 min, articles WP 15 min, comptes 5 min). Les fichiers sont stockés dans `cache/api/`. NOTE: Le cache de l'API PeerTube et de WordPress (`includes/simple-cache.php`) est *toujours actif*, indépendamment de `CACHE_ENABLED`, avec des TTL par endpoint (catégories 1 h, vidéos/recherche 10 min, articles WP 15 min, comptes 5 min). Les fichiers sont stockés dans `cache/api/`.
TIP: Pour éviter que le premier visiteur ne paye le coût des appels API sur cache froid, préchauffez le cache via cron : `php /var/www/annu-kute-ced/scripts/warm-cache.php` (toutes les 5 minutes par exemple).
.Sécurité .Sécurité
[cols="2,2,3",options="header"] [cols="2,2,3",options="header"]
|=== |===
@@ -967,6 +964,22 @@ python3 -m venv /tmp/test-venv
NOTE: Les tests E2E démarrent un serveur PHP local (`php -S`) et s'appuient sur la configuration par défaut. Les appels réseau vers PeerTube sont sautés proprement si l'instance est injoignable. NOTE: Les tests E2E démarrent un serveur PHP local (`php -S`) et s'appuient sur la configuration par défaut. Les appels réseau vers PeerTube sont sautés proprement si l'instance est injoignable.
==== ⏱️ Benchmark de performance
Le script `scripts/benchmark.php` mesure le TTFB (_time to first byte_) des pages clés — accueil (`/`), page vidéo (`/video.php?id=…`) et page catégorie (`/categories.php?id=…`) — dans deux conditions :
- *à froid* : cache API vidé (le premier visiteur paie le coût des appels API externes) ;
- *à chaud* : cache API rempli (moyenne sur 3 requêtes).
Il démarre un serveur PHP local (`php -S`), découvre automatiquement une vidéo et une catégorie réelles via l'API PeerTube (les pages correspondantes sont ignorées si l'instance est injoignable) et affiche la taille du cache API avant et après les mesures.
[source,bash]
----
php scripts/benchmark.php
----
NOTE: Le script vide le cache API (`cache/api`) : à utiliser hors production, ou suivi d'un préchauffage (`scripts/warm-cache.php`).
=== 📜 Licence === 📜 Licence
Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE* Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*
@@ -1152,9 +1165,6 @@ All of these sources can be changed in `includes/config.local.php` (see <<en-con
│ ├── sidebar.php # Side navigation │ ├── sidebar.php # Side navigation
│ ├── mobile-menu.php # Slide-in mobile menu │ ├── mobile-menu.php # Slide-in mobile menu
│ ├── hero-section.php # Homepage banner (live/video/playlist) │ ├── hero-section.php # Homepage banner (live/video/playlist)
│ ├── featured-videos.php # ⚠️ legacy, unused by current pages
│ ├── recent-videos.php # ⚠️ legacy, unused by current pages
│ ├── pwa-init.php # ⚠️ legacy, PWA now inlined in index.php
│ └── lib/ │ └── lib/
│ └── markdown.php # Markdown renderer (PeerTube-flavored subset) │ └── markdown.php # Markdown renderer (PeerTube-flavored subset)
├── js/ ├── js/
@@ -1167,7 +1177,9 @@ All of these sources can be changed in `includes/config.local.php` (see <<en-con
│ ├── pleroma-adapter.js # Pleroma → Mastodon API adapter │ ├── pleroma-adapter.js # Pleroma → Mastodon API adapter
│ └── pwa-update.js # SW registration + update modal │ └── pwa-update.js # SW registration + update modal
├── scripts/ ├── scripts/
── check.sh # Local quality checks (before pushing) ── benchmark.php # Cold/warm TTFB benchmark of key pages
│ ├── check.sh # Local quality checks (before pushing)
│ └── warm-cache.php # API cache warm-up (cron)
├── uploads/ # Announcement images (not versioned, except .gitkeep) ├── uploads/ # Announcement images (not versioned, except .gitkeep)
├── index.php # Homepage (aggregator) ├── index.php # Homepage (aggregator)
├── video.php # Video playback page ├── video.php # Video playback page
@@ -1385,7 +1397,7 @@ Default values from `includes/config.default.php`.
| Total number of episodes displayed | Total number of episodes displayed
|=== |===
WARNING: Castopod enforces a strict *rate limit* (HTTP 429) on RSS feeds. The code waits 5 s between podcasts. *Enable the cache* (`CACHE_ENABLED`) to avoid these delays on every page. WARNING: Castopod enforces a strict *rate limit* (HTTP 429) on RSS feeds. The code applies exponential backoff (5 s, 10 s, 15 s) only on HTTP 429. *Enable the cache* (`CACHE_ENABLED`) and warm it with `scripts/warm-cache.php` to avoid these delays on every page.
.Funkwhale (music, optional) .Funkwhale (music, optional)
[cols="2,2,3",options="header"] [cols="2,2,3",options="header"]
@@ -1602,10 +1614,6 @@ TIP: If you deploy an independent instance, replace `LIBERAPAY_URL` with your ow
|=== |===
| Constant | Default | Description | Constant | Default | Description
| `TAG_INDEPENDENCE`
| `'indépendance'`
| Tag used by `getIndependenceVideos()` (no associated homepage section currently)
| `SHORTS_MAX_DURATION` | `SHORTS_MAX_DURATION`
| `180` | `180`
| Max short duration (s); portrait ratio (`aspectRatio ≤ 1`) also required | Max short duration (s); portrait ratio (`aspectRatio ≤ 1`) also required
@@ -1644,9 +1652,9 @@ TIP: If you deploy an independent instance, replace `LIBERAPAY_URL` with your ow
| `6` | `6`
| Videos loaded per "Load more" click | Videos loaded per "Load more" click
| `FEATURED_VIDEOS_COUNT` / `INDEPENDENCE_VIDEOS_COUNT` | `FEATURED_VIDEOS_COUNT`
| `6` | `6`
| Reserved (sections not currently displayed) | Reserved (section not currently displayed)
|=== |===
.Cache .Cache
@@ -1665,6 +1673,8 @@ TIP: If you deploy an independent instance, replace `LIBERAPAY_URL` with your ow
NOTE: The PeerTube API and WordPress cache (`includes/simple-cache.php`) is *always on*, regardless of `CACHE_ENABLED`, with per-endpoint TTLs (categories 1 h, videos/search 10 min, WP posts 15 min, accounts 5 min). Files are stored in `cache/api/`. NOTE: The PeerTube API and WordPress cache (`includes/simple-cache.php`) is *always on*, regardless of `CACHE_ENABLED`, with per-endpoint TTLs (categories 1 h, videos/search 10 min, WP posts 15 min, accounts 5 min). Files are stored in `cache/api/`.
TIP: To prevent the first visitor from paying the cost of API calls on a cold cache, warm the cache via cron: `php /var/www/annu-kute-ced/scripts/warm-cache.php` (every 5 minutes for example).
.Security .Security
[cols="2,2,3",options="header"] [cols="2,2,3",options="header"]
|=== |===
@@ -1937,6 +1947,22 @@ python3 -m venv /tmp/test-venv
NOTE: E2E tests start a local PHP server (`php -S`) and rely on the default configuration. Network calls to PeerTube are skipped gracefully if the instance is unreachable. NOTE: E2E tests start a local PHP server (`php -S`) and rely on the default configuration. Network calls to PeerTube are skipped gracefully if the instance is unreachable.
==== ⏱️ Performance benchmark
The `scripts/benchmark.php` script measures the TTFB (_time to first byte_) of key pages — homepage (`/`), video page (`/video.php?id=…`) and category page (`/categories.php?id=…`) — under two conditions:
- *cold*: API cache emptied (the first visitor pays the cost of external API calls);
- *warm*: API cache filled (average over 3 requests).
It starts a local PHP server (`php -S`), automatically discovers a real video and category through the PeerTube API (the corresponding pages are skipped if the instance is unreachable) and reports the API cache size before and after the measurements.
[source,bash]
----
php scripts/benchmark.php
----
NOTE: The script purges the API cache (`cache/api`): use it outside production, or follow it with a cache warmup (`scripts/warm-cache.php`).
=== 📜 License === 📜 License
Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE* Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*
+12 -32
View File
@@ -2,6 +2,13 @@
// Inclure la configuration // Inclure la configuration
require_once '../includes/config.php'; 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 // Vérifier que la requête est faite par AJAX
if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') { if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') {
http_response_code(403); // Accès non autorisé 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 // Récupérer les paramètres
$type = isset($_GET['type']) ? $_GET['type'] : ''; $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; $categoryId = isset($_GET['category']) ? intval($_GET['category']) : 0;
// Vérifier que le type est valide // 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 http_response_code(400); // Requête incorrecte
echo json_encode(['error' => 'Type de vidéos non valide']); echo json_encode(['error' => 'Type de vidéos non valide']);
exit; exit;
@@ -69,17 +78,6 @@ switch ($type) {
$videos = formatVideosData($data['data'] ?? []); $videos = formatVideosData($data['data'] ?? []);
break; 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': case 'category':
// Récupérer les vidéos de la catégorie // Récupérer les vidéos de la catégorie
$data = callPeerTubeApi('videos', [ $data = callPeerTubeApi('videos', [
@@ -97,25 +95,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
+7 -34
View File
@@ -19,8 +19,8 @@ setSecurityHeaders();
$categoryId = isset($_GET['id']) ? $_GET['id'] : null; $categoryId = isset($_GET['id']) ? $_GET['id'] : null;
$categoryId = $categoryId ? validateCategoryId($categoryId) : null; $categoryId = $categoryId ? validateCategoryId($categoryId) : null;
// Récupérer les catégories disponibles // Récupérer les catégories disponibles (chargement paresseux, ARC-2)
$allCategories = PEERTUBE_CATEGORIES; $allCategories = getPeertubeCategories();
// Récupérer les vidéos de la catégorie si un ID est fourni // Récupérer les vidéos de la catégorie si un ID est fourni
if ($categoryId && isset($allCategories[$categoryId])) { if ($categoryId && isset($allCategories[$categoryId])) {
@@ -46,7 +46,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<meta name="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="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.">
<link rel="canonical" href="<?php echo getBaseUrl() . '/categories.php?id=' . $categoryId; ?>"> <link rel="canonical" href="<?php echo getBaseUrl() . '/categories.php?id=' . $categoryId; ?>">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -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>
+7
View File
@@ -22,6 +22,13 @@ RewriteRule ^(includes|cache|docs|conf)/ - [F,L]
Require all denied Require all denied
</FilesMatch> </FilesMatch>
# Bloquer les artefacts de documentation à la racine web
# (DEPLOY.adoc/html/pdf, README.adoc…) : ils divulguent le plan
# d'infrastructure et ne doivent jamais être servis s'ils sont copiés
# sur l'hébergement. [^/]+ = fichiers à la racine uniquement, les
# éventuels .html légitimes en sous-répertoire restent servis.
RewriteRule ^[^/]+\.(adoc|html|pdf)$ - [F,L,NC]
# Empêcher l'exploration des répertoires # Empêcher l'exploration des répertoires
Options -Indexes Options -Indexes
+10
View File
@@ -40,6 +40,16 @@ server {
return 404; return 404;
} }
# Bloquer les artefacts de documentation à la racine web
# (DEPLOY.adoc/html/pdf, README.adoc…) : ils divulguent le plan
# d'infrastructure et ne doivent jamais être servis s'ils sont copiés
# sur l'hébergement. [^/]+ = fichiers à la racine uniquement, les
# éventuels .html légitimes en sous-répertoire restent servis.
location ~* ^/[^/]+\.(adoc|html|pdf)$ {
deny all;
return 404;
}
# Bloquer l'accès aux fichiers cachés # Bloquer l'accès aux fichiers cachés
location ~ /\. { location ~ /\. {
deny all; deny all;
+18 -16
View File
@@ -3,6 +3,8 @@
require_once 'includes/config.php'; require_once 'includes/config.php';
// Inclure les fonctions de sécurité // Inclure les fonctions de sécurité
require_once 'includes/security.php'; require_once 'includes/security.php';
// Inclure les fonctions de données structurées (getBaseUrl, getCurrentUrl)
require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité // Appliquer les en-têtes de sécurité
setSecurityHeaders(); setSecurityHeaders();
?> ?>
@@ -14,11 +16,11 @@ setSecurityHeaders();
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>"> <meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title> <title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?> Ouverture prochaine."> <meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?> Ouverture prochaine.">
<link rel="canonical" href="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/countdown.php'; ?>"> <link rel="canonical" href="<?php echo getBaseUrl() . '/countdown.php'; ?>">
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>"> <link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -38,12 +40,10 @@ setSecurityHeaders();
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta property="og:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine"> <meta property="og:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
<meta property="og:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php <meta property="og:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE); echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE));
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp());
?>. Restez connectés !"> ?>. Restez connectés !">
<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">
@@ -52,15 +52,19 @@ setSecurityHeaders();
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine"> <meta name="twitter:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
<meta name="twitter:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php <meta name="twitter:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE); echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE));
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp());
?>. Restez connectés !"> ?>. Restez connectés !">
<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'; ?>">
<!-- Configuration JavaScript --> <!-- Configuration JavaScript : date cible en ISO 8601 avec fuseau horaire -->
<script nonce="<?php echo getCspNonce(); ?>"> <script nonce="<?php echo getCspNonce(); ?>">
const COUNTDOWN_TARGET_DATE = '<?php echo defined('COUNTDOWN_TARGET_DATE') ? COUNTDOWN_TARGET_DATE : '2025-10-11 00:00:00'; ?>'; const COUNTDOWN_TARGET_DATE = '<?php
$countdownTargetDate = new DateTime(
defined('COUNTDOWN_TARGET_DATE') ? COUNTDOWN_TARGET_DATE : '2025-10-11 00:00:00',
new DateTimeZone(DEFAULT_TIMEZONE)
);
echo $countdownTargetDate->format(DateTime::ATOM);
?>';
</script> </script>
</head> </head>
<body> <body>
@@ -98,9 +102,7 @@ setSecurityHeaders();
<!-- Message --> <!-- Message -->
<div class="countdown-message"> <div class="countdown-message">
<p>La plateforme ouvrira ses portes le <strong><?php <p>La plateforme ouvrira ses portes le <strong><?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE); echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE), true);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y à %H:%M', $targetDate->getTimestamp());
?></strong> (heure de La Réunion).</p> ?></strong> (heure de La Réunion).</p>
<!-- Heures des autres territoires --> <!-- Heures des autres territoires -->
+40
View File
@@ -18,6 +18,11 @@
border: 0 !important; border: 0 !important;
} }
/* Masquage d'éléments (remplace les attributs style="display: none;" bloqués par la CSP) */
.is-hidden {
display: none !important;
}
:root { :root {
--primary-red: #FF0000; --primary-red: #FF0000;
--primary-green: #008000; --primary-green: #008000;
@@ -3576,3 +3581,38 @@ i.icon-mastodon,
text-align: center; text-align: center;
margin: 4px 0 10px; margin: 4px 0 10px;
} }
/* Pages d'erreur (404.php / 500.php) */
.error-page {
text-align: center;
padding: 80px 20px;
}
.error-page .error-code {
font-size: 4.5rem;
font-weight: bold;
line-height: 1;
color: var(--primary-red);
margin-bottom: 10px;
}
.error-page h1 {
margin-bottom: 15px;
}
.error-page > p:not(.error-code):not(.error-actions) {
max-width: 600px;
margin: 0 auto 25px;
}
.error-page .error-home-link {
display: inline-block;
padding: 10px 25px;
background-color: var(--primary-red);
color: #ffffff;
border-radius: 5px;
}
.error-page .error-home-link:hover {
opacity: 0.85;
}
+9 -104
View File
@@ -9,6 +9,8 @@ if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
exit; exit;
} }
// Inclure le convertisseur Markdown
require_once 'includes/lib/markdown.php';
// Inclure les fonctions de données structurées // Inclure les fonctions de données structurées
require_once 'includes/structured-data.php'; require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité // Appliquer les en-têtes de sécurité
@@ -26,7 +28,7 @@ $liveStream = getLiveStream();
<meta name="description" content="Suivez nos diffusions en direct sur <?php echo SITE_NAME; ?>. Contenu en temps réel, discussions et événements exclusifs."> <meta name="description" content="Suivez nos diffusions en direct sur <?php echo SITE_NAME; ?>. Contenu en temps réel, discussions et événements exclusifs.">
<link rel="canonical" href="<?php echo getBaseUrl(); ?>/direct.php"> <link rel="canonical" href="<?php echo getBaseUrl(); ?>/direct.php">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -127,7 +129,7 @@ $liveStream = getLiveStream();
</div> </div>
<div class="live-player"> <div class="live-player">
<iframe <iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1" src="<?php echo e(PEERTUBE_URL . '/videos/embed/' . $liveStream['id'] . '?autoplay=1'); ?>"
frameborder="0" frameborder="0"
allowfullscreen="allowfullscreen" allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen" allow="autoplay; fullscreen"
@@ -142,9 +144,9 @@ $liveStream = getLiveStream();
<i class="fas fa-user-circle"></i> <i class="fas fa-user-circle"></i>
</div> </div>
<?php else: ?> <?php else: ?>
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="<?php echo $liveStream['channel']; ?>" class="channel-avatar"> <img src="<?php echo e($liveStream['channelAvatar']); ?>" alt="<?php echo e($liveStream['channel']); ?>" class="channel-avatar">
<?php endif; ?> <?php endif; ?>
<span class="channel-name"><?php echo $liveStream['channel']; ?></span> <span class="channel-name"><?php echo e($liveStream['channel']); ?></span>
</div> </div>
<?php if (!empty($liveStream['description'])): ?> <?php if (!empty($liveStream['description'])): ?>
<div class="live-description"> <div class="live-description">
@@ -158,106 +160,9 @@ $liveStream = getLiveStream();
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true; $showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) { if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live // Afficher l'annonce du prochain live (partial partagé avec hero-section.php)
// Définir l'image de fond si disponible $nextLiveVariant = 'page';
$bgImageStyle = ''; include 'includes/partials/next-live.php';
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
}
?>
<div class="next-live-announcement" style="<?php echo $bgImageStyle; ?>" nonce="<?php echo getCspNonce(); ?>">
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="next-live-image-container">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
class="next-live-image">
</div>
<?php endif; ?>
<div class="next-live-content">
<i class="fas fa-calendar-alt"></i>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$dayFormatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
DEFAULT_TIMEZONE,
IntlDateFormatter::GREGORIAN,
'EEEE d MMMM'
);
$formattedDay = $dayFormatter->format($liveDate);
$formattedDay = ucfirst($formattedDay);
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
} else {
$dynamicTitle = NEXT_LIVE_TITLE;
}
?>
<h1><?php echo htmlspecialchars($dynamicTitle); ?></h1>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveHour = $liveDate->format('H\hi');
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
} else {
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
}
?>
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
<?php if (!empty(NEXT_LIVE_DATE)): ?>
<div class="next-live-datetime">
<p class="next-live-date">
<i class="fas fa-clock"></i>
<?php
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::SHORT,
DEFAULT_TIMEZONE
);
echo $formatter->format($liveDate);
$offset = $liveDate->format('P');
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
?>
</p>
<!-- Autres fuseaux horaires -->
<div class="next-live-timezones">
<?php
// Ordre croissant : du plus en retard au plus en avance
$timezones = [
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$liveDateLocal = clone $liveDate;
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
// Vérifier si c'est un jour différent
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-shift">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-shift">-1j</span>';
}
?>
<span class="timezone-item">
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
</div>
</div>
<?php
} else { } else {
?> ?>
<div class="no-live-message"> <div class="no-live-message">
+13 -11
View File
@@ -83,7 +83,7 @@ function linkUrlsInText(string $text): string {
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="css/donations.css?v=<?php echo filemtime('css/donations.css'); ?>"> <link rel="stylesheet" href="css/donations.css?v=<?php echo filemtime('css/donations.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -123,7 +123,7 @@ function linkUrlsInText(string $text): string {
?> ?>
<!-- Script pour éviter le flash en mode sombre --> <!-- Script pour éviter le flash en mode sombre -->
<script> <script nonce="<?php echo getCspNonce(); ?>">
(function() { (function() {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -223,10 +223,10 @@ function linkUrlsInText(string $text): string {
<!-- Onglets don ponctuel / mensuel --> <!-- Onglets don ponctuel / mensuel -->
<div class="donation-tabs"> <div class="donation-tabs">
<button class="tab-btn active" onclick="switchTab('onetime')"> <button class="tab-btn active" data-tab="onetime">
<i class="fas fa-hand-holding-heart"></i> Don ponctuel <i class="fas fa-hand-holding-heart"></i> Don ponctuel
</button> </button>
<button class="tab-btn" onclick="switchTab('monthly')"> <button class="tab-btn" data-tab="monthly">
<i class="fas fa-sync-alt"></i> Don mensuel <i class="fas fa-sync-alt"></i> Don mensuel
</button> </button>
</div> </div>
@@ -308,20 +308,22 @@ function linkUrlsInText(string $text): string {
<?php include 'includes/mobile-menu.php'; ?> <?php include 'includes/mobile-menu.php'; ?>
<!-- Script pour les onglets --> <!-- Script pour les onglets -->
<script> <script nonce="<?php echo getCspNonce(); ?>">
function switchTab(tab) { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
// Cacher tous les contenus et désactiver tous les boutons // Cacher tous les contenus et désactiver tous les boutons
document.querySelectorAll('.tab-content').forEach(content => { document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active'); content.classList.remove('active');
}); });
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(otherBtn => {
btn.classList.remove('active'); otherBtn.classList.remove('active');
}); });
// Afficher le contenu sélectionné et activer le bouton // Afficher le contenu sélectionné et activer le bouton
document.getElementById(tab + '-tab').classList.add('active'); document.getElementById(this.dataset.tab + '-tab').classList.add('active');
event.target.classList.add('active'); this.classList.add('active');
} });
});
</script> </script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
+4 -3
View File
@@ -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_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('PEERTUBE_DISPLAY_NAME')) define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');
if (!defined('API_KEY')) define('API_KEY', ''); 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 if (!defined('SHORTS_MAX_DURATION')) define('SHORTS_MAX_DURATION', 180); // 3 minutes max pour les shorts
// Pagination et affichage // 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')) define('SHORTS_COUNT', 6);
if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100); if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100);
if (!defined('TRENDING_VIDEOS_COUNT')) define('TRENDING_VIDEOS_COUNT', 6); 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('CATEGORY_VIDEOS_COUNT')) define('CATEGORY_VIDEOS_COUNT', 6);
if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6); if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6);
@@ -105,8 +103,11 @@ if (!defined('CACHE_DURATION')) define('CACHE_DURATION', 3600); // En secondes (
// Clé secrète utilisée pour signer les tokens CSRF stateless. // Clé secrète utilisée pour signer les tokens CSRF stateless.
// À remplacer impérativement dans config.local.php par une valeur aléatoire // À remplacer impérativement dans config.local.php par une valeur aléatoire
// propre à l'instance (ex. bin2hex(random_bytes(32))). // propre à l'instance (ex. bin2hex(random_bytes(32))).
// Si cette valeur par défaut est conservée, getCsrfSecret() (security.php)
// enregistre un avertissement critique et génère un secret éphémère propre
// au processus : les tokens sont invalidés à chaque redémarrage.
if (!defined('CSRF_SECRET')) { if (!defined('CSRF_SECRET')) {
define('CSRF_SECRET', 'change-me-in-config-local-php'); define('CSRF_SECRET', CSRF_SECRET_PLACEHOLDER);
} }
// ========================================= // =========================================
-6
View File
@@ -71,9 +71,6 @@ define('APP_HOST_NAME', 'example.com');
// Filtres et tags // Filtres et tags
// ========================================= // =========================================
// Tag pour les vidéos sur l'indépendance
// define('TAG_INDEPENDANCE', 'indépendance');
// Tag pour les shorts // Tag pour les shorts
// define('TAG_SHORT', 'short'); // define('TAG_SHORT', 'short');
@@ -113,9 +110,6 @@ define('SHORTS_MAX_DURATION', 180); // 3 minutes
// Nombre de vidéos tendances // Nombre de vidéos tendances
// define('TRENDING_VIDEOS_COUNT', 6); // define('TRENDING_VIDEOS_COUNT', 6);
// Nombre de vidéos indépendance
// define('INDEPENDENCE_VIDEOS_COUNT', 6);
// Nombre de vidéos par catégorie // Nombre de vidéos par catégorie
// define('CATEGORY_VIDEOS_COUNT', 6); // define('CATEGORY_VIDEOS_COUNT', 6);
+25 -986
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -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' : '');
}
}
?>
+1
View File
@@ -1,4 +1,5 @@
<!-- Footer --> <!-- Footer -->
<?php require_once __DIR__ . '/nav-context.php'; ?>
<div class="footer"> <div class="footer">
<div class="footer-header"> <div class="footer-header">
<div class="footer-logo"> <div class="footer-logo">
+1 -1
View File
@@ -74,7 +74,7 @@
<button id="theme-toggle" class="icon-button" aria-label="Basculer entre mode clair et sombre" title="Changer le thème"> <button id="theme-toggle" class="icon-button" aria-label="Basculer entre mode clair et sombre" title="Changer le thème">
<i class="fas fa-sun" aria-hidden="true"></i> <i class="fas fa-sun" aria-hidden="true"></i>
</button> </button>
<button id="install-pwa" class="icon-button install-pwa-button" style="display: none;" nonce="<?php echo getCspNonce(); ?>" aria-label="Installer l'application"> <button id="install-pwa" class="icon-button install-pwa-button is-hidden" aria-label="Installer l'application">
<i class="fas fa-download" aria-hidden="true"></i> <i class="fas fa-download" aria-hidden="true"></i>
</button> </button>
<button class="mobile-menu-toggle" aria-expanded="false" aria-controls="mobile-menu" aria-label="Ouvrir le menu de navigation"> <button class="mobile-menu-toggle" aria-expanded="false" aria-controls="mobile-menu" aria-label="Ouvrir le menu de navigation">
+5 -99
View File
@@ -45,7 +45,7 @@ if ($heroType === 'none') {
</div> </div>
<div class="hero-video-container"> <div class="hero-video-container">
<iframe <iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1&muted=1" src="<?php echo e(PEERTUBE_URL . '/videos/embed/' . $liveStream['id'] . '?autoplay=1&muted=1'); ?>"
frameborder="0" frameborder="0"
allowfullscreen="allowfullscreen" allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen" allow="autoplay; fullscreen"
@@ -64,7 +64,7 @@ if ($heroType === 'none') {
<i class="fas fa-user-circle" aria-hidden="true"></i> <i class="fas fa-user-circle" aria-hidden="true"></i>
</div> </div>
<?php else: ?> <?php else: ?>
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar"> <img src="<?php echo e($liveStream['channelAvatar']); ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar">
<?php endif; ?> <?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span> <span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span>
</div> </div>
@@ -75,103 +75,9 @@ if ($heroType === 'none') {
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true; $showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) { if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live // Afficher l'annonce du prochain live (partial partagé avec direct.php)
// Définir l'image de fond si disponible $nextLiveVariant = 'hero';
$bgImageStyle = ''; include __DIR__ . '/partials/next-live.php';
if (!empty(NEXT_LIVE_IMAGE) && file_exists(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(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="hero-next-live-image-container">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
class="hero-next-live-image">
</div>
<?php endif; ?>
<div class="hero-next-live-content">
<i class="fas fa-calendar-alt"></i>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$dayFormatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
DEFAULT_TIMEZONE,
IntlDateFormatter::GREGORIAN,
'EEEE d MMMM'
);
$formattedDay = $dayFormatter->format($liveDate);
$formattedDay = ucfirst($formattedDay);
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
} else {
$dynamicTitle = NEXT_LIVE_TITLE;
}
?>
<h2><?php echo htmlspecialchars($dynamicTitle); ?></h2>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveHour = $liveDate->format('H\hi');
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
} else {
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
}
?>
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
<?php if (!empty(NEXT_LIVE_DATE)): ?>
<div class="hero-next-live-datetime">
<p class="hero-next-live-date">
<i class="fas fa-clock"></i>
<?php
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::SHORT,
DEFAULT_TIMEZONE
);
echo $formatter->format($liveDate);
$offset = $liveDate->format('P');
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
?>
</p>
<!-- Autres fuseaux horaires -->
<div class="hero-next-live-timezones">
<?php
// Ordre croissant : du plus en retard au plus en avance
$timezones = [
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$liveDateLocal = clone $liveDate;
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-shift">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-shift">-1j</span>';
}
?>
<span class="hero-timezone-item">
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php
} else { } else {
?> ?>
<div class="hero-no-live"> <div class="hero-no-live">
+244
View File
@@ -0,0 +1,244 @@
<?php
/**
* Intégration Castopod : lecture des feeds RSS des podcasts.
*
* Extrait de includes/config.php (ARC-1) — chargé par ce dernier.
* Dépend de lib/http.php (client cURL) et lib/format.php (formatDuration).
*/
/**
* 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) {
$response = httpGet($feedUrl, ['timeout' => 10]);
$xmlContent = $response['body'];
$httpCode = $response['code'];
// 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('/<channel>.*?<title>(.*?)<\/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;
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* Fonctions de formatage des données (vidéos, durées, dates, tailles).
*
* Extrait de includes/config.php (ARC-1) — chargé par ce dernier.
*/
/**
* 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' : '');
}
}
/**
* 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];
}
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* Intégration Funkwhale : récupération de morceaux aléatoires via l'API.
*
* Extrait de includes/config.php (ARC-1) — chargé par ce dernier.
* Dépend de lib/http.php (client cURL) et lib/format.php (formatDuration).
*/
/**
* 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 en ordre aléatoire ; le filtrage local
// est fait ensuite côté PHP (comparaison de domaine)
$apiUrl = rtrim($funkwhaleUrl, '/') . '/api/v1/tracks?ordering=random&page_size=' . $cacheFetchSize . '&scope=all';
// Récupérer les morceaux
$response = httpGet($apiUrl, ['timeout' => 10]);
$jsonContent = $response['body'];
if ($response['code'] !== 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);
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Client HTTP GET sécurisé, commun aux intégrations distantes
* (PeerTube, Castopod, Funkwhale).
*
* Extrait de includes/config.php (ARC-1) — chargé par ce dernier.
* Centralise les réglages cURL : timeouts bornés, vérification SSL
* systématique, User-Agent identifié.
*/
/**
* Effectue une requête HTTP GET avec des options de sécurité par défaut.
*
* @param string $url URL complète à appeler (validée au préalable contre les SSRF)
* @param array $options Options optionnelles :
* - timeout (int, défaut 10) : durée maximale totale en secondes
* - connectTimeout (int, défaut 10) : durée maximale de connexion en secondes
* - followLocation (bool, défaut true) : suivre les redirections
* - maxRedirects (int, défaut 3) : nombre maximal de redirections suivies
* - userAgent (string, défaut 'fediverse-oki/1.0') : en-tête User-Agent
* - headers (array, défaut []) : en-têtes HTTP additionnels
* @return array Tableau associatif : 'body' (string|false), 'code' (int), 'error' (string)
*/
function httpGet($url, $options = []) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $options['timeout'] ?? 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $options['connectTimeout'] ?? 10);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $options['followLocation'] ?? true);
curl_setopt($ch, CURLOPT_MAXREDIRS, $options['maxRedirects'] ?? 3);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_USERAGENT, $options['userAgent'] ?? 'fediverse-oki/1.0');
if (!empty($options['headers'])) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $options['headers']);
}
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return ['body' => $body, 'code' => $code, 'error' => $error];
}
+41 -11
View File
@@ -18,6 +18,10 @@ function markdown_to_html($markdown) {
$links = []; $links = [];
$link_count = 0; $link_count = 0;
// Note : le texte étant déjà échappé ci-dessus, les URLs extraites le sont
// aussi (« & » est devenu « &amp; »). Il ne faut PAS les ré-échapper dans
// les callbacks ci-dessous, sinon on obtient un double encodage (« &amp;amp; »).
// Conversion des liens Markdown [texte](url) // Conversion des liens Markdown [texte](url)
$markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) { $markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) {
$text = $matches[1]; $text = $matches[1];
@@ -30,7 +34,7 @@ function markdown_to_html($markdown) {
} }
$placeholder = "___LINK_{$link_count}___"; $placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>'; $links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
$link_count++; $link_count++;
return $placeholder; return $placeholder;
@@ -43,7 +47,7 @@ function markdown_to_html($markdown) {
$url = $matches[1]; $url = $matches[1];
$placeholder = "___LINK_{$link_count}___"; $placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>'; $links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
$link_count++; $link_count++;
return $placeholder; return $placeholder;
@@ -61,7 +65,7 @@ function markdown_to_html($markdown) {
} }
$placeholder = "___LINK_{$link_count}___"; $placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="http://' . htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>'; $links[$placeholder] = '<a href="http://' . $domain . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
$link_count++; $link_count++;
return $placeholder; return $placeholder;
@@ -71,19 +75,45 @@ function markdown_to_html($markdown) {
$markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown); $markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown);
$markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown); $markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown);
// Conversion des listes à puces // Conversion des listes (à puces et numérotées) en une seule passe ligne à
$markdown = preg_replace('/^- (.*?)$/m', '<li>$1</li>', $markdown); // ligne : les items consécutifs de même type forment une seule liste ; une
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ul>$0</ul>', $markdown); // ligne hors liste ou un changement de type ferme la liste courante.
$lines = explode("\n", $markdown);
$markdown = '';
$listType = null; // 'ul', 'ol' ou null (hors liste)
foreach ($lines as $line) {
$itemType = null;
$itemText = null;
if (preg_match('/^- (.*)$/', $line, $matches)) {
$itemType = 'ul';
$itemText = $matches[1];
} elseif (preg_match('/^\d+\. (.*)$/', $line, $matches)) {
$itemType = 'ol';
$itemText = $matches[1];
}
// Conversion des listes numérotées if ($itemType !== $listType) {
$markdown = preg_replace('/^\d+\. (.*?)$/m', '<li>$1</li>', $markdown); if ($listType !== null) {
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ol>$0</ol>', $markdown); $markdown .= '</' . $listType . ">\n";
}
if ($itemType !== null) {
$markdown .= '<' . $itemType . ">\n";
}
$listType = $itemType;
}
$markdown .= ($itemType !== null ? '<li>' . $itemText . '</li>' : $line) . "\n";
}
if ($listType !== null) {
$markdown .= '</' . $listType . ">\n";
}
$markdown = rtrim($markdown, "\n");
// Gestion des retours à la ligne // Gestion des retours à la ligne
$markdown = nl2br($markdown); $markdown = nl2br($markdown);
// Nettoyage des balises br dans les listes // Nettoyage des balises br autour des listes
$markdown = preg_replace('/<\/li><br \/>/', '</li>', $markdown); $markdown = preg_replace('/(<\/li>|<ul>|<ol>)<br \/>/', '$1', $markdown);
// Restaurer les liens // Restaurer les liens
foreach ($links as $placeholder => $link) { foreach ($links as $placeholder => $link) {
+519
View File
@@ -0,0 +1,519 @@
<?php
/**
* Client de l'API PeerTube et fonctions de récupération des vidéos.
*
* Extrait de includes/config.php (ARC-1) — chargé par ce dernier.
* Dépend de lib/http.php (client cURL), lib/format.php (formatage) et
* includes/simple-cache.php (callPeerTubeApiCached).
*/
/**
* Initialise et récupère les catégories depuis l'API PeerTube
*
* @return array Liste des catégories
*/
function initCategories() {
// Récupérer la liste des catégories depuis l'API
$categories = callPeerTubeApi('videos/categories');
// Tableau de correspondance pour traduire les catégories en français
$translations = [
'Music' => '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;
}
/**
* Retourne les catégories PeerTube, chargées paresseusement.
*
* Remplace l'ancienne constante PEERTUBE_CATEGORIES (définie à l'inclusion
* de config.php) : l'appel API n'a lieu qu'à la première utilisation et le
* résultat est mémoïsé pour le reste de la requête (ARC-2).
*
* @return array Liste des catégories (id => nom traduit)
*/
function getPeertubeCategories() {
static $categories = null;
if ($categories === null) {
$categories = initCategories();
}
return $categories;
}
/**
* 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);
}
// Options cURL : pas de redirection suivie (SSRF), timeouts bornés
$options = [
'timeout' => 30,
'connectTimeout' => 10,
'followLocation' => false,
'maxRedirects' => 0,
];
// Ajouter la clé API si définie
if (defined('API_KEY') && !empty(API_KEY)) {
$options['headers'] = [
'Authorization: ApiKey ' . API_KEY
];
}
// Exécuter la requête
$response = httpGet($url, $options);
// Traiter la réponse
if ($response['body'] === false || !empty($response['error'])) {
error_log('PeerTube API error: ' . $response['error']);
return [];
}
if ($response['code'] < 200 || $response['code'] >= 300) {
error_log('PeerTube API HTTP error: ' . $response['code']);
return [];
}
// Décoder la réponse JSON
$data = json_decode($response['body'], 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 = getPeertubeCategories();
$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;
}
/**
* 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;
// 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;
}
/**
* 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
* @param int|null $total Total réel renvoyé par l'API (passé par référence)
* @return array Liste des vidéos correspondant à la recherche
*/
function searchVideos($query, $count = COUNT_VIDEO_SEARCH, $start = 0, &$total = null) {
$total = 0;
if (empty($query)) {
return [];
}
// Vérifier si la recherche concerne un tag (commence par #)
if (substr($query, 0, 1) === '#') {
$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
]);
$videos = formatVideosData($data['data'] ?? []);
$total = isset($data['total']) ? (int) $data['total'] : count($videos);
return $videos;
}
// 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
]);
$videos = formatVideosData($data['data'] ?? []);
$total = isset($data['total']) ? (int) $data['total'] : count($videos);
return $videos;
}
+1
View File
@@ -1,4 +1,5 @@
<!-- Menu mobile (masqué par défaut) --> <!-- Menu mobile (masqué par défaut) -->
<?php require_once __DIR__ . '/nav-context.php'; ?>
<div class="mobile-menu"> <div class="mobile-menu">
<button class="mobile-menu-close"> <button class="mobile-menu-close">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Contexte de navigation partagé par les partials (sidebar, footer, menu mobile).
*
* Définit les variables $currentPage, $currentCategoryId, $currentQuery,
* $isTagSearch et $currentTag à partir de la requête courante. Chaque partial
* l'inclut lui-même (require_once) : l'ordre d'inclusion des partials n'a
* plus d'importance et chacun est autonome (ARC-3).
*
* Les variables déjà définies (par la page ou un partial précédent) ne sont
* pas écrasées.
*/
if (!isset($currentPage)) {
$currentPage = basename($_SERVER['PHP_SELF']);
}
if (!isset($currentCategoryId)) {
$currentCategoryId = isset($_GET['id']) ? intval($_GET['id']) : null;
}
if (!isset($currentQuery)) {
$currentQuery = isset($_GET['q']) ? trim($_GET['q']) : '';
}
if (!isset($isTagSearch)) {
$isTagSearch = !empty($currentQuery) && substr($currentQuery, 0, 1) === '#';
}
if (!isset($currentTag)) {
$currentTag = $isTagSearch ? substr($currentQuery, 1) : '';
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/**
* Partial : annonce du prochain live (constantes NEXT_LIVE_*).
*
* Factorise le bloc « prochain live » affiché par direct.php et
* includes/hero-section.php quand aucun direct n'est en cours (ARC-4).
* Prérequis : n'inclure ce partial que si NEXT_LIVE_ENABLED === true.
*
* Variable d'entrée :
* - $nextLiveVariant (string) : 'page' (direct.php, défaut) ou 'hero'
* (hero-section) — adapte les classes CSS, le niveau de titre et le
* lien « Retour à l'accueil ».
*/
$nextLiveVariant = $nextLiveVariant ?? 'page';
$isHeroVariant = $nextLiveVariant === 'hero';
// Classes CSS : le hero préfixe toutes ses classes par « hero-next-live »
$rootClass = $isHeroVariant ? 'hero-next-live' : 'next-live-announcement';
$imageContainerClass = $isHeroVariant ? 'hero-next-live-image-container' : 'next-live-image-container';
$imageClass = $isHeroVariant ? 'hero-next-live-image' : 'next-live-image';
$contentClass = $isHeroVariant ? 'hero-next-live-content' : 'next-live-content';
$datetimeClass = $isHeroVariant ? 'hero-next-live-datetime' : 'next-live-datetime';
$dateClass = $isHeroVariant ? 'hero-next-live-date' : 'next-live-date';
$timezonesClass = $isHeroVariant ? 'hero-next-live-timezones' : 'next-live-timezones';
$timezoneItemClass = $isHeroVariant ? 'hero-timezone-item' : 'timezone-item';
$headingTag = $isHeroVariant ? 'h2' : 'h1';
// Définir l'image de fond si disponible
$bgImageStyle = '';
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
}
?>
<?php if (!empty($bgImageStyle)): ?>
<style nonce="<?php echo getCspNonce(); ?>">
.<?php echo $rootClass; ?> { <?php echo $bgImageStyle; ?> }
</style>
<?php endif; ?>
<div class="<?php echo $rootClass; ?>">
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="<?php echo $imageContainerClass; ?>">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
class="<?php echo $imageClass; ?>">
</div>
<?php endif; ?>
<div class="<?php echo $contentClass; ?>">
<i class="fas fa-calendar-alt"></i>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$dayFormatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
DEFAULT_TIMEZONE,
IntlDateFormatter::GREGORIAN,
'EEEE d MMMM'
);
$formattedDay = $dayFormatter->format($liveDate);
$formattedDay = ucfirst($formattedDay);
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
} else {
$dynamicTitle = NEXT_LIVE_TITLE;
}
?>
<<?php echo $headingTag; ?>><?php echo htmlspecialchars($dynamicTitle); ?></<?php echo $headingTag; ?>>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveHour = $liveDate->format('H\hi');
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
} else {
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
}
?>
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
<?php if (!empty(NEXT_LIVE_DATE)): ?>
<div class="<?php echo $datetimeClass; ?>">
<p class="<?php echo $dateClass; ?>">
<i class="fas fa-clock"></i>
<?php
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::SHORT,
DEFAULT_TIMEZONE
);
echo $formatter->format($liveDate);
$offset = $liveDate->format('P');
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
?>
</p>
<!-- Autres fuseaux horaires -->
<div class="<?php echo $timezonesClass; ?>">
<?php
// Ordre croissant : du plus en retard au plus en avance
$timezones = [
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$liveDateLocal = clone $liveDate;
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
// Vérifier si c'est un jour différent
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-shift">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-shift">-1j</span>';
}
?>
<span class="<?php echo $timezoneItemClass; ?>">
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!$isHeroVariant): ?>
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
<?php endif; ?>
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Partial de rendu d'une carte vidéo.
*
* Centralise le balisage des cartes vidéo (accueil, catégories, recherche,
* endpoint AJAX « Voir plus ») afin de garantir un échappement systématique
* des données issues de l'API PeerTube (titres, chaînes, vignettes, avatars).
*/
/**
* Génère le HTML d'une carte vidéo, entièrement échappé.
*
* @param array $video Données formatées de la vidéo (voir formatVideosData())
* @return string HTML de la carte
*/
function renderVideoCard(array $video) {
$id = e($video['id'] ?? '');
$title = e($video['title'] ?? '');
$thumbnail = e($video['thumbnail'] ?? '');
$duration = e(formatDuration($video['duration'] ?? 0));
$channel = e($video['channel'] ?? '');
$channelAvatar = (string) ($video['channelAvatar'] ?? '');
$views = e(formatViewCount($video['views'] ?? 0));
$date = e(formatDate($video['date'] ?? ''));
ob_start();
?>
<article class="video-card" data-video-id="<?php echo $id; ?>">
<div class="video-thumbnail">
<img src="<?php echo $thumbnail; ?>" alt="<?php echo $title; ?>">
<div class="video-play-icon" aria-hidden="true">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration"><?php echo $duration; ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $title; ?></h3>
<div class="video-channel">
<?php if ($channelAvatar === '' || strpos($channelAvatar, 'default-avatar') !== false): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo e($channelAvatar); ?>" alt="<?php echo $channel; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $channel; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo $views; ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo $date; ?></span>
</div>
</div>
</article>
<?php
return ob_get_clean();
}
-82
View File
@@ -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.style.display = 'block';
installButton.addEventListener('click', function() {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) {
if (choiceResult.outcome === 'accepted') {
console.log('PWA installée');
}
deferredPrompt = null;
installButton.style.display = 'none';
});
});
}
});
// Masquer le bouton après installation
window.addEventListener('appinstalled', function() {
console.log('PWA installée avec succès');
if (installButton) {
installButton.style.display = 'none';
}
});
</script>
<?php
}
?>
-29
View File
@@ -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; ?>
+234 -139
View File
@@ -3,6 +3,20 @@
* Fonctions de sécurité pour la validation et l'assainissement des entrées * Fonctions de sécurité pour la validation et l'assainissement des entrées
*/ */
/**
* Échappe une valeur pour une sortie HTML (texte ou attribut).
*
* Raccourci pour htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8') :
* les guillemets simples et doubles sont encodés, ce qui rend la sortie
* sûre aussi bien dans le contenu que dans les attributs.
*
* @param mixed $value Valeur à échapper
* @return string Valeur échappée
*/
function e($value) {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/** /**
* Valide et assainit un ID de vidéo UUID * Valide et assainit un ID de vidéo UUID
* *
@@ -79,66 +93,41 @@ function validateCategoryId($categoryId) {
} }
/** /**
* Valide et assainit un User-Agent * 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.
* @param string $userAgent User-Agent à valider
* @return bool True si valide
*/ */
function validateUserAgent($userAgent) { if (!defined('CSRF_SECRET_PLACEHOLDER')) {
if (empty($userAgent)) { define('CSRF_SECRET_PLACEHOLDER', 'change-me-in-config-local-php');
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 * Retourne le secret CSRF effectif utilisé pour signer les tokens.
* *
* @return bool True si les en-têtes sont sûrs * 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 validateHttpHeaders() { function getCsrfSecret() {
// Vérifier le User-Agent static $secret = null;
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (!validateUserAgent($userAgent)) { if ($secret !== null) {
error_log('SECURITY: Suspicious User-Agent detected: ' . $userAgent); return $secret;
return false;
} }
// Vérifier les en-têtes suspects if (defined('CSRF_SECRET') && CSRF_SECRET !== '' && CSRF_SECRET !== CSRF_SECRET_PLACEHOLDER) {
$suspiciousHeaders = [ $secret = CSRF_SECRET;
'HTTP_X_FORWARDED_FOR', return $secret;
'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; 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;
} }
/** /**
@@ -152,7 +141,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 +172,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);
} }
@@ -200,6 +189,104 @@ function getCspNonce() {
return $GLOBALS['csp_nonce']; 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 * Applique des en-têtes de sécurité HTTP
*/ */
@@ -210,8 +297,8 @@ function setSecurityHeaders() {
// Protection contre le MIME sniffing // Protection contre le MIME sniffing
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
// Protection XSS basique // X-XSS-Protection volontairement absent : en-tête obsolète, supplanté par
header('X-XSS-Protection: 1; mode=block'); // la CSP et pouvant introduire des vulnérabilités dans les anciens navigateurs.
// Politique de référent // Politique de référent
header('Referrer-Policy: strict-origin-when-cross-origin'); header('Referrer-Policy: strict-origin-when-cross-origin');
@@ -225,91 +312,8 @@ function setSecurityHeaders() {
// Permissions Policy (feature policy) // 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=()'); 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 // Content Security Policy (domaines réellement utilisés uniquement)
$nonce = getCspNonce(); header('Content-Security-Policy: ' . buildContentSecurityPolicy(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);
// HTTPS strict transport security (seulement si HTTPS) // HTTPS strict transport security (seulement si HTTPS)
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
@@ -347,4 +351,95 @@ function validateAjaxOrigin() {
return false; 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.
*
* @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;
}
?> ?>
+1 -9
View File
@@ -1,18 +1,10 @@
<!-- Sidebar de navigation --> <!-- Sidebar de navigation -->
<?php require_once __DIR__ . '/nav-context.php'; ?>
<nav class="sidebar" role="navigation" aria-label="Navigation principale"> <nav class="sidebar" role="navigation" aria-label="Navigation principale">
<a href="/" class="logo" aria-label="Retour à l'accueil"> <a href="/" class="logo" aria-label="Retour à l'accueil">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>"> <img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>">
</a> </a>
<?php
// Détecter la page courante et ses paramètres
$currentPage = basename($_SERVER['PHP_SELF']);
$currentCategoryId = isset($_GET['id']) ? intval($_GET['id']) : null;
$currentQuery = isset($_GET['q']) ? trim($_GET['q']) : '';
$isTagSearch = !empty($currentQuery) && substr($currentQuery, 0, 1) === '#';
$currentTag = $isTagSearch ? substr($currentQuery, 1) : '';
?>
<div class="sidebar-nav"> <div class="sidebar-nav">
<a href="/" class="nav-item <?php echo ($currentPage === 'index.php') ? 'active' : ''; ?>" data-title="Accueil" aria-current="<?php echo ($currentPage === 'index.php') ? 'page' : 'false'; ?>"> <a href="/" class="nav-item <?php echo ($currentPage === 'index.php') ? 'active' : ''; ?>" data-title="Accueil" aria-current="<?php echo ($currentPage === 'index.php') ? 'page' : 'false'; ?>">
<i class="fas fa-home" aria-hidden="true"></i> <span>Accueil</span> <i class="fas fa-home" aria-hidden="true"></i> <span>Accueil</span>
+62 -16
View File
@@ -7,8 +7,8 @@ class SimpleAPICache {
private $cacheDir; private $cacheDir;
private $enabled; private $enabled;
public function __construct() { public function __construct($cacheDir = null) {
$this->cacheDir = __DIR__ . '/../cache/api'; $this->cacheDir = $cacheDir ?? (__DIR__ . '/../cache/api');
$this->enabled = true; $this->enabled = true;
// Créer le répertoire de cache s'il n'existe pas // Créer le répertoire de cache s'il n'existe pas
@@ -66,7 +66,26 @@ class SimpleAPICache {
'created' => time() 'created' => time()
]; ];
file_put_contents($file, json_encode($data)); // Verrou exclusif : évite les écritures concurrentes tronquées
file_put_contents($file, json_encode($data), LOCK_EX);
}
/**
* Vide entièrement le cache (entrées valides comme expirées)
*
* @return int Nombre de fichiers supprimés
*/
public function clear() {
$files = glob($this->cacheDir . '/cache_*.json');
$deleted = 0;
foreach ($files as $file) {
if (unlink($file)) {
$deleted++;
}
}
return $deleted;
} }
/** /**
@@ -92,29 +111,56 @@ class SimpleAPICache {
$GLOBALS['simple_api_cache'] = new SimpleAPICache(); $GLOBALS['simple_api_cache'] = new SimpleAPICache();
/** /**
* Version cachée de callPeerTubeApi - remplace l'originale * TTL de cache selon l'endpoint PeerTube.
*
* Correspondance exacte d'abord, puis par préfixe. L'ancien matching par
* sous-chaîne (strpos) créait des collisions : « accounts/{nom}/videos »
* capturait le TTL de « videos » (10 min) au lieu de celui de « accounts »
* (5 min, pensé pour les lives).
*
* @param string $endpoint Endpoint de l'API (sans slash initial)
* @return int TTL en secondes
*/ */
function callPeerTubeApiCached($endpoint, $params = []) { function getPeerTubeCacheTtl($endpoint) {
$cache = $GLOBALS['simple_api_cache']; static $exactMap = [
// TTL selon le type de contenu
$ttlMap = [
'videos/categories' => 3600, // 1 heure 'videos/categories' => 3600, // 1 heure
'videos' => 600, // 10 minutes (augmenté) 'videos' => 600, // 10 minutes
'search/videos' => 600, // 10 minutes 'search/videos' => 600, // 10 minutes
'wp-posts' => 900, // 15 minutes pour WordPress 'wp-posts' => 900, // 15 minutes pour WordPress
'accounts' => 300 // 5 minutes pour live streams 'accounts' => 300 // 5 minutes pour live streams
]; ];
// TTL dynamique selon l'endpoint static $prefixMap = [
$ttl = 300; // Défaut 5 minutes 'accounts/' => 300, // lives : accounts/{nom}/videos
foreach ($ttlMap as $pattern => $time) { 'video-channels/' => 600, // vidéos d'une chaîne
if (strpos($endpoint, $pattern) !== false) { 'videos/' => 600 // videos/{id}, commentaires
$ttl = $time; ];
break;
if (isset($exactMap[$endpoint])) {
return $exactMap[$endpoint];
}
foreach ($prefixMap as $prefix => $ttl) {
if (strpos($endpoint, $prefix) === 0) {
return $ttl;
} }
} }
return 300; // Défaut 5 minutes
}
/**
* Version cachée de callPeerTubeApi - remplace l'originale
*/
function callPeerTubeApiCached($endpoint, $params = []) {
// Cache désactivé : appel direct, sans lecture ni écriture
if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) {
return callPeerTubeApiOriginal($endpoint, $params);
}
$cache = $GLOBALS['simple_api_cache'];
$ttl = getPeerTubeCacheTtl($endpoint);
// Essayer le cache d'abord // Essayer le cache d'abord
$cached = $cache->get($endpoint, $params); $cached = $cache->get($endpoint, $params);
if ($cached !== null) { if ($cached !== null) {
+121 -13
View File
@@ -5,6 +5,18 @@
* pour améliorer le SEO et l'affichage dans les moteurs de recherche * pour améliorer le SEO et l'affichage dans les moteurs de recherche
*/ */
/**
* Drapeaux json_encode pour la sortie JSON-LD.
*
* Les JSON_HEX_* encodent <, >, &, ' et " en séquences \u00XX : une valeur
* contenant "</script>" (titre de vidéo, nom de chaîne, titre d'épisode…)
* ne peut plus fermer le bloc <script> ni injecter de HTML dans la page.
* Le JSON reste valide et se décode à l'identique.
*/
if (!defined('JSONLD_ENCODE_FLAGS')) {
define('JSONLD_ENCODE_FLAGS', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/** /**
* Génère le JSON-LD pour un objet WebSite * Génère le JSON-LD pour un objet WebSite
* *
@@ -38,7 +50,7 @@ function generateWebSiteJsonLd() {
] ]
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -158,7 +170,7 @@ function generateVideoObjectJsonLd($videoData, $video) {
$data["videoFrameSize"] = "Portrait"; $data["videoFrameSize"] = "Portrait";
} }
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -254,7 +266,7 @@ function generatePodcastJsonLd($episodes) {
"@graph" => array_merge([$series], $episodeItems) "@graph" => array_merge([$series], $episodeItems)
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -327,7 +339,7 @@ function generateBreadcrumbJsonLd($breadcrumbs) {
"itemListElement" => $listItems "itemListElement" => $listItems
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -372,7 +384,7 @@ function generateVideoCollectionJsonLd($name, $description, $videos, $url) {
] ]
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -418,36 +430,132 @@ function formatDateISO8601($dateString) {
} }
/** /**
* Tronque un texte à une longueur donnée * Formate une date en français (remplace strftime, déprécié depuis PHP 8.1)
*
* Utilise IntlDateFormatter quand l'extension intl est disponible,
* sinon replie sur un formatage manuel (mois français en toutes lettres).
*
* @param DateTimeInterface $date Date à formater
* @param bool $withTime true pour ajouter « à HH:mm »
* @return string Date formatée, ex. « 11 octobre 2025 » ou « 11 octobre 2025 à 00:00 »
*/
function formatDateFr($date, $withTime = false) {
if (class_exists('IntlDateFormatter')) {
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::FULL,
$date->getTimezone(), // Le formateur ignore le fuseau du DateTime sans ça
IntlDateFormatter::GREGORIAN,
$withTime ? "d MMMM yyyy 'à' HH:mm" : 'd MMMM yyyy'
);
$formatted = $formatter->format($date);
if ($formatted !== false) {
return $formatted;
}
}
// Repli sans extension intl : mois français en toutes lettres
$months = [
1 => 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
];
$formatted = $date->format('j') . ' ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
if ($withTime) {
$formatted .= ' à ' . $date->format('H:i');
}
return $formatted;
}
/**
* Tronque un texte à une longueur donnée (en caractères UTF-8)
* *
* @param string $text Texte à tronquer * @param string $text Texte à tronquer
* @param int $length Longueur maximale * @param int $length Longueur maximale
* @return string Texte tronqué * @return string Texte tronqué
*/ */
function truncateText($text, $length = 200) { function truncateText($text, $length = 200) {
if (strlen($text) <= $length) { if (mb_strlen($text) <= $length) {
return $text; return $text;
} }
$truncated = substr($text, 0, $length); $truncated = mb_substr($text, 0, $length);
$lastSpace = strrpos($truncated, ' '); $lastSpace = mb_strrpos($truncated, ' ');
if ($lastSpace !== false) { if ($lastSpace !== false) {
$truncated = substr($truncated, 0, $lastSpace); $truncated = mb_substr($truncated, 0, $lastSpace);
} }
return $truncated . '...'; return $truncated . '...';
} }
/** /**
* Obtient l'URL de base du site * Valide un nom d'hôte applicatif (hostname, IPv4 ou localhost, port optionnel)
*
* @param string $host Hôte à valider
* @return bool True si l'hôte est exploitable en toute sécurité dans une URL
*/
function isValidAppHostName($host) {
if (!is_string($host) || $host === '' || strlen($host) > 253) {
return false;
}
// Labels alphanumériques/tirets séparés par des points, port optionnel
return (bool) preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/i', $host);
}
/**
* Retourne le nom d'hôte de l'application, validé une fois à l'initialisation.
*
* Utilise la constante APP_HOST_NAME (configuration) et jamais l'en-tête
* HTTP_HOST fourni par le client, afin d'empêcher l'injection via Host.
*
* @return string Nom d'hôte validé ('localhost' en repli)
*/
function getAppHostName() {
static $validatedHost = null;
if ($validatedHost === null) {
$configuredHost = defined('APP_HOST_NAME') ? (string) APP_HOST_NAME : '';
if (isValidAppHostName($configuredHost)) {
$validatedHost = $configuredHost;
} else {
error_log('SECURITY: APP_HOST_NAME absent ou invalide, repli sur localhost');
$validatedHost = 'localhost';
}
}
return $validatedHost;
}
/**
* Obtient l'URL de base du site (schéma + hôte validé)
* *
* @return string URL de base * @return string URL de base
*/ */
function getBaseUrl() { function getBaseUrl() {
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http'; $scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; return $scheme . '://' . getAppHostName();
return $scheme . '://' . $host; }
/**
* Construit l'URL absolue de la page courante.
*
* L'hôte provient de getBaseUrl() (APP_HOST_NAME validé, pas l'en-tête Host)
* et REQUEST_URI est débarrassé des guillemets, chevrons, espaces et
* caractères de contrôle qui casseraient un attribut HTML ou une URL.
*
* @return string URL absolue de la requête courante
*/
function getCurrentUrl() {
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$requestUri = preg_replace('/[\x00-\x20"<>\'`]/', '', $requestUri);
if ($requestUri === '' || $requestUri[0] !== '/') {
$requestUri = '/' . $requestUri;
}
return getBaseUrl() . $requestUri;
} }
/** /**
+13 -100
View File
@@ -30,7 +30,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>"> <meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>">
<link rel="canonical" href="<?php echo getBaseUrl() . '/'; ?>"> <link rel="canonical" href="<?php echo getBaseUrl() . '/'; ?>">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>"> <link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>">
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?> <?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?>
<link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>"> <link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>">
@@ -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>
+50 -1
View File
@@ -2,9 +2,58 @@
* Script de compte à rebours pour la page de maintenance * Script de compte à rebours pour la page de maintenance
*/ */
/**
* Convertit une date ISO 8601 en timestamp, compatible Safari.
*
* Safari renvoie Invalid Date pour les formats non strictement ISO
* (ex. « 2025-10-11 00:00:00 » émis historiquement par PHP) : la chaîne
* est donc analysée manuellement. Sans fuseau explicite, la date est
* interprétée en UTC — PHP émet de toute façon toujours un décalage.
*
* @param {string|number} value Date ISO 8601 (ou timestamp déjà numérique)
* @returns {number} Timestamp en millisecondes
*/
function parseTargetDate(value) {
if (typeof value === 'number') {
return new Date(value).getTime();
}
const match = String(value).match(
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/
);
if (!match) {
// Repli sur l'analyse native pour les formats inattendus
return new Date(value).getTime();
}
const [, year, month, day, hours = '0', minutes = '0', seconds = '0', offset] = match;
let timestamp = Date.UTC(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
parseInt(hours, 10),
parseInt(minutes, 10),
parseInt(seconds, 10)
);
if (offset && offset !== 'Z') {
// Un décalage « +02:00 » signifie « 2 h d'avance sur UTC » : on le soustrait
const sign = offset[0] === '+' ? 1 : -1;
const offsetDigits = offset.slice(1).replace(':', '');
const offsetMinutes = sign * (
parseInt(offsetDigits.slice(0, 2), 10) * 60 + parseInt(offsetDigits.slice(2, 4), 10)
);
timestamp -= offsetMinutes * 60 * 1000;
}
return timestamp;
}
class CountdownTimer { class CountdownTimer {
constructor(targetDate) { constructor(targetDate) {
this.targetDate = new Date(targetDate).getTime(); this.targetDate = parseTargetDate(targetDate);
this.elements = { this.elements = {
days: document.getElementById('countdown-days'), days: document.getElementById('countdown-days'),
hours: document.getElementById('countdown-hours'), hours: document.getElementById('countdown-hours'),
-2
View File
@@ -428,8 +428,6 @@ document.addEventListener('DOMContentLoaded', function() {
videoType = 'recent'; videoType = 'recent';
} else if (sectionTitle.includes('tendances')) { } else if (sectionTitle.includes('tendances')) {
videoType = 'trending'; videoType = 'trending';
} else if (sectionTitle.includes('indépendance')) {
videoType = 'independence';
} else { } else {
// Vérifier si c'est une section de catégorie // Vérifier si c'est une section de catégorie
const categorySection = section.querySelector('[data-category-id]'); const categorySection = section.querySelector('[data-category-id]');
+1 -1
View File
@@ -13,7 +13,7 @@ setSecurityHeaders();
<meta name="description" content="Consultez les mentions légales de <?php echo SITE_NAME; ?>. Informations légales, conditions d'utilisation et politique de confidentialité."> <meta name="description" content="Consultez les mentions légales de <?php echo SITE_NAME; ?>. Informations légales, conditions d'utilisation et politique de confidentialité.">
<link rel="canonical" href="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/mentions-legales.php'; ?>"> <link rel="canonical" href="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/mentions-legales.php'; ?>">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+31 -51
View File
@@ -24,26 +24,33 @@ $currentSearchPage = validatePageNumber($_GET['page'] ?? 1);
$isTagSearch = !empty($query) && substr($query, 0, 1) === '#'; $isTagSearch = !empty($query) && substr($query, 0, 1) === '#';
$searchTag = $isTagSearch ? substr($query, 1) : ''; $searchTag = $isTagSearch ? substr($query, 1) : '';
// Rechercher les vidéos via l'API PeerTube si une requête est soumise // Rechercher les vidéos via l'API PeerTube si une requête est soumise.
$searchResults = !empty($query) ? searchVideos($query, COUNT_VIDEO_SEARCH) : []; // La pagination est déléguée à l'API (paramètre start) : chaque page ne
// récupère que VIDEOS_PER_PAGE vidéos et $resultsCount reçoit le total réel.
// Définir le nombre total de résultats $resultsCount = 0;
$resultsCount = count($searchResults);
// Calculer le nombre total de pages
$totalPages = ceil($resultsCount / VIDEOS_PER_PAGE);
// S'assurer que la page actuelle est valide
$currentSearchPage = min($currentSearchPage, max(1, $totalPages));
// Calculer les indices de début et de fin pour la page actuelle
$startIndex = ($currentSearchPage - 1) * VIDEOS_PER_PAGE;
$endIndex = min($startIndex + VIDEOS_PER_PAGE, $resultsCount);
// Extraire les vidéos pour la page actuelle
$currentPageVideos = []; $currentPageVideos = [];
if ($resultsCount > 0) { $totalPages = 1;
$currentPageVideos = array_slice($searchResults, $startIndex, VIDEOS_PER_PAGE); if (!empty($query)) {
$currentPageVideos = searchVideos(
$query,
VIDEOS_PER_PAGE,
($currentSearchPage - 1) * VIDEOS_PER_PAGE,
$resultsCount
);
$totalPages = max(1, (int) ceil($resultsCount / VIDEOS_PER_PAGE));
// Page demandée au-delà de la dernière : se replacer sur la dernière page valide
if ($currentSearchPage > $totalPages) {
$currentSearchPage = $totalPages;
$currentPageVideos = searchVideos(
$query,
VIDEOS_PER_PAGE,
($currentSearchPage - 1) * VIDEOS_PER_PAGE,
$resultsCount
);
// Le total a pu changer (ex. API en échec sur un start trop élevé)
$totalPages = max(1, (int) ceil($resultsCount / VIDEOS_PER_PAGE));
}
} }
?> ?>
@@ -61,7 +68,7 @@ if ($resultsCount > 0) {
<link rel="canonical" href="<?php echo getBaseUrl() . '/recherche.php'; ?>"> <link rel="canonical" href="<?php echo getBaseUrl() . '/recherche.php'; ?>">
<?php endif; ?> <?php endif; ?>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -74,8 +81,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 +91,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 +167,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>
+260
View File
@@ -0,0 +1,260 @@
<?php
/**
* Mesure les performances des pages clés du site (TTFB à froid / à chaud).
*
* Démarre un serveur PHP local (php -S) à la racine du projet, vide le cache
* API (cache/api), puis mesure le TTFB (time to first byte) des pages clés :
* - à froid : cache vide (le premier visiteur paie les appels API externes) ;
* - à chaud : cache rempli (moyenne sur plusieurs requêtes).
* La taille du cache API est affichée avant et après les mesures.
*
* Pages mesurées : accueil (/), page vidéo (/video.php?id=…) et page
* catégorie (/categories.php?id=…). Les identifiants de vidéo et de catégorie
* sont découverts automatiquement via l'API PeerTube ; si l'instance est
* injoignable, les pages correspondantes sont ignorées avec un avertissement.
*
* ⚠️ Le script vide le cache API (cache/api) : à utiliser hors production,
* ou suivi d'un préchauffage (scripts/warm-cache.php).
*
* Usage :
* php scripts/benchmark.php
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Ce script ne s'exécute qu'en ligne de commande.\n");
}
const WARM_RUNS = 3; // Nombre de requêtes à chaud par page (moyenne)
const REQUEST_TIMEOUT = 120; // Secondes (à froid, l'accueil paie tous les appels API)
const STARTUP_TIMEOUT = 15; // Délai d'attente du démarrage de php -S
$rootDir = dirname(__DIR__);
$cacheDir = $rootDir . '/cache/api';
require_once $rootDir . '/includes/config.php';
// Arrêt du serveur local quoi qu'il arrive (exit, erreur fatale…)
$serverProcess = null;
$serverLogFile = null;
register_shutdown_function(function () use (&$serverProcess, &$serverLogFile) {
if (is_resource($serverProcess)) {
proc_terminate($serverProcess);
proc_close($serverProcess);
}
if ($serverLogFile !== null) {
@unlink($serverLogFile);
}
});
/**
* Trouve un port TCP libre sur 127.0.0.1
*
* @return int Port libre
*/
function findFreePort() {
$sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
if ($sock === false) {
return 8000;
}
$name = stream_socket_get_name($sock, false);
fclose($sock);
return (int) substr($name, strrpos($name, ':') + 1);
}
/**
* Vide le cache API
*
* @param string $cacheDir Répertoire du cache
* @return int Nombre de fichiers supprimés
*/
function purgeApiCache($cacheDir) {
$deleted = 0;
foreach (glob($cacheDir . '/cache_*.json') ?: [] as $file) {
if (@unlink($file)) {
$deleted++;
}
}
return $deleted;
}
/**
* Statistiques du cache API
*
* @param string $cacheDir Répertoire du cache
* @return array ['files' => nombre de fichiers, 'bytes' => taille totale en octets]
*/
function getCacheStats($cacheDir) {
$files = glob($cacheDir . '/cache_*.json') ?: [];
$bytes = 0;
foreach ($files as $file) {
$bytes += (int) filesize($file);
}
return ['files' => count($files), 'bytes' => $bytes];
}
/**
* Formate une taille en octets de façon lisible
*
* @param int $bytes Taille en octets
* @return string Taille formatée (o, Ko ou Mo)
*/
function formatBytes($bytes) {
if ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . ' Mo';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 1) . ' Ko';
}
return $bytes . ' o';
}
/**
* Aligne une chaîne à droite sur une largeur donnée (compatible UTF-8)
*
* @param string $str Chaîne à compléter
* @param int $len Largeur cible
* @return string Chaîne complétée d'espaces
*/
function padRight($str, $len) {
$width = function_exists('mb_strwidth') ? mb_strwidth($str) : strlen($str);
return $str . str_repeat(' ', max(0, $len - $width));
}
/**
* Démarre un serveur PHP local (php -S) sur la racine du projet
*
* @param string $rootDir Racine du projet (document root)
* @param int $port Port d'écoute
* @return array [resource $process, string $logFile]
*/
function startPhpServer($rootDir, $port) {
$logFile = tempnam(sys_get_temp_dir(), 'benchmark-php-server-');
$process = proc_open(
['php', '-S', '127.0.0.1:' . $port, '-t', $rootDir],
[
0 => ['file', '/dev/null', 'r'],
1 => ['file', $logFile, 'w'],
2 => ['file', $logFile, 'a'],
],
$pipes,
$rootDir
);
if ($process === false) {
exit("❌ Impossible de démarrer php -S\n");
}
// Attendre que le serveur accepte des connexions
$deadline = microtime(true) + STARTUP_TIMEOUT;
while (microtime(true) < $deadline) {
$status = proc_get_status($process);
if (!$status['running']) {
echo "❌ Le serveur PHP s'est arrêté au démarrage :\n" . (string) @file_get_contents($logFile);
exit(1);
}
$fp = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.5);
if ($fp !== false) {
fclose($fp);
return [$process, $logFile];
}
usleep(200000);
}
echo "❌ Le serveur PHP ne répond pas après " . STARTUP_TIMEOUT . " s\n";
exit(1);
}
/**
* Mesure le TTFB et le temps total d'une page via HTTP
*
* @param string $url URL complète de la page
* @return array ['code' => int, 'ttfb' => float, 'total' => float] (secondes)
*/
function measurePage($url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => REQUEST_TIMEOUT,
]);
curl_exec($ch);
$result = [
'code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE),
'ttfb' => curl_getinfo($ch, CURLINFO_STARTTRANSFER_TIME),
'total' => curl_getinfo($ch, CURLINFO_TOTAL_TIME),
];
curl_close($ch);
return $result;
}
/**
* Calcule le TTFB moyen d'une série de mesures
*
* @param array $measures Mesures retournées par measurePage()
* @return float TTFB moyen en secondes
*/
function averageTtfb($measures) {
return array_sum(array_column($measures, 'ttfb')) / count($measures);
}
// --- Découverte des identifiants réels via l'API PeerTube ---
echo "🔎 Découverte d'une vidéo et d'une catégorie via l'API PeerTube…\n";
$pages = ['Accueil (/)' => '/index.php'];
$recentVideos = getRecentVideos(1);
if (!empty($recentVideos)) {
$pages['Vidéo (/video.php?id=…)'] = '/video.php?id=' . urlencode($recentVideos[0]['id']);
} else {
echo "⚠️ Aucune vidéo trouvée (instance PeerTube injoignable ?) : page vidéo ignorée\n";
}
$displayCategories = getDisplayCategories();
if (!empty($displayCategories)) {
$pages['Catégorie (/categories.php?id=…)'] = '/categories.php?id=' . urlencode($displayCategories[0]['id']);
} else {
echo "⚠️ Aucune catégorie trouvée : page catégorie ignorée\n";
}
// --- Préparation : état du cache, purge et démarrage du serveur ---
$initialStats = getCacheStats($cacheDir);
$purged = purgeApiCache($cacheDir);
$port = findFreePort();
echo "🚀 Démarrage de php -S sur 127.0.0.1:{$port}\n";
[$serverProcess, $serverLogFile] = startPhpServer($rootDir, $port);
$baseUrl = 'http://127.0.0.1:' . $port;
// --- Mesure à froid (cache API vide) ---
echo "\n📏 Mesure à froid (cache API vidé, {$purged} fichier(s) supprimé(s))…\n";
$results = [];
foreach ($pages as $label => $path) {
$cold = measurePage($baseUrl . $path);
$results[$label] = ['path' => $path, 'cold' => $cold, 'warm' => []];
printf(" - %s HTTP %d TTFB %7.0f ms\n", padRight($label, 34), $cold['code'], $cold['ttfb'] * 1000);
}
// --- Mesure à chaud (cache API rempli) ---
echo "\n📏 Mesure à chaud (" . WARM_RUNS . " requêtes par page, moyenne)…\n";
foreach ($results as $label => &$result) {
for ($i = 0; $i < WARM_RUNS; $i++) {
$result['warm'][] = measurePage($baseUrl . $result['path']);
}
printf(" - %s HTTP %d TTFB %7.0f ms\n", padRight($label, 34), $result['warm'][0]['code'], averageTtfb($result['warm']) * 1000);
}
unset($result);
$finalStats = getCacheStats($cacheDir);
// --- Résumé ---
echo "\n===== Résumé =====\n";
printf("%s %14s %14s %8s\n", padRight('Page', 34), 'TTFB froid', 'TTFB chaud', 'Gain');
foreach ($results as $label => $result) {
$coldTtfb = $result['cold']['ttfb'];
$warmTtfb = averageTtfb($result['warm']);
$gain = $coldTtfb > 0 ? (int) round((1 - $warmTtfb / $coldTtfb) * 100) : 0;
printf("%s %12.0f ms %12.0f ms %7d %%\n", padRight($label, 34), $coldTtfb * 1000, $warmTtfb * 1000, $gain);
}
echo "\nCache API au départ : {$initialStats['files']} fichier(s), " . formatBytes($initialStats['bytes']) . "\n";
echo "Cache API après mesures : {$finalStats['files']} fichier(s), " . formatBytes($finalStats['bytes']) . "\n";
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Vide le cache API de l'application (fichiers cache/api/cache_*.json).
*
* Usage : php scripts/purge-cache.php [répertoire-cache]
*
* Utile après un changement de configuration ou pour forcer le
* rafraîchissement des données externes (PeerTube, Castopod, Funkwhale)
* sans attendre l'expiration des entrées.
*
* Contrairement à warm-cache.php, ce script ne charge pas config.php :
* purger le cache ne nécessite aucun appel API. Le paramètre optionnel
* de répertoire sert essentiellement aux tests automatisés.
*/
require_once __DIR__ . '/../includes/simple-cache.php';
$cache = new SimpleAPICache($argv[1] ?? null);
$deleted = $cache->clear();
echo "Cache purgé : {$deleted} entrée(s) supprimée(s).\n";
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Préchauffe le cache API de l'application.
*
* À appeler en tâche cron pour éviter que le premier visiteur ne paye le coût
* des appels API externes (PeerTube, Castopod, Funkwhale) sur cache froid.
*
* Exemple cron (toutes les 5 minutes) :
* php /var/www/annu-kute-ced/scripts/warm-cache.php >/dev/null 2>&1
* (à programmer toutes les 5 minutes dans la crontab)
*/
require_once __DIR__ . '/../includes/config.php';
$start = microtime(true);
$results = [];
// Vidéos PeerTube
$results['recent_videos'] = count(getRecentVideos());
$results['trending_videos'] = count(getTrendingVideos());
$results['shorts'] = count(getShorts());
// Catégories affichées
$categories = getDisplayCategories();
$results['display_categories'] = count($categories);
foreach ($categories as $category) {
$results['category_' . $category['id']] = count(getVideosByCategory($category['id']));
}
// Live
$live = getLiveStream();
$results['live_stream'] = $live ? 1 : 0;
// Podcasts Castopod
$results['castopod_episodes'] = count(getCastopodEpisodes());
// Funkwhale (si activé)
if (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED) {
$results['funkwhale_tracks'] = count(getFunkwhaleTracks());
}
$duration = round(microtime(true) - $start, 2);
echo "Cache préchauffé en {$duration}s\n";
foreach ($results as $key => $value) {
echo " - {$key}: {$value}\n";
}
+20 -10
View File
@@ -5,6 +5,10 @@
const STATIC_CACHE_NAME = 'annu-kute-ced-static-26072026-1946'; const STATIC_CACHE_NAME = 'annu-kute-ced-static-26072026-1946';
const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-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 // Ressources à mettre en cache immédiatement
const STATIC_ASSETS = [ 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' '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 // Installation du Service Worker
self.addEventListener('install', event => { self.addEventListener('install', event => {
console.log('Service Worker: Installation'); console.log('Service Worker: Installation');
@@ -133,7 +128,13 @@ self.addEventListener('fetch', event => {
if (response.status === 200) { if (response.status === 200) {
const responseClone = response.clone(); const responseClone = response.clone();
caches.open(DYNAMIC_CACHE_NAME) 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; return response;
}) })
@@ -175,6 +176,15 @@ self.addEventListener('fetch', event => {
}); });
// Fonctions utilitaires // 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) { function isStaticAsset(url) {
return url.includes('/css/') || return url.includes('/css/') ||
url.includes('/js/') || url.includes('/js/') ||
+20
View File
@@ -71,6 +71,20 @@ def base_url():
port = _trouver_port() port = _trouver_port()
url = f"http://{SERVER_HOST}:{port}" url = f"http://{SERVER_HOST}:{port}"
# Les tokens CSRF stateless sont signés avec CSRF_SECRET. Sans configuration
# locale, getCsrfSecret() génère un secret éphémère par processus, ce qui
# rend les tokens invalides d'une requête à l'autre sous `php -S`.
# On crée donc une configuration locale temporaire avec un secret fixe.
config_local = PROJECT_ROOT / "includes" / "config.local.php"
config_local_exists = config_local.exists()
if not config_local_exists:
config_local.write_text(
"<?php\n"
"// Configuration temporaire pour les tests E2E.\n"
"define('CSRF_SECRET', 'e2e-test-secret-do-not-use-in-prod');\n",
encoding="utf-8",
)
log = tempfile.NamedTemporaryFile( log = tempfile.NamedTemporaryFile(
mode="w+", prefix="php-e2e-", suffix=".log", delete=False mode="w+", prefix="php-e2e-", suffix=".log", delete=False
) )
@@ -103,6 +117,8 @@ def base_url():
if not pret: if not pret:
proc.terminate() proc.terminate()
if not config_local_exists:
config_local.unlink(missing_ok=True)
raise RuntimeError( raise RuntimeError(
f"Le serveur PHP ne répond pas après {STARTUP_TIMEOUT} s" f"Le serveur PHP ne répond pas après {STARTUP_TIMEOUT} s"
) )
@@ -114,6 +130,8 @@ def base_url():
raise RuntimeError(f"index.php a répondu HTTP {rep.status}") raise RuntimeError(f"index.php a répondu HTTP {rep.status}")
except Exception: except Exception:
proc.terminate() proc.terminate()
if not config_local_exists:
config_local.unlink(missing_ok=True)
log.seek(0) log.seek(0)
raise RuntimeError( raise RuntimeError(
"Impossible de charger la page d'accueil :\n" + log.read() "Impossible de charger la page d'accueil :\n" + log.read()
@@ -126,6 +144,8 @@ def base_url():
proc.wait(timeout=10) proc.wait(timeout=10)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill() proc.kill()
if not config_local_exists:
config_local.unlink(missing_ok=True)
log.close() log.close()
+89
View File
@@ -0,0 +1,89 @@
"""Tests E2E de conformité CSP : page de dons, anti-flash de thème et bouton PWA.
Couvre les corrections COR-4/COR-7 de l'audit : scripts inline noncés,
remplacement des `onclick` par des addEventListener, et de
`style="display: none;"` par la classe utilitaire `.is-hidden`.
"""
import pytest
from playwright.sync_api import expect
def _aller_page_dons(page, base_url):
"""Charge dons.php ; saute le test si les dons sont désactivés sur l'instance."""
reponse = page.goto(base_url + "/dons.php", wait_until="domcontentloaded")
if reponse is None or reponse.status != 200:
pytest.skip(f"dons.php indisponible (HTTP {reponse.status if reponse else '?'})")
def _collecter_erreurs_console(page):
"""Installe la collecte des erreurs console/JS ; retourne la liste à vérifier."""
erreurs = []
page.on("console", lambda msg: erreurs.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: erreurs.append(str(exc)))
return erreurs
def test_dons_scripts_inline_ont_un_nonce(page, base_url):
"""Tous les scripts inline de dons.php portent le nonce CSP (COR-7)."""
_aller_page_dons(page, base_url)
sans_nonce = page.locator("script:not([src]):not([nonce])").evaluate_all(
"(els) => els.map(e => e.outerHTML.slice(0, 80))"
)
assert sans_nonce == [], f"Scripts inline sans nonce : {sans_nonce}"
onclick = page.locator("[onclick]").count()
assert onclick == 0, f"{onclick} élément(s) avec un attribut onclick (bloqué par la CSP)"
def test_dons_anti_flash_theme_sans_erreur(page, base_url):
"""Le script anti-flash noncé s'exécute : thème sombre appliqué dès le chargement."""
page.add_init_script("localStorage.setItem('theme', 'dark');")
erreurs = _collecter_erreurs_console(page)
_aller_page_dons(page, base_url)
assert page.locator("html").get_attribute("data-theme") == "dark", (
"data-theme absent : le script anti-flash a été bloqué par la CSP"
)
assert erreurs == [], f"Erreurs console sur dons.php : {erreurs}"
def test_dons_onglets_stripe(page, base_url):
"""Les onglets Don ponctuel / Don mensuel fonctionnent via addEventListener."""
_aller_page_dons(page, base_url)
onglets = page.locator(".donation-tabs .tab-btn")
if onglets.count() == 0:
pytest.skip("Stripe désactivé sur cette instance (STRIPE_ENABLED=false)")
onglet_mensuel = page.locator('.tab-btn[data-tab="monthly"]')
onglet_ponctuel = page.locator('.tab-btn[data-tab="onetime"]')
# État initial : don ponctuel actif
expect(page.locator("#onetime-tab")).to_have_class("tab-content active")
expect(page.locator("#monthly-tab")).to_have_class("tab-content")
# dispatch_event plutôt que click : les polices chargées depuis cdnjs décalent
# la mise en page et rendent le clic aux coordonnées intermittent. Le but est
# de vérifier le câblage addEventListener (bloqué avant la correction CSP).
onglet_mensuel.dispatch_event("click")
expect(page.locator("#monthly-tab")).to_have_class("tab-content active")
expect(page.locator("#onetime-tab")).to_have_class("tab-content")
expect(onglet_mensuel).to_have_class("tab-btn active")
# Retour au don ponctuel
onglet_ponctuel.dispatch_event("click")
expect(page.locator("#onetime-tab")).to_have_class("tab-content active")
expect(page.locator("#monthly-tab")).to_have_class("tab-content")
def test_bouton_install_pwa_utilise_is_hidden(page, base_url):
"""Le bouton d'installation PWA est masqué par .is-hidden, sans style inline (COR-4)."""
_aller_page_dons(page, base_url)
bouton = page.locator("#install-pwa")
assert bouton.count() == 1, "Bouton #install-pwa absent du header"
assert bouton.get_attribute("style") is None, "Attribut style résiduel (bloqué par la CSP)"
assert bouton.get_attribute("nonce") is None, "Attribut nonce résiduel sur un non-script"
assert bouton.evaluate("(e) => e.classList.contains('is-hidden')")
assert not bouton.is_visible(), "Le bouton PWA devrait être masqué sans beforeinstallprompt"
+125
View File
@@ -0,0 +1,125 @@
"""Tests E2E de la pagination de la recherche (recherche.php).
La pagination est déléguée à l'API PeerTube (paramètre start) et le total
affiché est le total réel renvoyé par l'API — et non le nombre de vidéos
réellement présentes sur la page.
"""
import json
import math
import re
import urllib.parse
import urllib.request
import pytest
VIDEOS_PAR_PAGE = 12 # VIDEOS_PER_PAGE (includes/config.default.php)
TERMES_CANDIDATS = ["a", "e", "video", "les"]
def _total_api(peertube_url, terme):
"""Total d'une recherche plein texte côté API PeerTube."""
params = urllib.parse.urlencode({
"search": terme,
"isLocal": "true",
"count": 1,
})
url = f"{peertube_url}/api/v1/search/videos?{params}"
with urllib.request.urlopen(url, timeout=30) as rep:
return int(json.load(rep).get("total", 0))
def _nombre_resultats(page):
"""Extrait le total affiché dans le bandeau des résultats."""
texte = page.locator(".search-results-count p").inner_text()
m = re.search(r"(\d+)", texte)
assert m, f"Total introuvable dans le bandeau : {texte!r}"
return int(m.group(1))
def _ids_cartes(page):
"""Identifiants des vidéos affichées dans la grille de résultats."""
cartes = page.locator(".video-grid .video-card")
return {
cartes.nth(i).get_attribute("data-video-id")
for i in range(cartes.count())
}
@pytest.fixture(scope="session")
def terme_paginable(peertube_url):
"""Un terme de recherche couvrant au moins deux pages de résultats."""
for terme in TERMES_CANDIDATS:
try:
if _total_api(peertube_url, terme) > VIDEOS_PAR_PAGE:
return terme
except Exception:
continue
pytest.skip("Aucun terme avec plus d'une page de résultats (réseau ?)")
def test_recherche_affiche_total_reel(page, base_url, peertube_url, terme_paginable):
"""Le total affiché est le total réel de l'API, pas le contenu de la page."""
total_api = _total_api(peertube_url, terme_paginable)
page.goto(
f"{base_url}/recherche.php?q={urllib.parse.quote(terme_paginable)}",
wait_until="domcontentloaded",
)
cartes = page.locator(".video-grid .video-card")
if cartes.count() == 0:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
total_affiche = _nombre_resultats(page)
assert total_affiche == total_api
# La première page ne contient qu'une page de résultats, pas le total
assert total_affiche > cartes.count()
assert cartes.count() == min(VIDEOS_PAR_PAGE, total_affiche)
# La barre de pagination reflète le total réel
assert page.locator(".pagination").count() == 1
assert page.locator(".page-number.current").inner_text() == "1"
numeros = [
int(page.locator(".page-number").nth(i).inner_text())
for i in range(page.locator(".page-number").count())
]
assert max(numeros) == math.ceil(total_affiche / VIDEOS_PAR_PAGE)
def test_recherche_navigation_page_2(page, base_url, terme_paginable):
"""Le lien « Suivant » mène à la page 2, avec des vidéos différentes."""
page.goto(
f"{base_url}/recherche.php?q={urllib.parse.quote(terme_paginable)}",
wait_until="domcontentloaded",
)
ids_page1 = _ids_cartes(page)
if not ids_page1:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
page.locator(".page-link.next").click()
page.wait_for_url(re.compile(r"[?&]page=2(&|$)"))
assert page.locator(".page-number.current").inner_text() == "2"
assert page.locator(".page-link.prev").count() == 1
ids_page2 = _ids_cartes(page)
assert ids_page2, "La page 2 ne contient aucune vidéo"
assert ids_page2.isdisjoint(ids_page1), "Les pages 1 et 2 partagent des vidéos"
def test_recherche_page_hors_limites(page, base_url, terme_paginable):
"""Une page au-delà de la dernière retombe sur une page valide."""
page.goto(
f"{base_url}/recherche.php?q={urllib.parse.quote(terme_paginable)}&page=99999999",
wait_until="domcontentloaded",
)
cartes = page.locator(".video-grid .video-card")
if cartes.count() == 0:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
# La page se rabat sur une page valide (jamais de grille vide ni d'erreur)
total_affiche = _nombre_resultats(page)
assert total_affiche > 0
assert page.locator(".page-number.current").count() == 1
page_courante = int(page.locator(".page-number.current").inner_text())
assert 1 <= page_courante <= math.ceil(total_affiche / VIDEOS_PAR_PAGE)
+25
View File
@@ -88,3 +88,28 @@ def test_video_page_invalid_id_redirects(page, base_url):
page.goto(base_url + "/video.php?id=pas-un-uuid-valide", page.goto(base_url + "/video.php?id=pas-un-uuid-valide",
wait_until="domcontentloaded") wait_until="domcontentloaded")
assert page.url.endswith("/index.php"), f"URL après redirection : {page.url}" assert page.url.endswith("/index.php"), f"URL après redirection : {page.url}"
def test_video_page_not_found_no_js_errors(page, base_url):
"""Un UUID bien formé mais inexistant affiche « Vidéo non trouvée » sans erreur JS."""
erreurs_js = []
page.on("pageerror", lambda exc: erreurs_js.append(str(exc)))
page.goto(base_url + "/video.php?id=00000000-0000-0000-0000-000000000000",
wait_until="load")
# La page d'erreur est affichée (pas de redirection : l'UUID est bien formé ;
# si l'API est indisponible, la réponse vide mène au même état)
assert page.locator(".error-message").count() == 1, "Le message d'erreur est absent"
assert page.locator("#error-heading").inner_text() == "Vidéo non trouvée"
# Les modales téléchargement/partage et leur script ne doivent pas être rendus
assert page.locator("#download-modal").count() == 0, (
"La modale de téléchargement est présente sur la page d'erreur"
)
assert page.locator("#share-modal").count() == 0, (
"La modale de partage est présente sur la page d'erreur"
)
assert erreurs_js == [], (
f"Erreurs JS sur la page « vidéo introuvable » : {erreurs_js}"
)
+88 -8
View File
@@ -80,11 +80,17 @@ function loadCountdown({ elements = null, now = FIXED_NOW } = {}) {
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
const CountdownTimer = vm.runInContext(COUNTDOWN_SOURCE + '\nCountdownTimer;', sandbox); const { CountdownTimer, parseTargetDate } = vm.runInContext(
COUNTDOWN_SOURCE + '\n({ CountdownTimer, parseTargetDate });',
sandbox
);
return { CountdownTimer, window: windowMock, timers }; return { CountdownTimer, parseTargetDate, window: windowMock, timers };
} }
// Convertit un timestamp en chaîne ISO 8601, comme le fait PHP via DateTime::ATOM
const toISO = (timestamp) => new Date(timestamp).toISOString();
describe('CountdownTimer', () => { describe('CountdownTimer', () => {
test('formatNumber complète les nombres sur deux chiffres', () => { test('formatNumber complète les nombres sur deux chiffres', () => {
const { CountdownTimer } = loadCountdown(); const { CountdownTimer } = loadCountdown();
@@ -104,7 +110,7 @@ describe('CountdownTimer', () => {
const elements = makeElements(); const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements }); const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW + distance); const timer = new CountdownTimer(toISO(FIXED_NOW + distance));
try { try {
assert.strictEqual(elements['countdown-days'].textContent, '02'); assert.strictEqual(elements['countdown-days'].textContent, '02');
@@ -121,7 +127,7 @@ describe('CountdownTimer', () => {
const elements = makeElements(); const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements }); const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW + distance); const timer = new CountdownTimer(toISO(FIXED_NOW + distance));
try { try {
assert.strictEqual(elements['countdown-days'].textContent, '01'); assert.strictEqual(elements['countdown-days'].textContent, '01');
@@ -136,7 +142,7 @@ describe('CountdownTimer', () => {
test('une date passée arrête le compte à rebours et redirige vers /', () => { test('une date passée arrête le compte à rebours et redirige vers /', () => {
const elements = makeElements(); const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements }); const { CountdownTimer, window } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW - 1000); const timer = new CountdownTimer(toISO(FIXED_NOW - 1000));
try { try {
// onComplete() redirige vers la page principale // onComplete() redirige vers la page principale
@@ -155,7 +161,7 @@ describe('CountdownTimer', () => {
test('une distance nulle affiche zéro partout sans rediriger', () => { test('une distance nulle affiche zéro partout sans rediriger', () => {
const elements = makeElements(); const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements }); const { CountdownTimer, window } = loadCountdown({ elements });
const timer = new CountdownTimer(FIXED_NOW); const timer = new CountdownTimer(toISO(FIXED_NOW));
try { try {
assert.strictEqual(elements['countdown-days'].textContent, '00'); assert.strictEqual(elements['countdown-days'].textContent, '00');
@@ -170,7 +176,7 @@ describe('CountdownTimer', () => {
test('fonctionne sans éléments DOM présents', () => { test('fonctionne sans éléments DOM présents', () => {
const { CountdownTimer } = loadCountdown({ elements: null }); const { CountdownTimer } = loadCountdown({ elements: null });
const timer = new CountdownTimer(FIXED_NOW + 60 * 1000); const timer = new CountdownTimer(toISO(FIXED_NOW + 60 * 1000));
// Aucune erreur attendue malgré l'absence des éléments // Aucune erreur attendue malgré l'absence des éléments
timer.stop(); timer.stop();
@@ -178,7 +184,7 @@ describe('CountdownTimer', () => {
test('démarre un intervalle d\'une seconde et stop() le nettoie', () => { test('démarre un intervalle d\'une seconde et stop() le nettoie', () => {
const { CountdownTimer, timers } = loadCountdown(); const { CountdownTimer, timers } = loadCountdown();
const timer = new CountdownTimer(FIXED_NOW + 60 * 1000); const timer = new CountdownTimer(toISO(FIXED_NOW + 60 * 1000));
assert.strictEqual(timers.intervals.length, 1); assert.strictEqual(timers.intervals.length, 1);
assert.strictEqual(timers.intervals[0].delay, 1000); assert.strictEqual(timers.intervals[0].delay, 1000);
@@ -187,4 +193,78 @@ describe('CountdownTimer', () => {
timer.stop(); timer.stop();
assert.deepStrictEqual(timers.cleared, [timer.interval]); assert.deepStrictEqual(timers.cleared, [timer.interval]);
}); });
test('accepte une date ISO 8601 avec fuseau horaire (contrat PHP)', () => {
// FIXED_NOW = 2030-01-01T00:00:00Z
// Cible : 2030-01-02T05:00:00+04:00, soit 2030-01-02T01:00:00Z → 1 jour + 1 heure
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer('2030-01-02T05:00:00+04:00');
try {
assert.strictEqual(elements['countdown-days'].textContent, '01');
assert.strictEqual(elements['countdown-hours'].textContent, '01');
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
} finally {
timer.stop();
}
});
});
describe('parseTargetDate', () => {
test('analyse une date ISO 8601 avec décalage horaire', () => {
const { parseTargetDate } = loadCountdown();
assert.strictEqual(
parseTargetDate('2025-10-11T00:00:00-04:00'),
Date.UTC(2025, 9, 11, 4, 0, 0)
);
assert.strictEqual(
parseTargetDate('2025-10-11T00:00:00+02:00'),
Date.UTC(2025, 9, 10, 22, 0, 0)
);
});
test('analyse une date ISO 8601 suffixée Z', () => {
const { parseTargetDate } = loadCountdown();
assert.strictEqual(
parseTargetDate('2025-10-11T00:00:00Z'),
Date.UTC(2025, 9, 11, 0, 0, 0)
);
});
test('accepte le format historique avec espace (cas Safari)', () => {
const { parseTargetDate } = loadCountdown();
// Sans fuseau explicite, la date est interprétée en UTC
assert.strictEqual(
parseTargetDate('2025-10-11 00:00:00'),
Date.UTC(2025, 9, 11, 0, 0, 0)
);
});
test('accepte les millisecondes et les décalages sans deux-points', () => {
const { parseTargetDate } = loadCountdown();
assert.strictEqual(parseTargetDate('2030-01-01T00:00:00.000Z'), FIXED_NOW);
assert.strictEqual(
parseTargetDate('2025-10-11T00:00:00-0400'),
Date.UTC(2025, 9, 11, 4, 0, 0)
);
});
test('accepte un timestamp numérique tel quel', () => {
const { parseTargetDate } = loadCountdown();
assert.strictEqual(parseTargetDate(FIXED_NOW), FIXED_NOW);
});
test('retourne le même instant que new Date pour une date ISO valide', () => {
const { parseTargetDate } = loadCountdown();
const iso = '2030-06-15T12:30:45+00:00';
assert.strictEqual(parseTargetDate(iso), new Date(iso).getTime());
});
}); });
+3 -3
View File
@@ -6,8 +6,8 @@
* SANS aucun appel réseau : PEERTUBE_URL pointe volontairement sur * SANS aucun appel réseau : PEERTUBE_URL pointe volontairement sur
* 127.0.0.1, ce qui fait échouer isValidPeerTubeUrl() et court-circuite * 127.0.0.1, ce qui fait échouer isValidPeerTubeUrl() et court-circuite
* callPeerTubeApiOriginal() (retour [] immédiat, jamais de cURL). De ce * callPeerTubeApiOriginal() (retour [] immédiat, jamais de cURL). De ce
* fait, initCategories() — appelée au chargement de config.php — est * fait, getPeertubeCategories() — chargement paresseux des catégories —
* neutralisée et PEERTUBE_CATEGORIES vaut un tableau vide. * retourne un tableau vide si un test l'appelle.
* *
* Les constantes sont définies AVANT le chargement de config.php pour * Les constantes sont définies AVANT le chargement de config.php pour
* primer sur les valeurs de config.default.php (protégées par !defined()). * primer sur les valeurs de config.default.php (protégées par !defined()).
@@ -31,4 +31,4 @@ require_once $includes_dir . '/security.php';
require_once $includes_dir . '/simple-cache.php'; require_once $includes_dir . '/simple-cache.php';
require_once $includes_dir . '/lib/markdown.php'; require_once $includes_dir . '/lib/markdown.php';
require_once $includes_dir . '/structured-data.php'; require_once $includes_dir . '/structured-data.php';
require_once $includes_dir . '/config.php'; // fournit les fonctions de formatage require_once $includes_dir . '/config.php'; // bootstrap (charge les modules de includes/lib/)
+111
View File
@@ -68,6 +68,117 @@ assertEquals(1, $cleaned, 'cleanup supprime uniquement les entrées expirées');
assertEquals('valide', $cache->get('cle-valide'), 'cleanup conserve les entrées valides'); assertEquals('valide', $cache->get('cle-valide'), 'cleanup conserve les entrées valides');
assertNull($cache->get('cle-perimee'), 'cleanup a bien supprimé l\'entrée expirée'); assertNull($cache->get('cle-perimee'), 'cleanup a bien supprimé l\'entrée expirée');
// --- Écriture verrouillée (LOCK_EX) ------------------------------------------
// Verrou structurel : set() écrit avec un verrou exclusif
$cacheSource = file_get_contents(dirname(__DIR__, 2) . '/includes/simple-cache.php');
assertContains('LOCK_EX', $cacheSource, 'set() écrit avec file_put_contents et LOCK_EX');
// Intégrité d'écriture : un contenu volumineux est relu sans troncature
$bigContent = ['payload' => str_repeat('données-€-', 5000)];
$cache->set('lock-test', [], $bigContent);
assertEquals($bigContent, $cache->get('lock-test'), 'une écriture volumineuse est relue intégralement');
// --- getPeerTubeCacheTtl() : correspondance exacte puis préfixe --------------
assertEquals(3600, getPeerTubeCacheTtl('videos/categories'), 'catégories : TTL 1 heure');
assertEquals(600, getPeerTubeCacheTtl('videos'), 'liste des vidéos : TTL 10 minutes');
assertEquals(600, getPeerTubeCacheTtl('search/videos'), 'recherche : TTL 10 minutes (match exact)');
assertEquals(900, getPeerTubeCacheTtl('wp-posts'), 'WordPress : TTL 15 minutes');
assertEquals(300, getPeerTubeCacheTtl('accounts'), 'comptes : TTL 5 minutes');
assertEquals(
300,
getPeerTubeCacheTtl('accounts/membre/videos'),
'vidéos d\'un compte (lives) : TTL 5 minutes — l\'ancien strpos capturait « videos » à 10 minutes'
);
assertEquals(600, getPeerTubeCacheTtl('videos/9cf2e3a1-abc'), 'détail vidéo : TTL 10 minutes');
assertEquals(600, getPeerTubeCacheTtl('videos/9cf2e3a1-abc/comment-threads'), 'commentaires : TTL 10 minutes');
assertEquals(600, getPeerTubeCacheTtl('video-channels/ma-chaine/videos'), 'vidéos d\'une chaîne : TTL 10 minutes');
assertEquals(300, getPeerTubeCacheTtl('endpoint-inconnu'), 'endpoint inconnu : TTL par défaut de 5 minutes');
// --- clear() : purge complète -------------------------------------------------
$cache->clear(); // repartir d'un dossier vide
$cache->set('purge-1', [], 'un');
$cache->set('purge-2', [], 'deux', 300);
assertEquals(2, count(glob($tmpDir . '/cache_*.json')), 'deux entrées présentes avant purge');
$deleted = $cache->clear();
assertEquals(2, $deleted, 'clear() supprime toutes les entrées, même valides');
assertEquals(0, count(glob($tmpDir . '/cache_*.json')), 'le dossier est vide après clear()');
assertNull($cache->get('purge-1'), 'une entrée purgée n\'est plus lisible');
// --- CACHE_ENABLED honoré par callPeerTubeApiCached ---------------------------
// Impossible de redéfinir une constante dans ce processus (CACHE_ENABLED=true
// dans bootstrap.php) : on teste dans un sous-processus PHP avec un stub de
// callPeerTubeApiOriginal qui compte les appels réels.
$root = dirname(__DIR__, 2);
$runCacheSnippet = function (string $code): string {
$runner = tempnam(sys_get_temp_dir(), 'cache-runner-') . '.php';
file_put_contents($runner, "<?php\n" . $code . "\n");
$output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($runner) . ' 2>&1');
unlink($runner);
return $output ?? '';
};
$flagTestDir = sys_get_temp_dir() . '/annu-kute-cache-flag-' . getmypid();
mkdir($flagTestDir, 0755, true);
$snippetTemplate = <<<'PHP'
define('CACHE_ENABLED', %s);
require %s;
$GLOBALS['simple_api_cache'] = new SimpleAPICache(%s);
$GLOBALS['api_calls'] = 0;
function callPeerTubeApiOriginal($endpoint, $params = []) {
$GLOBALS['api_calls']++;
return ['data' => ['appel-' . $GLOBALS['api_calls']]];
}
callPeerTubeApiCached('videos-test-flag');
callPeerTubeApiCached('videos-test-flag');
echo $GLOBALS['api_calls'];
PHP;
$simpleCachePath = var_export($root . '/includes/simple-cache.php', true);
$flagDirExport = var_export($flagTestDir, true);
// Cache désactivé : deux appels API réels, aucune écriture de cache
$outDisabled = trim($runCacheSnippet(sprintf($snippetTemplate, 'false', $simpleCachePath, $flagDirExport)));
assertEquals('2', $outDisabled, 'CACHE_ENABLED=false : chaque appel va à l\'API (pas de lecture cache)');
assertEquals(0, count(glob($flagTestDir . '/cache_*.json')), 'CACHE_ENABLED=false : aucune écriture de cache');
// Cache activé : le second appel est servi par le cache
$outEnabled = trim($runCacheSnippet(sprintf($snippetTemplate, 'true', $simpleCachePath, $flagDirExport)));
assertEquals('1', $outEnabled, 'CACHE_ENABLED=true : le second appel est servi par le cache');
assertEquals(1, count(glob($flagTestDir . '/cache_*.json')), 'CACHE_ENABLED=true : une entrée écrite en cache');
foreach (glob($flagTestDir . '/cache_*.json') as $file) {
unlink($file);
}
rmdir($flagTestDir);
// --- scripts/purge-cache.php ---------------------------------------------------
$purgeDir = sys_get_temp_dir() . '/annu-kute-purge-' . getmypid();
mkdir($purgeDir, 0755, true);
file_put_contents($purgeDir . '/cache_a.json', '{}');
file_put_contents($purgeDir . '/cache_b.json', '{}');
file_put_contents($purgeDir . '/cache_c.json', '{}');
file_put_contents($purgeDir . '/autre.txt', 'conservé'); // ne doit pas être supprimé
$purgeOutput = shell_exec(
escapeshellarg(PHP_BINARY) . ' ' .
escapeshellarg($root . '/scripts/purge-cache.php') . ' ' .
escapeshellarg($purgeDir) . ' 2>&1'
) ?? '';
assertContains('Cache purgé : 3', $purgeOutput, 'le script rapporte les 3 entrées supprimées');
assertEquals(0, count(glob($purgeDir . '/cache_*.json')), 'le script supprime tous les fichiers de cache');
assertTrue(file_exists($purgeDir . '/autre.txt'), 'le script conserve les fichiers hors cache');
unlink($purgeDir . '/autre.txt');
rmdir($purgeDir);
// --- Nettoyage du dossier temporaire ---------------------------------------- // --- Nettoyage du dossier temporaire ----------------------------------------
foreach (glob($tmpDir . '/cache_*.json') as $file) { foreach (glob($tmpDir . '/cache_*.json') as $file) {
+221
View File
@@ -0,0 +1,221 @@
<?php
/**
* Tests du groupe « dons » : synchronisation dons.php / dons.php.sample,
* gabarits d'erreur 404/500 et blocage des artefacts de documentation.
*
* ARC-7 : dons.php inclut 404.php / 500.php — les gabarits .sample existent
* et le chemin d'erreur affiche une page propre (sans warning PHP).
* SEC-6 : DEPLOY.adoc/html/pdf à la racine sont bloqués par les modèles
* nginx / Apache.
* Dérive : linkUrlsInText() présent dans dons.php comme dans le .sample.
*/
$root = dirname(__DIR__, 2);
// --- Synchronisation dons.php / dons.php.sample -----------------------------
// dons.php est une copie d'instance ignorée par git : dans le CI elle est
// absente, on la crée depuis le .sample pour les tests et on la nettoie après.
$donsLivePath = $root . '/dons.php';
$donsLiveCreated = false;
if (!file_exists($donsLivePath)) {
copy($root . '/dons.php.sample', $donsLivePath);
$donsLiveCreated = true;
}
$donsLive = file_get_contents($donsLivePath);
$donsSample = file_get_contents($root . '/dons.php.sample');
assertContains(
'function linkUrlsInText',
$donsLive,
'dons.php définit linkUrlsInText() comme le .sample'
);
assertContains(
'linkUrlsInText(DONATIONS_OKI_DISCLAIMER)',
$donsLive,
'dons.php rend le message de transparence avec linkUrlsInText()'
);
assertNotContains(
'htmlspecialchars(DONATIONS_OKI_DISCLAIMER)',
$donsLive,
'dons.php n\'utilise plus un simple htmlspecialchars() pour le message de transparence'
);
// La définition de la fonction doit être identique dans les deux fichiers
// (verrou anti-dérive entre le modèle et la copie d'instance)
$extractFunction = function (string $source): ?string {
if (preg_match('/(function linkUrlsInText\(string \$text\): string \{.*?\n\})/s', $source, $m)) {
return $m[1];
}
return null;
};
assertEquals(
$extractFunction($donsSample),
$extractFunction($donsLive),
'linkUrlsInText() est identique dans dons.php et dons.php.sample'
);
// --- linkUrlsInText() : comportement ----------------------------------------
if (!function_exists('linkUrlsInText')) {
eval($extractFunction($donsSample));
}
assertEquals(
'Texte &amp; &quot;échappé&quot;',
linkUrlsInText('Texte & "échappé"'),
'linkUrlsInText échappe le texte sans URL'
);
$linked = linkUrlsInText('Soutenez-nous sur https://example.com/dons merci');
assertContains(
'<a href="https://example.com/dons" target="_blank" rel="noopener noreferrer">https://example.com/dons</a>',
$linked,
'linkUrlsInText rend les URLs cliquables avec target et rel'
);
assertEquals(
'&lt;script&gt;alert(1)&lt;/script&gt;',
linkUrlsInText('<script>alert(1)</script>'),
'linkUrlsInText échappe le HTML injecté dans le texte'
);
assertContains(
'href="https://example.com/?a=1&amp;b=2"',
linkUrlsInText('Lien : https://example.com/?a=1&b=2'),
'linkUrlsInText échappe les & dans l\'URL du lien'
);
// --- Gabarits d'erreur 404.php.sample / 500.php.sample (ARC-7) ---------------
foreach (['404' => '404.php.sample', '500' => '500.php.sample'] as $code => $file) {
$path = $root . '/' . $file;
assertTrue(file_exists($path), "$file existe");
$template = file_get_contents($path);
assertContains(
"http_response_code($code)",
$template,
"$file positionne le code HTTP $code"
);
assertContains(
'noindex',
$template,
"$file demande la non-indexation (robots noindex)"
);
assertNotContains(
'style="',
$template,
"$file n'utilise pas d'attribut style inline (bloqué par la CSP)"
);
// Tout <script> inline (sans src) doit porter un nonce CSP
preg_match_all('/<script(?![^>]*\bsrc=)[^>]*>/i', $template, $inlineScripts);
$allNonced = true;
foreach ($inlineScripts[0] as $scriptTag) {
if (strpos($scriptTag, 'nonce=') === false) {
$allNonced = false;
}
}
assertTrue($allNonced, "$file : tous les scripts inline portent un nonce CSP");
// Le gabarit doit être syntaxiquement valide
$lint = shell_exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path) . ' 2>&1');
assertContains('No syntax errors', $lint, "$file passe php -l");
}
// --- Blocage des artefacts de documentation (SEC-6) --------------------------
$nginxConf = file_get_contents($root . '/conf/nginx.conf.sample');
assertContains(
'^/[^/]+\.(adoc|html|pdf)$',
$nginxConf,
'nginx.conf.sample bloque les .adoc/.html/.pdf à la racine web'
);
$htaccess = file_get_contents($root . '/conf/.htaccess.sample');
assertContains(
'RewriteRule ^[^/]+\.(adoc|html|pdf)$ - [F,L,NC]',
$htaccess,
'.htaccess.sample bloque les .adoc/.html/.pdf à la racine web'
);
// --- dons.php : chemins d'erreur 404 et 500 (exécution réelle) ---------------
// dons.php inclut 404.php / 500.php (copies d'instance, non versionnées) :
// on les crée à partir des .sample si elles sont absentes, puis on nettoie.
$createdCopies = [];
foreach (['404.php', '500.php'] as $copy) {
$copyPath = $root . '/' . $copy;
if (!file_exists($copyPath)) {
copy($root . '/' . $copy . '.sample', $copyPath);
$createdCopies[] = $copyPath;
}
}
/**
* Exécute dons.php dans un sous-processus PHP avec des constantes prédéfinies
* (PEERTUBE_URL privée : aucun appel réseau, cf. bootstrap.php).
*
* @param array $defines Constantes définies avant le chargement de dons.php
* @return string Sortie complète du sous-processus (stdout + stderr)
*/
$runDonsPage = function (array $defines) use ($root): string {
$lines = ['<?php'];
foreach ($defines as $name => $value) {
$lines[] = 'define(' . var_export($name, true) . ', ' . var_export($value, true) . ');';
}
$lines[] = 'chdir(' . var_export($root, true) . ');';
$lines[] = 'require ' . var_export($root . '/dons.php', true) . ';';
$runner = tempnam(sys_get_temp_dir(), 'dons-runner-') . '.php';
file_put_contents($runner, implode("\n", $lines) . "\n");
$output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($runner) . ' 2>&1');
unlink($runner);
return $output ?? '';
};
$noPlatform = [
'PEERTUBE_URL' => 'http://127.0.0.1', // URL privée volontaire : bloque tout appel API réel
'APP_HOST_NAME' => 'test.local',
'DONATIONS_ENABLED' => true,
'LIBERAPAY_URL' => '',
'KOFI_URL' => '',
'STRIPE_ENABLED' => false,
];
$phpErrorNeedles = ['PHP Warning', 'PHP Notice', 'PHP Deprecated', 'PHP Fatal error',
'Fatal error', 'Warning:', 'Notice:', 'Deprecated:', 'Uncaught'];
// Dons activés mais aucune plateforme configurée -> page 500 propre
$output500 = $runDonsPage($noPlatform);
assertContains(
'<p class="error-code" aria-hidden="true">500</p>',
$output500,
'dons.php sans plateforme configurée affiche la page 500'
);
foreach ($phpErrorNeedles as $needle) {
assertNotContains($needle, $output500, "page 500 sans erreur PHP ($needle)");
}
// Dons désactivés -> page 404 propre
$output404 = $runDonsPage(['DONATIONS_ENABLED' => false] + $noPlatform);
assertContains(
'<p class="error-code" aria-hidden="true">404</p>',
$output404,
'dons.php avec dons désactivés affiche la page 404'
);
foreach ($phpErrorNeedles as $needle) {
assertNotContains($needle, $output404, "page 404 sans erreur PHP ($needle)");
}
foreach ($createdCopies as $copyPath) {
unlink($copyPath);
}
if ($donsLiveCreated) {
unlink($donsLivePath);
}
+104 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
/** /**
* Tests unitaires pour les fonctions de formatage de includes/config.php * Tests unitaires pour les fonctions de formatage de includes/lib/format.php
* (formatDuration, formatViewCount, formatDate, formatVideosData) * (formatDuration, formatViewCount, formatDate, formatVideosData)
* et truncateText de includes/structured-data.php * et truncateText de includes/structured-data.php
*/ */
@@ -56,6 +56,21 @@ assertEquals(
'formatDate affiche les années au pluriel' 'formatDate affiche les années au pluriel'
); );
// --- formatDate : dates malformées (repli sur la chaîne brute) ---------------
assertEquals(
'pas une date',
formatDate('pas une date'),
'formatDate retourne la chaîne brute si la date est invalide'
);
assertEquals(
'2024-13-45T99:99:99Z',
formatDate('2024-13-45T99:99:99Z'),
'formatDate retourne une date ISO malformée telle quelle'
);
assertEquals('', formatDate(''), 'formatDate retourne une chaîne vide telle quelle');
assertEquals(' ', formatDate(' '), 'formatDate retourne une chaîne blanche telle quelle');
// --- formatVideosData ------------------------------------------------------- // --- formatVideosData -------------------------------------------------------
$rawVideos = [ $rawVideos = [
@@ -125,6 +140,58 @@ assertEquals('', $videos[1]['description'], 'formatVideosData met une descriptio
assertEquals([], $videos[1]['tags'], 'formatVideosData met des tags vides par défaut'); assertEquals([], $videos[1]['tags'], 'formatVideosData met des tags vides par défaut');
assertFalse($videos[1]['isLive'], 'formatVideosData met isLive à false par défaut'); assertFalse($videos[1]['isLive'], 'formatVideosData met isLive à false par défaut');
// --- formatVideosData : données API incomplètes -------------------------------
$incompleteVideos = formatVideosData([
[
// Sans uuid : doit être ignorée
'name' => 'Sans uuid',
'duration' => 10,
],
[
// uuid vide : doit être ignorée aussi
'uuid' => '',
'name' => 'Uuid vide',
],
[
// uuid seul : valeurs par défaut partout ailleurs
'uuid' => 'ghi-789',
],
[
// channel présent mais sans displayName ni avatars
'uuid' => 'jkl-012',
'channel' => ['name' => 'compte'],
],
]);
assertEquals(2, count($incompleteVideos), 'formatVideosData ignore les entrées sans uuid');
assertEquals('ghi-789', $incompleteVideos[0]['id'], 'formatVideosData conserve l\'uuid seul');
assertEquals('', $incompleteVideos[0]['title'], 'formatVideosData met un titre vide par défaut');
assertEquals(0, $incompleteVideos[0]['duration'], 'formatVideosData met une durée à 0 par défaut');
assertEquals('', $incompleteVideos[0]['channel'], 'formatVideosData met une chaîne vide par défaut');
assertEquals(0, $incompleteVideos[0]['views'], 'formatVideosData met les vues à 0 par défaut');
assertEquals('', $incompleteVideos[0]['date'], 'formatVideosData met une date vide par défaut');
assertNull($incompleteVideos[0]['aspectRatio'], 'formatVideosData met aspectRatio à null par défaut');
assertFalse($incompleteVideos[0]['isLive'], 'formatVideosData met isLive à false par défaut');
assertEquals(
'img/default-thumbnail.jpg',
$incompleteVideos[0]['thumbnail'],
'formatVideosData met la vignette par défaut pour une entrée minimale'
);
assertEquals(
'img/default-avatar.png',
$incompleteVideos[0]['channelAvatar'],
'formatVideosData met l\'avatar par défaut pour une entrée minimale'
);
assertEquals('', $incompleteVideos[1]['channel'], 'formatVideosData met une chaîne vide si displayName absent');
assertEquals(
'img/default-avatar.png',
$incompleteVideos[1]['channelAvatar'],
'formatVideosData met l\'avatar par défaut si le tableau avatars est absent'
);
// --- truncateText (includes/structured-data.php) ---------------------------- // --- truncateText (includes/structured-data.php) ----------------------------
assertEquals('court', truncateText('court', 200), 'truncateText laisse un texte court intact'); assertEquals('court', truncateText('court', 200), 'truncateText laisse un texte court intact');
@@ -143,3 +210,39 @@ assertEquals(
truncateText($textNoSpace, 200), truncateText($textNoSpace, 200),
'truncateText coupe à la limite quand il n\'y a pas d\'espace' 'truncateText coupe à la limite quand il n\'y a pas d\'espace'
); );
// --- truncateText : UTF-8 multioctet (mb_substr) -----------------------------
$utf8WithSpace = str_repeat('é', 150) . ' ' . str_repeat('b', 100); // 251 caractères
assertEquals(
str_repeat('é', 150) . '...',
truncateText($utf8WithSpace, 200),
'truncateText compte les caractères UTF-8 et non les octets'
);
$utf8NoSpace = str_repeat('€', 250); // 3 octets par caractère
$truncatedUtf8 = truncateText($utf8NoSpace, 200);
assertEquals(
str_repeat('€', 200) . '...',
$truncatedUtf8,
'truncateText ne coupe pas un caractère multioctet en deux'
);
assertEquals(1, preg_match('//u', $truncatedUtf8), 'truncateText retourne de l\'UTF-8 valide');
// --- formatDateFr (includes/structured-data.php) -----------------------------
$dateFr = new DateTime('2025-10-11 00:00:00', new DateTimeZone('Indian/Reunion'));
assertEquals('11 octobre 2025', formatDateFr($dateFr), 'formatDateFr formate la date longue en français');
assertEquals(
'11 octobre 2025 à 00:00',
formatDateFr($dateFr, true),
'formatDateFr ajoute l\'heure quand demandé'
);
$dateFrSummer = new DateTime('2026-07-04 12:30:00', new DateTimeZone('UTC'));
assertEquals('4 juillet 2026', formatDateFr($dateFrSummer), 'formatDateFr sans zéro initial sur le jour');
assertEquals(
'4 juillet 2026 à 12:30',
formatDateFr($dateFrSummer, true),
'formatDateFr utilise le fuseau de la date fournie'
);
+34 -11
View File
@@ -1,9 +1,6 @@
<?php <?php
/** /**
* Tests unitaires pour markdown_to_html (includes/lib/markdown.php) * Tests unitaires pour markdown_to_html (includes/lib/markdown.php)
*
* Les sorties attendues reflètent le comportement actuel de la fonction,
* y compris ses particularités (voir la note sur les listes à puces).
*/ */
// --- Échappement XSS --------------------------------------------------------- // --- Échappement XSS ---------------------------------------------------------
@@ -63,21 +60,47 @@ assertEquals(
'markdown_to_html ne transforme pas un numéro de version en lien' 'markdown_to_html ne transforme pas un numéro de version en lien'
); );
// --- Listes ------------------------------------------------------------------ // --- URLs contenant « & » : pas de double encodage ---------------------------
// Note : la passe des listes numérotées s'applique aussi aux <li> déjà // Le texte est échappé une seule fois : « & » devient « &amp; », jamais « &amp;amp; »
// produits par la passe des puces, d'où un double enveloppement <ul><ol>.
// C'est le comportement actuel, verrouillé ici contre toute régression.
assertEquals( assertEquals(
"<ul><ol><li>a</li>\n<li>b</li></ol></ul>", 'Voir <a href="https://exemple.com/page?a=1&amp;b=2" target="_blank" rel="noopener noreferrer">https://exemple.com/page?a=1&amp;b=2</a> suite',
markdown_to_html("- a\n- b"), markdown_to_html('Voir https://exemple.com/page?a=1&b=2 suite'),
'markdown_to_html convertit les listes à puces (double enveloppement actuel)' 'markdown_to_html n\'encode pas deux fois le & des URLs brutes'
); );
assertEquals( assertEquals(
"<ol><li>a</li>\n<li>b</li></ol>", '<a href="https://exemple.com/?x=1&amp;y=2" target="_blank" rel="noopener noreferrer">lien</a>',
markdown_to_html('[lien](https://exemple.com/?x=1&y=2)'),
'markdown_to_html n\'encode pas deux fois le & des liens Markdown'
);
assertNotContains(
'&amp;amp;',
markdown_to_html('https://exemple.com/page?a=1&b=2'),
'markdown_to_html ne produit jamais de &amp;amp;'
);
// --- Listes ------------------------------------------------------------------
assertEquals(
"<ul>\n<li>a</li>\n<li>b</li>\n</ul>",
markdown_to_html("- a\n- b"),
'markdown_to_html convertit les listes à puces en <ul> uniquement'
);
assertEquals(
"<ol>\n<li>a</li>\n<li>b</li>\n</ol>",
markdown_to_html("1. a\n2. b"), markdown_to_html("1. a\n2. b"),
'markdown_to_html convertit les listes numérotées en <ol>' 'markdown_to_html convertit les listes numérotées en <ol>'
); );
assertEquals(
"<ul>\n<li>a</li>\n</ul><br />\n<ol>\n<li>b</li>\n</ol><br />\n<ul>\n<li>c</li>\n</ul>",
markdown_to_html("- a\n1. b\n- c"),
'markdown_to_html sépare les listes de types différents'
);
assertEquals(
"<ul>\n<li>a</li>\n</ul><br />\ntexte",
markdown_to_html("- a\ntexte"),
'markdown_to_html ferme la liste avant le texte qui suit'
);
// --- Retours à la ligne ------------------------------------------------------ // --- Retours à la ligne ------------------------------------------------------
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* Tests unitaires pour checkRateLimit() (includes/security.php)
*/
$rlDir = sys_get_temp_dir() . '/annu-kute-rl-test-' . getmypid();
// Nettoyage d'une éventuelle exécution précédente
foreach (glob($rlDir . '/rl_*.json') ?: [] as $oldFile) {
unlink($oldFile);
}
// --- Limite de base -----------------------------------------------------------
$ip = '203.0.113.10';
assertTrue(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 1re requête est autorisée');
assertTrue(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 2e requête est autorisée');
assertTrue(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 3e requête est autorisée');
assertFalse(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 4e requête est refusée (limite 3)');
assertFalse(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : une 5e requête reste refusée');
// --- Indépendance des identifiants --------------------------------------------
assertTrue(
checkRateLimit('203.0.113.99', 3, 60, $rlDir),
'rate limit : un autre identifiant a son propre compteur'
);
assertTrue(
checkRateLimit($ip, 3, 60, $rlDir . '/autre'),
'rate limit : un autre répertoire de stockage a son propre compteur'
);
// --- Réinitialisation de la fenêtre -------------------------------------------
// Forcer l'expiration de la fenêtre en réécrivant le fichier d'état
$stateFile = $rlDir . '/rl_' . hash('sha256', $ip) . '.json';
assertTrue(file_exists($stateFile), 'rate limit : le fichier d\'état existe');
file_put_contents($stateFile, json_encode(['count' => 3, 'reset' => time() - 1]));
assertTrue(
checkRateLimit($ip, 3, 60, $rlDir),
'rate limit : le compteur repart à zéro après expiration de la fenêtre'
);
// --- Fail-open si le stockage est indisponible ---------------------------------
// Un chemin qui est un fichier (pas un répertoire) : mkdir doit échouer
$notADir = $rlDir . '/fichier-bloquant';
file_put_contents($notADir, 'x');
assertTrue(
@checkRateLimit('198.51.100.5', 1, 60, $notADir),
'rate limit : fail-open si le répertoire de stockage est indisponible'
);
// --- Nettoyage -----------------------------------------------------------------
foreach ([$rlDir, $rlDir . '/autre'] as $dir) {
foreach (glob($dir . '/rl_*.json') ?: [] as $file) {
unlink($file);
}
if (is_dir($dir)) {
rmdir($dir);
}
}
unlink($notADir);
+127
View File
@@ -0,0 +1,127 @@
<?php
/**
* Tests unitaires pour searchVideos() (includes/config.php).
*
* Aucun appel réseau : des réponses API factices sont pré-déposées dans le
* cache (callPeerTubeApiCached consulte le cache avant tout appel cURL, qui
* de toute façon échouerait avec PEERTUBE_URL = http://127.0.0.1). Le
* répertoire de cache de l'instance globale est redirigé vers un dossier
* temporaire via réflexion pour ne jamais toucher au cache réel (cache/api).
*/
$cache = $GLOBALS['simple_api_cache'];
$cacheDirProperty = new ReflectionProperty(SimpleAPICache::class, 'cacheDir');
$cacheDirProperty->setAccessible(true);
$tmpDir = sys_get_temp_dir() . '/annu-kute-search-test-' . getmypid();
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
$cacheDirProperty->setValue($cache, $tmpDir);
/**
* Fabrique une entrée vidéo brute minimale au format API PeerTube
*/
function fakeApiVideo($uuid, $name = 'Vidéo de test') {
return [
'uuid' => $uuid,
'name' => $name,
'duration' => 42,
'publishedAt' => '2024-01-01T00:00:00.000Z'
];
}
// --- Requête vide -----------------------------------------------------------
$total = null;
$videos = searchVideos('', 12, 0, $total);
assertEquals([], $videos, 'une requête vide retourne un tableau vide');
assertEquals(0, $total, 'une requête vide donne un total de 0');
// --- Paramètres de la requête API (identiques à ceux de searchVideos) -------
$paramsPage1 = [
'search' => 'assemblee',
'count' => 12,
'start' => 0,
'isLocal' => true,
'sort' => '-publishedAt'
];
$paramsPage2 = $paramsPage1;
$paramsPage2['start'] = 12;
// Deux pages factices : total 25, 12 vidéos en page 1, 13 en page 2
$dataPage1 = [];
for ($i = 1; $i <= 12; $i++) {
$dataPage1[] = fakeApiVideo('uuid-page1-' . $i);
}
$dataPage2 = [];
for ($i = 1; $i <= 13; $i++) {
$dataPage2[] = fakeApiVideo('uuid-page2-' . $i);
}
$cache->set('search/videos', $paramsPage1, ['total' => 25, 'data' => $dataPage1]);
$cache->set('search/videos', $paramsPage2, ['total' => 25, 'data' => $dataPage2]);
// --- Total réel renvoyé par l'API -------------------------------------------
$total = null;
$videos = searchVideos('assemblee', 12, 0, $total);
assertEquals(12, count($videos), 'la page 1 retourne les 12 vidéos de la réponse');
assertEquals(25, $total, 'le total provient de l\'API (25), pas du nombre de vidéos reçues');
assertEquals('uuid-page1-1', $videos[0]['id'], 'les vidéos sont formatées par formatVideosData');
// --- Le paramètre start pilote la pagination ---------------------------------
$total = null;
$videos = searchVideos('assemblee', 12, 12, $total);
assertEquals(13, count($videos), 'start=12 retourne la deuxième page (13 vidéos)');
assertEquals('uuid-page2-1', $videos[0]['id'], 'start=12 interroge bien l\'API avec un décalage');
assertEquals(25, $total, 'le total reste le même sur la deuxième page');
// --- Recherche par hashtag (endpoint videos) ---------------------------------
$paramsTag = [
'tagsOneOf' => 'reunion',
'count' => 12,
'start' => 0,
'isLocal' => true,
'sort' => '-publishedAt'
];
$cache->set('videos', $paramsTag, ['total' => 3, 'data' => [
fakeApiVideo('uuid-tag-1'),
fakeApiVideo('uuid-tag-2'),
fakeApiVideo('uuid-tag-3')
]]);
$total = null;
$videos = searchVideos('#reunion', 12, 0, $total);
assertEquals(3, count($videos), 'la recherche par hashtag retourne les vidéos du tag');
assertEquals(3, $total, 'la recherche par hashtag renseigne aussi le total');
// --- Réponse sans clé total : repli sur le nombre de vidéos reçues ----------
$paramsSansTotal = $paramsPage1;
$paramsSansTotal['search'] = 'sanstotal';
$cache->set('search/videos', $paramsSansTotal, ['data' => [
fakeApiVideo('uuid-st-1'),
fakeApiVideo('uuid-st-2')
]]);
$total = null;
$videos = searchVideos('sanstotal', 12, 0, $total);
assertEquals(2, count($videos), 'réponse sans total : les vidéos sont retournées');
assertEquals(2, $total, 'réponse sans total : repli sur le nombre de vidéos reçues');
// --- Aucune entrée en cache et réseau bloqué : résultat vide -----------------
$total = null;
$videos = searchVideos('jamaisencache', 12, 0, $total);
assertEquals([], $videos, 'sans cache ni réseau, la recherche retourne un tableau vide');
assertEquals(0, $total, 'sans cache ni réseau, le total vaut 0');
// --- Nettoyage du dossier temporaire ----------------------------------------
foreach (glob($tmpDir . '/cache_*.json') as $file) {
unlink($file);
}
rmdir($tmpDir);
+182
View File
@@ -141,6 +141,18 @@ assertTrue(
isValidApiEndpoint('accounts/annu_kute_ced/videos'), isValidApiEndpoint('accounts/annu_kute_ced/videos'),
'isValidApiEndpoint accepte les vidéos d\'un compte' 'isValidApiEndpoint accepte les vidéos d\'un compte'
); );
assertTrue(
isValidApiEndpoint('video-channels/annu_kute_ced/videos'),
'isValidApiEndpoint accepte les vidéos d\'une chaîne'
);
assertFalse(
isValidApiEndpoint('video-channels/annu_kute_ced'),
'isValidApiEndpoint refuse une chaîne sans sous-chemin /videos'
);
assertFalse(
isValidApiEndpoint('video-channels'),
'isValidApiEndpoint refuse "video-channels" seul'
);
assertFalse(isValidApiEndpoint('../config'), 'isValidApiEndpoint refuse le path traversal'); assertFalse(isValidApiEndpoint('../config'), 'isValidApiEndpoint refuse le path traversal');
assertFalse(isValidApiEndpoint('videos/../x'), 'isValidApiEndpoint refuse ".." dans le chemin'); assertFalse(isValidApiEndpoint('videos/../x'), 'isValidApiEndpoint refuse ".." dans le chemin');
assertFalse(isValidApiEndpoint('videos//categories'), 'isValidApiEndpoint refuse un double slash'); assertFalse(isValidApiEndpoint('videos//categories'), 'isValidApiEndpoint refuse un double slash');
@@ -155,3 +167,173 @@ $nonce = getCspNonce();
assertTrue(is_string($nonce) && $nonce !== '', 'getCspNonce retourne une chaîne non vide'); assertTrue(is_string($nonce) && $nonce !== '', 'getCspNonce retourne une chaîne non vide');
assertEquals(16, strlen(base64_decode($nonce, true)), 'getCspNonce est un base64 de 16 octets aléatoires'); assertEquals(16, strlen(base64_decode($nonce, true)), 'getCspNonce est un base64 de 16 octets aléatoires');
assertEquals($nonce, getCspNonce(), 'getCspNonce retourne le même nonce durant toute la requête'); assertEquals($nonce, getCspNonce(), 'getCspNonce retourne le même nonce durant toute la requête');
// --- getCsrfSecret ------------------------------------------------------------
assertEquals(
CSRF_SECRET,
getCsrfSecret(),
'getCsrfSecret retourne CSRF_SECRET quand il est configuré'
);
// Repli éphémère : impossible à tester dans ce processus (CSRF_SECRET est déjà
// défini par le bootstrap), on passe par un sous-processus PHP isolé dont la
// sortie d'erreur est redirigée vers stdout pour capter l'avertissement.
$securityFile = dirname(__DIR__, 2) . '/includes/security.php';
$snippet = <<<'PHP'
require $argv[1];
define('CSRF_SECRET', CSRF_SECRET_PLACEHOLDER);
$s1 = getCsrfSecret();
$s2 = getCsrfSecret();
echo 'len=' . strlen($s1) . "\n";
echo 'same=' . ($s1 === $s2 ? 'yes' : 'no') . "\n";
echo 'placeholder=' . ($s1 === CSRF_SECRET ? 'yes' : 'no') . "\n";
$token = generateCSRFToken();
echo 'token=' . (validateCSRFToken($token) ? 'valid' : 'invalid') . "\n";
PHP;
$cmd = escapeshellarg(PHP_BINARY)
. ' -d log_errors=1 -d error_log=/dev/stdout -r '
. escapeshellarg($snippet)
. ' ' . escapeshellarg($securityFile);
$fallbackOutput = function_exists('shell_exec') ? shell_exec($cmd) : null;
if ($fallbackOutput === null) {
test_record(false, 'getCsrfSecret : sous-processus de test du repli éphémère (shell_exec indisponible)');
} else {
assertContains(
'SECURITY CRITICAL',
$fallbackOutput,
'getCsrfSecret enregistre un avertissement critique avec la valeur par défaut'
);
assertContains(
'CSRF_SECRET',
$fallbackOutput,
'l\'avertissement critique mentionne CSRF_SECRET'
);
assertContains(
'len=64',
$fallbackOutput,
'le secret éphémère fait 64 caractères hexadécimaux (32 octets)'
);
assertContains(
'same=yes',
$fallbackOutput,
'le secret éphémère est stable durant tout le processus'
);
assertContains(
'placeholder=no',
$fallbackOutput,
'le secret éphémère diffère de la valeur par défaut'
);
assertContains(
'token=valid',
$fallbackOutput,
'un token signé avec le secret éphémère est validé dans le même processus'
);
}
// --- isValidRemoteUrl (includes/security.php) ---------------------------------
assertTrue(isValidRemoteUrl('https://peertube.example.com'), 'isValidRemoteUrl accepte une URL HTTPS publique');
assertTrue(isValidRemoteUrl('https://kute.o-k-i.net'), 'isValidRemoteUrl accepte une instance Castopod publique');
assertTrue(isValidRemoteUrl('https://mizik.o-k-i.net'), 'isValidRemoteUrl accepte une instance Funkwhale publique');
assertTrue(isValidRemoteUrl('http://castopod.example.com'), 'isValidRemoteUrl accepte HTTP (développement)');
assertFalse(isValidRemoteUrl('ftp://kute.o-k-i.net'), 'isValidRemoteUrl refuse un schéma non HTTP(S)');
assertFalse(isValidRemoteUrl('pas-une-url'), 'isValidRemoteUrl refuse une chaîne mal formée');
assertFalse(isValidRemoteUrl(''), 'isValidRemoteUrl refuse une chaîne vide');
assertFalse(isValidRemoteUrl('https://localhost'), 'isValidRemoteUrl refuse localhost');
assertFalse(isValidRemoteUrl('http://127.0.0.1'), 'isValidRemoteUrl refuse 127.0.0.1');
assertFalse(isValidRemoteUrl('http://192.168.1.1'), 'isValidRemoteUrl refuse une IP privée (192.168.x)');
assertFalse(isValidRemoteUrl('http://10.0.0.5'), 'isValidRemoteUrl refuse une IP privée (10.x)');
assertFalse(isValidRemoteUrl('http://172.16.0.1'), 'isValidRemoteUrl refuse une IP privée (172.16.x)');
assertFalse(
isValidRemoteUrl('http://169.254.169.254/latest/meta-data'),
'isValidRemoteUrl refuse l\'IP de métadonnées cloud (link-local)'
);
assertFalse(
isValidRemoteUrl('https://metadata.google.internal'),
'isValidRemoteUrl refuse metadata.google.internal'
);
// --- cspOriginFromUrl ---------------------------------------------------------
assertEquals(
'https://peertube.example.com',
cspOriginFromUrl('https://peertube.example.com'),
'cspOriginFromUrl extrait l\'origine d\'une URL simple'
);
assertEquals(
'https://peertube.example.com',
cspOriginFromUrl('https://peertube.example.com/chemin/page?x=1'),
'cspOriginFromUrl ignore le chemin et la query string'
);
assertEquals(
'http://127.0.0.1',
cspOriginFromUrl('http://127.0.0.1'),
'cspOriginFromUrl conserve le schéma http'
);
assertEquals('', cspOriginFromUrl(''), 'cspOriginFromUrl refuse une chaîne vide');
assertEquals('', cspOriginFromUrl('pas-une-url'), 'cspOriginFromUrl refuse une URL mal formée');
// --- buildContentSecurityPolicy ------------------------------------------------
/**
* Extrait la valeur d'une directive CSP pour les assertions
*/
function csp_directive($csp, $name) {
if (preg_match('/(?:^| )' . preg_quote($name, '/') . ' ([^;]+)/', $csp, $m)) {
return $m[1];
}
return null;
}
$testNonce = 'nonce-de-test';
$csp = buildContentSecurityPolicy($testNonce);
assertContains(
"'nonce-{$testNonce}",
csp_directive($csp, 'script-src'),
'CSP : script-src porte le nonce de la requête'
);
assertContains(
"'nonce-{$testNonce}",
csp_directive($csp, 'style-src'),
'CSP : style-src porte le nonce de la requête'
);
// Domaines réellement utilisés présents
$imgSrc = csp_directive($csp, 'img-src');
$mediaSrc = csp_directive($csp, 'media-src');
assertContains(cspOriginFromUrl(PEERTUBE_URL), $imgSrc, 'CSP : img-src autorise le domaine PeerTube');
assertContains(cspOriginFromUrl(MASTODON_INSTANCE_URL), $imgSrc, 'CSP : img-src autorise le domaine Mastodon');
assertContains(cspOriginFromUrl(PEERTUBE_URL), $mediaSrc, 'CSP : media-src autorise le domaine PeerTube');
assertContains(cspOriginFromUrl(CASTOPOD_URL), $mediaSrc, 'CSP : media-src autorise le domaine Castopod');
assertContains(cspOriginFromUrl(CASTOPOD_URL), $imgSrc, 'CSP : img-src autorise les pochettes Castopod');
// Pas de joker https:/http: hors développement local (HTTP_HOST absent en CLI)
assertTrue(
preg_match('/(^|\s)https?:($|\s)/', $imgSrc) === 0,
'CSP : img-src ne contient pas de joker https: en production'
);
assertTrue(
preg_match('/(^|\s)https?:($|\s)/', $mediaSrc) === 0,
'CSP : media-src ne contient pas de joker https: en production'
);
// Directives de verrouillage toujours présentes
assertContains("object-src 'none'", $csp, 'CSP : object-src none est présent');
assertContains("frame-ancestors 'self'", $csp, 'CSP : frame-ancestors self est présent');
// En développement local, le joker HTTP(S) est réintroduit pour le contenu fédéré
$_SERVER['HTTP_HOST'] = 'localhost:8080';
$cspDev = buildContentSecurityPolicy($testNonce);
assertTrue(
preg_match('/(^|\s)https?:($|\s)/', csp_directive($cspDev, 'img-src')) === 1,
'CSP : img-src contient le joker https: en développement local'
);
assertTrue(
preg_match('/(^|\s)https?:($|\s)/', csp_directive($cspDev, 'media-src')) === 1,
'CSP : media-src contient le joker https: en développement local'
);
unset($_SERVER['HTTP_HOST']);
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* Tests unitaires pour includes/structured-data.php
* (encodage JSON-LD anti-injection et construction d'URL de confiance)
*/
// --- JSON-LD : neutralisation de "</script>" ---------------------------------
$maliciousTitle = 'Vidéo </script><script>alert("xss")</script> test';
$videoData = [
'thumbnailPath' => '/static/thumbnails/test.jpg',
];
$video = [
'id' => '9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
'title' => $maliciousTitle,
'description' => 'Une description',
'duration' => 120,
'channel' => 'Chaîne </script>',
'views' => 42,
'date' => '2024-01-15 10:00:00',
'tags' => ['test'],
];
$jsonLd = generateVideoObjectJsonLd($videoData, $video);
assertNotContains('</script>', $jsonLd, 'generateVideoObjectJsonLd ne reproduit pas "</script>" en clair');
// L'équivalent encodé de "</script>" (séquences \u00XX via JSON_HEX_TAG)
// doit apparaître à la place de la balise en clair
$encodedClosingTag = trim(json_encode('</script>', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG), '"');
assertContains(
$encodedClosingTag,
$jsonLd,
'generateVideoObjectJsonLd encode "<" et ">" en séquences unicode (JSON_HEX_TAG)'
);
$decoded = json_decode($jsonLd, true);
assertTrue(is_array($decoded), 'Le JSON-LD VideoObject reste un JSON valide');
assertEquals(
$maliciousTitle,
$decoded['name'],
'Le titre malveillant est préservé à l\'identique après décodage JSON'
);
// Les autres générateurs JSON-LD bénéficient des mêmes drapeaux d'encodage
$websiteLd = generateWebSiteJsonLd();
assertNotContains('</script>', $websiteLd, 'generateWebSiteJsonLd ne contient pas "</script>"');
assertTrue(json_decode($websiteLd, true) !== null, 'generateWebSiteJsonLd produit un JSON valide');
$breadcrumbLd = generateBreadcrumbJsonLd([
['name' => 'Accueil', 'url' => 'https://test.local'],
['name' => 'Page </script>', 'url' => 'https://test.local/page'],
]);
assertNotContains('</script>', $breadcrumbLd, 'generateBreadcrumbJsonLd neutralise "</script>" dans un nom de fil d\'Ariane');
$collectionLd = generateVideoCollectionJsonLd('Collection </script>', 'Description', [$video], 'https://test.local/col');
assertNotContains('</script>', $collectionLd, 'generateVideoCollectionJsonLd neutralise "</script>" dans le nom de collection');
$podcastLd = generatePodcastJsonLd([
[
'title' => 'Épisode </script>',
'link' => 'https://podcast.example/ep1',
'pubDate' => '2024-01-01',
],
]);
assertNotContains('</script>', $podcastLd, 'generatePodcastJsonLd neutralise "</script>" dans un titre d\'épisode');
assertTrue(json_decode($podcastLd, true) !== null, 'generatePodcastJsonLd produit un JSON valide');
// --- isValidAppHostName -------------------------------------------------------
assertTrue(isValidAppHostName('example.com'), 'isValidAppHostName accepte un nom de domaine');
assertTrue(isValidAppHostName('test.local'), 'isValidAppHostName accepte un domaine local');
assertTrue(isValidAppHostName('localhost'), 'isValidAppHostName accepte localhost');
assertTrue(isValidAppHostName('127.0.0.1:8080'), 'isValidAppHostName accepte une IPv4 avec port');
assertTrue(isValidAppHostName('sub.example-site.com'), 'isValidAppHostName accepte sous-domaines et tirets');
assertFalse(isValidAppHostName(''), 'isValidAppHostName refuse une chaîne vide');
assertFalse(isValidAppHostName('evil.com"><script>'), 'isValidAppHostName refuse guillemets et chevrons');
assertFalse(isValidAppHostName("evil.com\r\nX-Injected: 1"), 'isValidAppHostName refuse les CRLF (injection d\'en-tête)');
assertFalse(isValidAppHostName('http://evil.com'), 'isValidAppHostName refuse une URL avec schéma');
assertFalse(isValidAppHostName('evil com'), 'isValidAppHostName refuse les espaces');
assertFalse(isValidAppHostName('evil.com/path'), 'isValidAppHostName refuse un chemin');
// --- getBaseUrl / getAppHostName : hôte de confiance --------------------------
// Un en-tête Host malveillant ne doit pas influencer l'URL de base
$_SERVER['HTTP_HOST'] = 'evil.example"><script>alert(1)</script>';
$_SERVER['HTTPS'] = 'on';
assertEquals(
'https://' . APP_HOST_NAME,
getBaseUrl(),
'getBaseUrl utilise APP_HOST_NAME et ignore un HTTP_HOST malveillant'
);
assertNotContains('evil.example', getBaseUrl(), 'getBaseUrl ne reflète pas l\'en-tête Host');
assertEquals(APP_HOST_NAME, getAppHostName(), 'getAppHostName retourne APP_HOST_NAME validé');
unset($_SERVER['HTTPS']);
assertEquals('http://' . APP_HOST_NAME, getBaseUrl(), 'getBaseUrl retombe en http sans HTTPS');
// --- getCurrentUrl : REQUEST_URI assaini --------------------------------------
$_SERVER['REQUEST_URI'] = '/video.php?id=abc"><meta http-equiv="refresh" content="0;url=https://phishing.example">';
$currentUrl = getCurrentUrl();
assertNotContains('"', $currentUrl, 'getCurrentUrl supprime les guillemets de REQUEST_URI');
assertNotContains('<', $currentUrl, 'getCurrentUrl supprime les chevrons de REQUEST_URI');
assertNotContains(' ', $currentUrl, 'getCurrentUrl supprime les espaces de REQUEST_URI');
assertEquals(
0,
strpos($currentUrl, 'http://' . APP_HOST_NAME . '/video.php?id=abc'),
'getCurrentUrl préfixe par l\'hôte validé et conserve le chemin'
);
$_SERVER['REQUEST_URI'] = 'sans-slash-initial';
assertEquals(
'http://' . APP_HOST_NAME . '/sans-slash-initial',
getCurrentUrl(),
'getCurrentUrl ajoute le slash initial manquant'
);
// Nettoyage des superglobales modifiées pour ne pas impacter d'autres tests
unset($_SERVER['HTTPS']);
$_SERVER['HTTP_HOST'] = APP_HOST_NAME;
$_SERVER['REQUEST_URI'] = '/';
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* Tests unitaires pour le helper e() (includes/security.php)
* et le partial renderVideoCard() (includes/partials/video-card.php)
*
* Non-régression XSS (SEC-1) : les données de l'API PeerTube (titres,
* chaînes, vignettes, avatars) ne doivent jamais ressortir telles quelles
* dans le HTML d'une carte vidéo.
*/
// --- e() ---------------------------------------------------------------------
assertEquals(
'&lt;script&gt;alert(1)&lt;/script&gt;',
e('<script>alert(1)</script>'),
'e() échappe les balises HTML'
);
assertEquals(
'&quot;guillemets&quot; &#039;apostrophes&#039;',
e('"guillemets" \'apostrophes\''),
'e() échappe guillemets et apostrophes (ENT_QUOTES)'
);
assertEquals('&amp;', e('&'), 'e() échappe l\'esperluette');
assertEquals('', e(null), 'e() convertit null en chaîne vide');
assertEquals('42', e(42), 'e() convertit les nombres en chaîne');
// --- renderVideoCard : structure de base -------------------------------------
$video = [
'id' => 'abc-123',
'title' => 'Ma vidéo',
'thumbnail' => 'https://videos.example/lazy/abc.jpg',
'duration' => 125,
'channel' => 'Ma chaîne',
'channelAvatar' => 'https://videos.example/avatars/a.png',
'views' => 42,
'date' => date('Y-m-d H:i:s'),
];
$html = renderVideoCard($video);
assertContains('class="video-card"', $html, 'renderVideoCard génère une carte vidéo');
assertContains('data-video-id="abc-123"', $html, 'renderVideoCard expose l\'identifiant vidéo');
assertContains('src="https://videos.example/lazy/abc.jpg"', $html, 'renderVideoCard affiche la vignette');
assertContains('<h3 class="video-title">Ma vidéo</h3>', $html, 'renderVideoCard affiche le titre');
assertContains('<span class="channel-name">Ma chaîne</span>', $html, 'renderVideoCard affiche la chaîne');
assertContains('class="channel-avatar"', $html, 'renderVideoCard affiche l\'avatar personnalisé');
assertContains('2:05', $html, 'renderVideoCard affiche la durée formatée');
// --- renderVideoCard : avatar par défaut --------------------------------------
$videoDefaultAvatar = $video;
$videoDefaultAvatar['channelAvatar'] = 'img/default-avatar.png';
$htmlDefault = renderVideoCard($videoDefaultAvatar);
assertContains('channel-avatar-placeholder', $htmlDefault, 'renderVideoCard utilise un placeholder pour l\'avatar par défaut');
assertNotContains('class="channel-avatar"', $htmlDefault, 'renderVideoCard n\'affiche pas l\'image d\'avatar par défaut');
$videoEmptyAvatar = $video;
$videoEmptyAvatar['channelAvatar'] = '';
assertContains(
'channel-avatar-placeholder',
renderVideoCard($videoEmptyAvatar),
'renderVideoCard utilise un placeholder si l\'avatar est vide'
);
// --- renderVideoCard : échappement XSS (SEC-1) ---------------------------------
$maliciousTitle = '"><img src=x onerror=alert(1)><script>alert(2)</script>';
$maliciousChannel = '<svg onload=alert(3)>';
$maliciousThumbnail = 'https://videos.example/x.jpg" onerror="alert(4)';
$maliciousAvatar = 'https://videos.example/a.png\' onerror=\'alert(5)';
$htmlXss = renderVideoCard([
'id' => $maliciousTitle,
'title' => $maliciousTitle,
'thumbnail' => $maliciousThumbnail,
'duration' => 60,
'channel' => $maliciousChannel,
'channelAvatar' => $maliciousAvatar,
'views' => 1,
'date' => date('Y-m-d H:i:s'),
]);
assertNotContains($maliciousTitle, $htmlXss, 'renderVideoCard ne ressort pas le titre brut');
assertNotContains($maliciousChannel, $htmlXss, 'renderVideoCard ne ressort pas la chaîne brute');
assertNotContains($maliciousThumbnail, $htmlXss, 'renderVideoCard ne ressort pas la vignette brute');
assertNotContains($maliciousAvatar, $htmlXss, 'renderVideoCard ne ressort pas l\'avatar brut');
assertNotContains('<script>', $htmlXss, 'renderVideoCard ne génère aucune balise script injectée');
assertNotContains('onerror="', $htmlXss, 'renderVideoCard ne laisse passer aucun attribut d\'événement actif');
assertContains(
htmlspecialchars($maliciousTitle, ENT_QUOTES, 'UTF-8'),
$htmlXss,
'renderVideoCard affiche le titre échappé'
);
assertContains(
htmlspecialchars($maliciousChannel, ENT_QUOTES, 'UTF-8'),
$htmlXss,
'renderVideoCard affiche la chaîne échappée'
);
// L'échappement ENT_QUOTES doit aussi protéger les attributs à apostrophes
$htmlAttr = renderVideoCard([
'id' => 'xyz',
'title' => "Titre avec 'apostrophe'",
'thumbnail' => 'https://videos.example/x.jpg',
'duration' => 10,
'channel' => 'Chaîne',
'channelAvatar' => 'img/default-avatar.png',
'views' => 0,
'date' => date('Y-m-d H:i:s'),
]);
assertContains('&#039;', $htmlAttr, 'renderVideoCard échappe les apostrophes dans les attributs');
assertNotContains("'apostrophe'", $htmlAttr, 'renderVideoCard ne laisse pas d\'apostrophe brute');
// --- renderVideoCard : données incomplètes --------------------------------------
$htmlMinimal = renderVideoCard(['id' => 'solo-1']);
assertContains('class="video-card"', $htmlMinimal, 'renderVideoCard tolère une vidéo quasi vide');
assertContains('data-video-id="solo-1"', $htmlMinimal, 'renderVideoCard conserve l\'id minimal');
assertContains('channel-avatar-placeholder', $htmlMinimal, 'renderVideoCard met un placeholder sans avatar');
+39 -37
View File
@@ -126,7 +126,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<?php endif; ?> <?php endif; ?>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>"> <link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="css/video-page.css?v=<?php echo filemtime('css/video-page.css'); ?>"> <link rel="stylesheet" href="css/video-page.css?v=<?php echo filemtime('css/video-page.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons --> <!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -140,9 +140,9 @@ if (empty($videoData) || isset($videoData['error'])) {
<meta property="og:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>"> <meta property="og:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>">
<meta property="og:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>"> <meta property="og:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>">
<?php if (isset($videoData['thumbnailPath'])): ?> <?php if (isset($videoData['thumbnailPath'])): ?>
<meta property="og:image" content="<?php echo PEERTUBE_URL . $videoData['thumbnailPath']; ?>"> <meta property="og:image" content="<?php echo e(PEERTUBE_URL . $videoData['thumbnailPath']); ?>">
<?php endif; ?> <?php endif; ?>
<meta property="og:url" content="<?php echo (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"; ?>"> <meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<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">
@@ -152,7 +152,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<meta name="twitter:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>"> <meta name="twitter:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>"> <meta name="twitter:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>">
<?php if (isset($videoData['thumbnailPath'])): ?> <?php if (isset($videoData['thumbnailPath'])): ?>
<meta name="twitter:image" content="<?php echo PEERTUBE_URL . $videoData['thumbnailPath']; ?>"> <meta name="twitter:image" content="<?php echo e(PEERTUBE_URL . $videoData['thumbnailPath']); ?>">
<?php endif; ?> <?php endif; ?>
<?php if (!isset($videoNotFound) && !empty($video)): ?> <?php if (!isset($videoNotFound) && !empty($video)): ?>
@@ -224,7 +224,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?> <?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye" aria-hidden="true"></i> <?php echo formatViewCount($video['views']); ?> vues</span> <span class="video-views"><i class="fas fa-eye" aria-hidden="true"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?> <?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt" aria-hidden="true"></i> <time datetime="<?php echo $video['date']; ?>"><?php echo formatDate($video['date']); ?></time></span> <span class="video-date"><i class="far fa-calendar-alt" aria-hidden="true"></i> <time datetime="<?php echo e($video['date']); ?>"><?php echo formatDate($video['date']); ?></time></span>
</div> </div>
<div class="video-actions"> <div class="video-actions">
@@ -301,20 +301,20 @@ if (empty($videoData) || isset($videoData['error'])) {
$channelUrl = PEERTUBE_URL . '/c/' . $video['channelHandle']; $channelUrl = PEERTUBE_URL . '/c/' . $video['channelHandle'];
?> ?>
<?php if (strpos($channelAvatar, 'default-avatar') !== false || empty($channelAvatar)): ?> <?php if (strpos($channelAvatar, 'default-avatar') !== false || empty($channelAvatar)): ?>
<a href="<?php echo $channelUrl; ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>"> <a href="<?php echo e($channelUrl); ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
<div class="channel-avatar-placeholder" role="img" aria-label="Avatar par défaut"> <div class="channel-avatar-placeholder" role="img" aria-label="Avatar par défaut">
<i class="fas fa-user-circle" aria-hidden="true"></i> <i class="fas fa-user-circle" aria-hidden="true"></i>
</div> </div>
</a> </a>
<?php else: ?> <?php else: ?>
<a href="<?php echo $channelUrl; ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>"> <a href="<?php echo e($channelUrl); ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
<div class="channel-avatar"> <div class="channel-avatar">
<img src="<?php echo $channelAvatar; ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($video['channel']); ?>"> <img src="<?php echo e($channelAvatar); ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
</div> </div>
</a> </a>
<?php endif; ?> <?php endif; ?>
<div class="channel-details"> <div class="channel-details">
<a href="<?php echo $channelUrl; ?>" target="_blank" rel="noopener noreferrer" class="channel-name-link"> <a href="<?php echo e($channelUrl); ?>" target="_blank" rel="noopener noreferrer" class="channel-name-link">
<h2 class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></h2> <h2 class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></h2>
</a> </a>
</div> </div>
@@ -335,7 +335,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<i class="fas fa-chevron-down" aria-hidden="true"></i> <i class="fas fa-chevron-down" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div id="full-description" class="full-description" style="display: none;" nonce="<?php echo getCspNonce(); ?>"> <div id="full-description" class="full-description is-hidden">
<?php echo markdown_to_html($video['description']); ?> <?php echo markdown_to_html($video['description']); ?>
<button class="show-less-btn" aria-expanded="true" aria-controls="full-description"> <button class="show-less-btn" aria-expanded="true" aria-controls="full-description">
<span>Voir moins</span> <span>Voir moins</span>
@@ -362,7 +362,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="section-title-wrapper"> <div class="section-title-wrapper">
<h2 id="comments-heading" class="section-title">Commentaires</h2> <h2 id="comments-heading" class="section-title">Commentaires</h2>
</div> </div>
<a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="view-on-peertube" aria-label="Voir cette vidéo sur <?php echo PEERTUBE_DISPLAY_NAME; ?>"> <a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="view-on-peertube" aria-label="Voir cette vidéo sur <?php echo PEERTUBE_DISPLAY_NAME; ?>">
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Voir sur <?php echo PEERTUBE_DISPLAY_NAME; ?> <i class="fas fa-external-link-alt" aria-hidden="true"></i> Voir sur <?php echo PEERTUBE_DISPLAY_NAME; ?>
</a> </a>
</header> </header>
@@ -375,7 +375,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<article class="comment"> <article class="comment">
<div class="comment-avatar"> <div class="comment-avatar">
<?php if (isset($comment['account']['avatar']) && !empty($comment['account']['avatar']['path'])): ?> <?php if (isset($comment['account']['avatar']) && !empty($comment['account']['avatar']['path'])): ?>
<img src="<?php echo PEERTUBE_URL . $comment['account']['avatar']['path']; ?>" alt="<?php echo htmlspecialchars($comment['account']['displayName']); ?>"> <img src="<?php echo e(PEERTUBE_URL . $comment['account']['avatar']['path']); ?>" alt="<?php echo htmlspecialchars($comment['account']['displayName']); ?>">
<?php else: ?> <?php else: ?>
<div class="channel-avatar-placeholder mini" role="img" aria-label="Avatar par défaut"> <div class="channel-avatar-placeholder mini" role="img" aria-label="Avatar par défaut">
<i class="fas fa-user-circle" aria-hidden="true"></i> <i class="fas fa-user-circle" aria-hidden="true"></i>
@@ -385,14 +385,14 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="comment-content"> <div class="comment-content">
<header class="comment-header"> <header class="comment-header">
<span class="comment-author"><?php echo htmlspecialchars($comment['account']['displayName']); ?></span> <span class="comment-author"><?php echo htmlspecialchars($comment['account']['displayName']); ?></span>
<time class="comment-date" datetime="<?php echo $comment['createdAt']; ?>"><?php echo formatDate($comment['createdAt']); ?></time> <time class="comment-date" datetime="<?php echo e($comment['createdAt']); ?>"><?php echo formatDate($comment['createdAt']); ?></time>
</header> </header>
<div class="comment-text"><?php echo nl2br(htmlspecialchars($comment['text'])); ?></div> <div class="comment-text"><?php echo nl2br(htmlspecialchars($comment['text'])); ?></div>
<?php if (isset($comment['totalReplies']) && $comment['totalReplies'] > 0): ?> <?php if (isset($comment['totalReplies']) && $comment['totalReplies'] > 0): ?>
<div class="comment-replies-toggle"> <div class="comment-replies-toggle">
<a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="show-replies" aria-label="Voir les réponses sur PeerTube"> <a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="show-replies" aria-label="Voir les réponses sur PeerTube">
<i class="fas fa-reply" aria-hidden="true"></i> Voir les <?php echo $comment['totalReplies']; ?> réponse<?php echo $comment['totalReplies'] > 1 ? 's' : ''; ?> <i class="fas fa-reply" aria-hidden="true"></i> Voir les <?php echo e($comment['totalReplies']); ?> réponse<?php echo $comment['totalReplies'] > 1 ? 's' : ''; ?>
</a> </a>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -404,13 +404,13 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="comments-info"> <div class="comments-info">
<i class="fas fa-info-circle"></i> <i class="fas fa-info-circle"></i>
<p>Les commentaires sont visibles mais l'ajout de commentaires et les threads de réponses sont désactivés sur cette page.</p> <p>Les commentaires sont visibles mais l'ajout de commentaires et les threads de réponses sont désactivés sur cette page.</p>
<p>Pour ajouter des commentaires ou voir les réponses, veuillez vous rendre sur <a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p> <p>Pour ajouter des commentaires ou voir les réponses, veuillez vous rendre sur <a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="no-comments"> <div class="no-comments">
<i class="fas fa-comments"></i> <i class="fas fa-comments"></i>
<p>Aucun commentaire pour cette vidéo.</p> <p>Aucun commentaire pour cette vidéo.</p>
<p>Pour ajouter des commentaires, veuillez vous rendre sur <a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p> <p>Pour ajouter des commentaires, veuillez vous rendre sur <a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -424,13 +424,13 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="suggestion-list"> <div class="suggestion-list">
<?php foreach ($suggestedVideos as $suggestedVideo): ?> <?php foreach ($suggestedVideos as $suggestedVideo): ?>
<article class="suggested-video"> <article class="suggested-video">
<a href="video.php?id=<?php echo $suggestedVideo['id']; ?>" class="suggested-video-link" aria-labelledby="suggestion-title-<?php echo $suggestedVideo['id']; ?>"> <a href="video.php?id=<?php echo e($suggestedVideo['id']); ?>" class="suggested-video-link" aria-labelledby="suggestion-title-<?php echo e($suggestedVideo['id']); ?>">
<div class="suggested-video-thumbnail"> <div class="suggested-video-thumbnail">
<img src="<?php echo $suggestedVideo['thumbnail']; ?>" alt="<?php echo $suggestedVideo['title']; ?>"> <img src="<?php echo e($suggestedVideo['thumbnail']); ?>" alt="<?php echo e($suggestedVideo['title']); ?>">
</div> </div>
<div class="suggested-video-info"> <div class="suggested-video-info">
<span class="suggested-video-duration"><?php echo formatDuration($suggestedVideo['duration']); ?></span> <span class="suggested-video-duration"><?php echo formatDuration($suggestedVideo['duration']); ?></span>
<h3 id="suggestion-title-<?php echo $suggestedVideo['id']; ?>" class="suggested-video-title"><?php echo htmlspecialchars($suggestedVideo['title']); ?></h3> <h3 id="suggestion-title-<?php echo e($suggestedVideo['id']); ?>" class="suggested-video-title"><?php echo htmlspecialchars($suggestedVideo['title']); ?></h3>
<div class="suggested-video-channel"> <div class="suggested-video-channel">
<?php <?php
// Vérifier si un avatar de chaîne est disponible pour la vidéo suggérée // Vérifier si un avatar de chaîne est disponible pour la vidéo suggérée
@@ -442,9 +442,9 @@ if (empty($videoData) || isset($videoData['error'])) {
<i class="fas fa-user-circle"></i> <i class="fas fa-user-circle"></i>
</div> </div>
<?php else: ?> <?php else: ?>
<img src="<?php echo $suggestedAvatar; ?>" alt="<?php echo $suggestedVideo['channel']; ?>" class="channel-avatar mini"> <img src="<?php echo e($suggestedAvatar); ?>" alt="<?php echo e($suggestedVideo['channel']); ?>" class="channel-avatar mini">
<?php endif; ?> <?php endif; ?>
<span class="channel-name"><?php echo $suggestedVideo['channel']; ?></span> <span class="channel-name"><?php echo e($suggestedVideo['channel']); ?></span>
</div> </div>
<div class="suggested-video-metadata"> <div class="suggested-video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?> <?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
@@ -467,6 +467,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<?php include 'includes/footer.php'; ?> <?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?> <?php include 'includes/mobile-menu.php'; ?>
<?php if (!isset($videoNotFound)): ?>
<!-- Modal de téléchargement --> <!-- Modal de téléchargement -->
<div id="download-modal" class="modal"> <div id="download-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -482,13 +483,13 @@ if (empty($videoData) || isset($videoData['error'])) {
if (!empty($downloadOptions)): if (!empty($downloadOptions)):
foreach ($downloadOptions as $option): foreach ($downloadOptions as $option):
?> ?>
<a href="<?php echo $option['url']; ?>" class="download-option" download> <a href="<?php echo e($option['url']); ?>" class="download-option" download>
<div class="download-resolution"> <div class="download-resolution">
<i class="fas fa-film"></i> <i class="fas fa-film"></i>
<span><?php echo $option['resolution']; ?></span> <span><?php echo e($option['resolution']); ?></span>
</div> </div>
<div class="download-info"> <div class="download-info">
<span class="download-size"><?php echo $option['size']; ?></span> <span class="download-size"><?php echo e($option['size']); ?></span>
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
</div> </div>
</a> </a>
@@ -517,7 +518,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="share-link-container"> <div class="share-link-container">
<p>Lien de la vidéo :</p> <p>Lien de la vidéo :</p>
<div class="share-link-box"> <div class="share-link-box">
<input type="text" id="share-link" value="<?php echo htmlspecialchars((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" readonly> <input type="text" id="share-link" value="<?php echo htmlspecialchars(getCurrentUrl()); ?>" readonly>
<button id="copy-link-btn" class="copy-btn" title="Copier le lien"> <button id="copy-link-btn" class="copy-btn" title="Copier le lien">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
@@ -526,32 +527,32 @@ if (empty($videoData) || isset($videoData['error'])) {
<p class="share-platforms-title">Partager sur :</p> <p class="share-platforms-title">Partager sur :</p>
<div class="share-platforms"> <div class="share-platforms">
<a href="mailto:?subject=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&body=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" class="share-platform-btn" title="Partager par e-mail"> <a href="mailto:?subject=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&body=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . getCurrentUrl()); ?>" class="share-platform-btn" title="Partager par e-mail">
<i class="fas fa-envelope"></i> <i class="fas fa-envelope"></i>
<span>E-mail</span> <span>E-mail</span>
</a> </a>
<a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur Facebook"> <a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode(getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur Facebook">
<i class="fab fa-facebook-f"></i> <i class="fab fa-facebook-f"></i>
<span>Facebook</span> <span>Facebook</span>
</a> </a>
<a href="https://twitter.com/intent/tweet?text=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&url=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur X/Twitter"> <a href="https://twitter.com/intent/tweet?text=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&url=<?php echo urlencode(getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur X/Twitter">
<i class="fab fa-x-twitter"></i> <i class="fab fa-x-twitter"></i>
<span>X</span> <span>X</span>
</a> </a>
<a href="https://wa.me/?text=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur WhatsApp"> <a href="https://wa.me/?text=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur WhatsApp">
<i class="fab fa-whatsapp"></i> <i class="fab fa-whatsapp"></i>
<span>WhatsApp</span> <span>WhatsApp</span>
</a> </a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur LinkedIn"> <a href="https://www.linkedin.com/sharing/share-offsite/?url=<?php echo urlencode(getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur LinkedIn">
<i class="fab fa-linkedin-in"></i> <i class="fab fa-linkedin-in"></i>
<span>LinkedIn</span> <span>LinkedIn</span>
</a> </a>
<a href="https://t.me/share/url?url=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>&text=<?php echo urlencode($video['title']); ?>" target="_blank" class="share-platform-btn" title="Partager sur Telegram"> <a href="https://t.me/share/url?url=<?php echo urlencode(getCurrentUrl()); ?>&text=<?php echo urlencode($video['title']); ?>" target="_blank" class="share-platform-btn" title="Partager sur Telegram">
<i class="fab fa-telegram-plane"></i> <i class="fab fa-telegram-plane"></i>
<span>Telegram</span> <span>Telegram</span>
</a> </a>
@@ -560,7 +561,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="share-embed"> <div class="share-embed">
<p>Intégrer la vidéo :</p> <p>Intégrer la vidéo :</p>
<div class="share-link-box"> <div class="share-link-box">
<input type="text" id="embed-code" value='<iframe width="560" height="315" src="<?php echo $video['url']; ?>" frameborder="0" allowfullscreen></iframe>' readonly> <input type="text" id="embed-code" value='<iframe width="560" height="315" src="<?php echo e($video['url']); ?>" frameborder="0" allowfullscreen></iframe>' readonly>
<button id="copy-embed-btn" class="copy-btn" title="Copier le code d'intégration"> <button id="copy-embed-btn" class="copy-btn" title="Copier le code d'intégration">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
@@ -580,15 +581,15 @@ if (empty($videoData) || isset($videoData['error'])) {
if (showMoreBtn) { if (showMoreBtn) {
showMoreBtn.addEventListener('click', function() { showMoreBtn.addEventListener('click', function() {
document.querySelector('.truncated-description').style.display = 'none'; document.querySelector('.truncated-description').classList.add('is-hidden');
document.querySelector('.full-description').style.display = 'block'; document.querySelector('.full-description').classList.remove('is-hidden');
}); });
} }
if (showLessBtn) { if (showLessBtn) {
showLessBtn.addEventListener('click', function() { showLessBtn.addEventListener('click', function() {
document.querySelector('.full-description').style.display = 'none'; document.querySelector('.full-description').classList.add('is-hidden');
document.querySelector('.truncated-description').style.display = 'block'; document.querySelector('.truncated-description').classList.remove('is-hidden');
}); });
} }
@@ -672,6 +673,7 @@ if (empty($videoData) || isset($videoData['error'])) {
} }
}); });
</script> </script>
<?php endif; ?>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script> <script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body> </body>