37 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
cedric 9b460a2550 ci: add PHP and JS unit tests to workflows
Déploiement PROD / check (push) Successful in 3m28s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 22:52:46 +04:00
cedric f895800c9d chore: wire tests into check.sh and document test suites 2026-07-26 20:22:29 +04:00
cedric 606175d718 test(e2e): add Playwright tests for homepage, video page and load-more 2026-07-26 20:22:20 +04:00
cedric 54a4368ea8 test(js): add node:test unit tests for countdown and pleroma adapter 2026-07-26 20:22:12 +04:00
cedric c02180fb4d test(php): add native unit tests for security, cache, format, markdown 2026-07-26 20:22:05 +04:00
cedric 5c726f91b2 fix(server): exclude /ajax/ from .php redirect to preserve POST
Déploiement PROD / check (push) Successful in 3m23s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 19:55:18 +04:00
cedric 98c2818c3f fix: change cache name
Déploiement PROD / check (push) Successful in 3m24s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 19:47:15 +04:00
cedric 6c432c517f fix: call load-more endpoint without .php extension 2026-07-26 19:46:16 +04:00
cedric 090663dc3b fix: change cache name
Déploiement PROD / check (push) Successful in 3m24s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 18:51:32 +04:00
cedric 5899dfb856 fix(csrf): stateless HMAC token to survive page cache
Déploiement PROD / check (push) Successful in 3m26s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 18:44:05 +04:00
cedric 9109068b0b chore(sw): bump cache version to 26072026-1831
Déploiement PROD / check (push) Successful in 3m28s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 18:31:36 +04:00
cedric 0d45b716fb fix: view more button on category page 2026-07-26 18:19:53 +04:00
cedric b12d66195d docs(readme): align CI checks description with workflows
Déploiement PROD / check (push) Successful in 3m26s
Déploiement PROD / deploy (push) Successful in 8s
2026-07-26 12:22:31 +04:00
cedric 050d583b6b ci: keep only PHP/JS lint in CI, move rest to local checks
Déploiement PROD / check (push) Successful in 3m25s
Déploiement PROD / deploy (push) Successful in 9s
2026-07-25 22:33:01 +04:00
cedric bc64981023 docs(deploy): clarify CI SSH key transfer to server
Déploiement PROD / check (push) Failing after 3m44s
Déploiement PROD / deploy (push) Has been skipped
2026-07-25 22:01:10 +04:00
cedric 3620010212 feat: add ko-fi link 2026-07-25 21:52:29 +04:00
cedric aa86bf65e4 feat(donations): clickable OKI links in donation disclaimer 2026-07-25 21:52:29 +04:00
cedric e673b484ce feat(donations): default OKI links and transparency notice 2026-07-25 21:35:33 +04:00
cedric 5457f6fae0 style: feature trending hashtags card with accent bar and icon 2026-07-25 18:45:11 +04:00
cedric 33646f8d19 fix: make search results count visible in dark mode 2026-07-25 18:09:07 +04:00
72 changed files with 6095 additions and 2148 deletions
+4 -15
View File
@@ -37,19 +37,8 @@ jobs:
done
echo "JS lint OK"
- name: Installer Asciidoctor
run: sudo gem install asciidoctor
- name: Tests unitaires PHP
run: php tests/php/run.php
- name: Valider README.adoc
run: asciidoctor -o /tmp/readme.html README.adoc
- name: Valider JSON et XML
run: |
sudo apt-get update && sudo apt-get install -y libxml2-utils
python3 -m json.tool site.webmanifest.sample > /dev/null
xmllint --noout sitemap.xml.sample browserconfig.xml
- name: Shellcheck (scripts shell)
run: |
sudo apt-get install -y shellcheck
shellcheck docs/generate-readme-pdf.sh scripts/check.sh
- name: Tests unitaires JS
run: node tests/js/run.js
+4 -15
View File
@@ -37,22 +37,11 @@ jobs:
done
echo "JS lint OK"
- name: Installer Asciidoctor
run: sudo gem install asciidoctor
- name: Tests unitaires PHP
run: php tests/php/run.php
- name: Valider README.adoc
run: asciidoctor -o /tmp/readme.html README.adoc
- name: Valider JSON et XML
run: |
sudo apt-get update && sudo apt-get install -y libxml2-utils
python3 -m json.tool site.webmanifest.sample > /dev/null
xmllint --noout sitemap.xml.sample browserconfig.xml
- name: Shellcheck (scripts shell)
run: |
sudo apt-get install -y shellcheck
shellcheck docs/generate-readme-pdf.sh scripts/check.sh
- name: Tests unitaires JS
run: node tests/js/run.js
deploy:
needs: check
+10
View File
@@ -8,6 +8,8 @@ robots.txt
site.webmanifest
mentions-legales.php
dons.php
404.php
500.php
# Fichiers de l'IDE/éditeur
.vscode/
@@ -37,4 +39,12 @@ uploads/*
# vendor/
# node_modules/
# Rapport d'audit interne OKI (ne pas committer)
audit/
# Artefacts de tests locaux
.pytest_cache/
__pycache__/
*.pyc
img/movement_presentation.png
+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>
+74 -8
View File
@@ -19,7 +19,7 @@ L'architecture retenue (identique à celle de pawol.nu) :
└─────────────┘ └──────────────────┘ └─────────────┘
----
. *Vérification* (`check-pr.yml` + job `check` de `deploy-prod.yml`) : lint PHP/JS, validation AsciiDoc, JSON, XML, shellcheck. Les mêmes vérifications sont exécutables en local avec `scripts/check.sh`.
. *Vérification* (`check-pr.yml` + job `check` de `deploy-prod.yml`) : lint PHP/JS et tests unitaires PHP/JS. Les validations AsciiDoc, JSON, XML et shellcheck ne sont pas exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.
. *Déploiement* (`deploy-prod.yml`) : le runner se connecte en SSH au serveur, qui tient *un clone du dépôt*, fait `git pull --ff-only`, puis *bumpe la version des caches* du Service Worker (`sw.js`) pour déclencher le modal de mise à jour PWA chez les visiteurs.
Pourquoi un `git pull` sur le serveur plutôt qu'un rsync : tous les fichiers propres à l'instance (`config.local.php`, `.htaccess`, `sitemap.xml`, `robots.txt`, `site.webmanifest`, `mentions-legales.php`, `dons.php`, `uploads/`, `cache/`) sont ignorés par Git — un pull ne les écrase jamais. Le déploiement est ainsi sans risque pour la configuration de production.
@@ -59,6 +59,11 @@ usermod -aG www-data deploy
# Répertoire de l'application
mkdir -p /var/www/annu-kute-ced
chown -R deploy:www-data /var/www/annu-kute-ced
# Répertoire SSH de l'utilisateur deploy (préparé pour les clés publiques)
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.ssh
----
== 3️⃣ Clone du dépôt
@@ -200,15 +205,61 @@ Ouvrir le site dans un navigateur : vidéos, podcasts, timeline et le bouton d'i
== 8️⃣ Clé SSH du CI
C'est la clé utilisée par le runner Gitea Actions pour déployer. Elle est *différente* de la deploy key de lecture (étape 3) : celle-ci doit pouvoir écrire dans le clone.
C'est la clé utilisée par le runner Gitea Actions pour se connecter au serveur et déployer. Elle est *différente* de la deploy key de lecture (étape 3) : celle-ci doit pouvoir écrire dans le clone.
Générez la paire de clés *sur votre poste local* (pas sur le serveur), puis transférez la clé publique sur le serveur pour l'autoriser.
[source,bash]
----
# Générer la paire SUR VOTRE POSTE (pas sur le serveur)
# Sur votre poste local
ssh-keygen -t ed25519 -f annu-kute-ced-deploy -C "ci-gitea-annu-kute-ced"
# Autoriser la clé publique pour l'utilisateur deploy
sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys < annu-kute-ced-deploy.pub
# Affichez la clé publique (elle doit être copiée sur le serveur)
cat annu-kute-ced-deploy.pub
----
=== Transfert de la clé publique sur le serveur
Option A — copie directe avec `scp` (depuis votre poste) :
[source,bash]
----
# Sur votre poste local (adapter user@serveur si vous ne vous connectez pas en root)
scp annu-kute-ced-deploy.pub root@<IP_DU_SERVEUR>:/tmp/annu-kute-ced-deploy.pub
# Puis sur le serveur, ajoutez-la au fichier authorized_keys de deploy
sudo -u deploy mkdir -p /home/deploy/.ssh
sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys < /tmp/annu-kute-ced-deploy.pub
----
Option B — connexion SSH sur le serveur, puis création manuelle du fichier :
[source,bash]
----
# Sur le serveur, connecté en root ou avec sudo
sudo -u deploy mkdir -p /home/deploy/.ssh
sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys
# Coller le contenu de annu-kute-ced-deploy.pub, puis Ctrl+D
----
Quelle que soit la méthode, vérifiez les permissions :
[source,bash]
----
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
----
=== Test de la connexion CI
Avant d'enregistrer la clé privée dans Gitea, testez depuis votre poste que la connexion fonctionne avec la clé privée générée :
[source,bash]
----
# Sur votre poste local
ssh -i annu-kute-ced-deploy deploy@<IP_DU_SERVEUR> "whoami && hostname"
# Attendu : "deploy" et le nom du serveur, sans mot de passe demandé
----
La clé privée (`annu-kute-ced-deploy`, sans passphrase) ira dans les secrets Gitea à l'étape suivante. *Conservez-la en lieu sûr et ne la commitez jamais.*
@@ -257,17 +308,32 @@ grep STATIC_CACHE_NAME sw.js # le suffixe de version a été bumpé à l'heure d
| Événement | Résultat
| Pull request vers `main`
| Workflow *Vérification PR* : lints et validations bloquants en cas d'erreur
| Workflow *Vérification PR* : lint PHP/JS et tests unitaires PHP/JS bloquants en cas d'erreur
| Push sur `main`
| Workflow *Déploiement PROD* : mêmes vérifications, puis SSH → `git pull --ff-only` → bump de la version des caches `sw.js` → modal de mise à jour chez les visiteurs
| Workflow *Déploiement PROD* : lint PHP/JS et tests unitaires PHP/JS, puis SSH → `git pull --ff-only` → bump de la version des caches `sw.js` → modal de mise à jour chez les visiteurs
| En local, avant de pousser
| `scripts/check.sh` exécute les mêmes vérifications que le CI
| `scripts/check.sh` exécute l'ensemble des vérifications qualité (PHP, JS, AsciiDoc, JSON, XML, shellcheck)
|===
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
[cols="1,3",options="header"]
+148 -42
View File
@@ -182,15 +182,11 @@ Toutes ces sources sont modifiables dans `includes/config.local.php` (voir <<fr-
│ ├── sidebar.php # Navigation latérale
│ ├── mobile-menu.php # Menu mobile coulissant
│ ├── 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/
│ └── markdown.php # Rendu Markdown (sous-ensemble PeerTube)
├── js/
│ ├── main.js # UI globale (thème, carrousel, « Voir plus »…)
│ ├── audio-player.js # Lecteur audio Castopod / Funkwhale
│ ├── categories.js # « Voir plus » de la page catégorie
│ ├── countdown.js # Compte à rebours
│ ├── search.js # Cartes cliquables de la recherche
│ ├── mastodon-config.php # Configuration JS de la timeline (servie en JS)
@@ -198,7 +194,9 @@ Toutes ces sources sont modifiables dans `includes/config.local.php` (voir <<fr-
│ ├── pleroma-adapter.js # Adaptateur Pleroma → API Mastodon
│ └── pwa-update.js # Enregistrement SW + modal de mise à jour
├── scripts/
── check.sh # Vérifications du CI 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)
├── index.php # Page d'accueil (agrégateur)
├── video.php # Page de lecture d'une vidéo
@@ -416,7 +414,7 @@ Valeurs par défaut issues de `includes/config.default.php`.
| 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)
[cols="2,2,3",options="header"]
@@ -492,12 +490,12 @@ NOTE: La timeline n'a pas de flag d'activation : elle est toujours affichée sur
| Constante | Défaut | Description
| `DONATIONS_ENABLED`
| `false`
| `true`
| Activer le système (icône cœur, lien « Soutenir », page `/dons.php`)
| `LIBERAPAY_URL` / `KOFI_URL`
| `''` / `''`
| URLs des plateformes externes
| `'https://liberapay.com/OKI/donate'` / `''`
| URLs des plateformes externes. Par défaut le lien Liberapay pointe vers OKI, mainteneur de l'application.
| `STRIPE_ENABLED`
| `false`
@@ -518,9 +516,15 @@ NOTE: La timeline n'a pas de flag d'activation : elle est toujours affichée sur
| `DONATION_CURRENCY`
| `'EUR'`
| Devise
| `DONATIONS_OKI_DISCLAIMER`
| Texte explicite
| Message affiché sur `/dons.php` lorsque les liens pointent vers les comptes d'une organisation tierce (OKI par défaut). Laissez vide pour masquer.
|===
NOTE: Au moins une plateforme (LiberaPay, Ko-fi ou Stripe) doit être configurée pour que `dons.php` s'affiche, sinon la page renvoie une erreur 500.
NOTE: Au moins une plateforme (LiberaPay, Ko-fi ou Stripe) doit être configurée pour que `dons.php` s'affiche, sinon la page renvoie une erreur 500. Par défaut, seul le compte Liberapay de OKI est renseigné.
TIP: Si vous déployez une instance indépendante, remplacez `LIBERAPAY_URL` par votre propre lien et adaptez ou videz `DONATIONS_OKI_DISCLAIMER`.
.Compte à rebours (maintenance / lancement)
[cols="2,2,3",options="header"]
@@ -627,10 +631,6 @@ NOTE: Au moins une plateforme (LiberaPay, Ko-fi ou Stripe) doit être configuré
|===
| Constante | Défaut | Description
| `TAG_INDEPENDENCE`
| `'indépendance'`
| Tag de la fonction `getIndependenceVideos()` (pas de section d'accueil associée actuellement)
| `SHORTS_MAX_DURATION`
| `180`
| Durée max d'un short (s) ; ratio portrait (`aspectRatio ≤ 1`) aussi requis
@@ -669,9 +669,9 @@ NOTE: Au moins une plateforme (LiberaPay, Ko-fi ou Stripe) doit être configuré
| `6`
| Vidéos chargées par clic sur « Voir plus »
| `FEATURED_VIDEOS_COUNT` / `INDEPENDENCE_VIDEOS_COUNT`
| `FEATURED_VIDEOS_COUNT`
| `6`
| Réservés (sections non affichées actuellement)
| Réservé (section non affichée actuellement)
|===
.Cache
@@ -690,6 +690,18 @@ NOTE: Au moins une plateforme (LiberaPay, Ko-fi ou Stripe) doit être configuré
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é
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description
| `CSRF_SECRET`
| `'change-me-in-config-local-php'`
| Clé secrète utilisée pour signer les tokens CSRF stateless. À remplacer impérativement dans `config.local.php` par une valeur aléatoire (`php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"`)
|===
.Bloc « À propos » (pas de valeur par défaut — bloc masqué tant que non défini)
[cols="2,3",options="header"]
|===
@@ -779,7 +791,7 @@ Mise en page desktop : hero/Castopod/Funkwhale + Mastodon + WordPress s'organise
- *En-têtes* : `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `X-XSS-Protection`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` restrictive, HSTS (HTTPS uniquement), CORP/COOP `same-origin`
+
NOTE: L'en-tête COEP a été *volontairement retiré* (commit `62d4d99`) : `require-corp` bloquait les embeds cross-origin de PeerTube, Mastodon, Castopod et Funkwhale.
- *CSRF* : jeton de session (`random_bytes(32)`) injecté en `<meta>`, vérifié par `hash_equals` sur l'endpoint AJAX ; vérification de l'en-tête `Origin`
- *CSRF* : jeton stateless HMAC-SHA256 (timestamp + signature, validité 1 h) injecté en `<meta>`, vérifié par `hash_equals` sur l'endpoint AJAX ; vérification de l'en-tête `Origin` ou du `Referer`
- *Anti-SSRF* : validation des URLs d'instances (schéma http/https, blocage IP privées et `localhost`), liste blanche des endpoints de l'API PeerTube, cURL sans redirections
- *Anti-XSS* : échappement systématique des sorties ; validation des entrées (UUID vidéo, requête de recherche ≤ 200 car., numéros de page, ID de catégorie 120)
@@ -911,22 +923,63 @@ NOTE: `asciidoctor-pdf` (utilisé par l'extension AsciiDoc de VSCodium) n'est pa
==== ✅ Vérifications locales et CI/CD
Avant de pousser, exécutez les mêmes vérifications que le CI :
Avant de pousser, exécutez les vérifications locales complètes :
[source,bash]
----
scripts/check.sh
----
Ce script lance : lint PHP (`php -l` sur tous les fichiers, samples inclus), lint JS (`node --check`), rendu AsciiDoc, validation JSON (`site.webmanifest.sample`) et XML (`sitemap.xml.sample`, `browserconfig.xml`), shellcheck. Un outil manquant est signalé et le check correspondant ignoré ; le CI, lui, exécute tout.
Ce script lance : lint PHP (`php -l` sur tous les fichiers, samples inclus), lint JS (`node --check`), rendu AsciiDoc, validation JSON (`site.webmanifest.sample`) et XML (`sitemap.xml.sample`, `browserconfig.xml`), shellcheck. Un outil manquant est signalé et le check correspondant ignoré.
Pipelines Gitea Actions (`.gitea/workflows/`) :
- *Vérification PR* (`check-pr.yml`) : sur toute pull request vers `main`
- *Déploiement PROD* (`deploy-prod.yml`) : sur push sur `main` — mêmes vérifications, puis déploiement en SSH sur le serveur (`git pull --ff-only` + bump de version du Service Worker, qui déclenche le modal de mise à jour chez les visiteurs)
- *Vérification PR* (`check-pr.yml`) : lint PHP et JS, tests unitaires PHP et JS bloquants sur toute pull request vers `main`
- *Déploiement PROD* (`deploy-prod.yml`) : lint PHP et JS, tests unitaires PHP et JS sur push sur `main`, puis déploiement en SSH sur le serveur (`git pull --ff-only` + bump de version du Service Worker, qui déclenche le modal de mise à jour chez les visiteurs)
Les validations AsciiDoc, JSON, XML et shellcheck ne sont pas exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.
La mise en place complète du serveur de production (clone, fichiers d'instance, clés SSH, secrets Gitea) est documentée dans link:DEPLOY.adoc[DEPLOY.adoc].
==== 🧪 Tests
Le projet dispose de trois niveaux de tests, tous *sans framework lourd* (aucune dépendance commitée dans le repo) :
- *Tests unitaires PHP* (`tests/php/`) : script PHP natif (`php tests/php/run.php`). Couvre les validateurs (`security.php`), le cache (`simple-cache.php`), les formateurs (`config.php`) et le rendu Markdown.
- *Tests unitaires JS* (`tests/js/`) : `node:test` natif (`node tests/js/run.js`). Couvre `CountdownTimer` et l'adaptateur Pleroma → Mastodon.
- *Tests E2E* (`tests/e2e/`) : Playwright (Python), installé en local uniquement. Couvre la homepage, la page vidéo et l'endpoint AJAX « Voir plus ».
[source,bash]
----
# Tests unitaires PHP et JS (aussi lancés par scripts/check.sh)
php tests/php/run.php
node tests/js/run.js
# Tests E2E (nécessite Playwright installé hors repo)
python3 -m venv /tmp/test-venv
/tmp/test-venv/bin/pip install playwright pytest
/tmp/test-venv/bin/playwright install chromium
/tmp/test-venv/bin/python -m pytest tests/e2e/
----
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
Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*
@@ -1112,15 +1165,11 @@ All of these sources can be changed in `includes/config.local.php` (see <<en-con
│ ├── sidebar.php # Side navigation
│ ├── mobile-menu.php # Slide-in mobile menu
│ ├── 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/
│ └── markdown.php # Markdown renderer (PeerTube-flavored subset)
├── js/
│ ├── main.js # Global UI (theme, carousel, "Load more"…)
│ ├── audio-player.js # Castopod / Funkwhale audio player
│ ├── categories.js # Category page "Load more"
│ ├── countdown.js # Countdown timer
│ ├── search.js # Clickable search result cards
│ ├── mastodon-config.php # Timeline JS config (served as JavaScript)
@@ -1128,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
│ └── pwa-update.js # SW registration + update modal
├── scripts/
── check.sh # CI checks run locally (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)
├── index.php # Homepage (aggregator)
├── video.php # Video playback page
@@ -1346,7 +1397,7 @@ Default values from `includes/config.default.php`.
| 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)
[cols="2,2,3",options="header"]
@@ -1422,12 +1473,12 @@ NOTE: The timeline has no enable flag: it is always displayed on the homepage.
| Constant | Default | Description
| `DONATIONS_ENABLED`
| `false`
| `true`
| Enable the system (heart icon, "Support" link, `/dons.php` page)
| `LIBERAPAY_URL` / `KOFI_URL`
| `''` / `''`
| External platform URLs
| `'https://liberapay.com/OKI/donate'` / `''`
| External platform URLs. By default the LiberaPay link points to OKI, the application maintainer.
| `STRIPE_ENABLED`
| `false`
@@ -1448,9 +1499,15 @@ NOTE: The timeline has no enable flag: it is always displayed on the homepage.
| `DONATION_CURRENCY`
| `'EUR'`
| Currency
| `DONATIONS_OKI_DISCLAIMER`
| Explanatory text
| Message shown on `/dons.php` when donation links point to a third-party organization (OKI by default). Leave empty to hide.
|===
NOTE: At least one platform (LiberaPay, Ko-fi or Stripe) must be configured for `dons.php` to render, otherwise the page returns a 500 error.
NOTE: At least one platform (LiberaPay, Ko-fi or Stripe) must be configured for `dons.php` to render, otherwise the page returns a 500 error. By default only OKI's LiberaPay account is set.
TIP: If you deploy an independent instance, replace `LIBERAPAY_URL` with your own link and update or clear `DONATIONS_OKI_DISCLAIMER`.
.Countdown (maintenance / launch)
[cols="2,2,3",options="header"]
@@ -1557,10 +1614,6 @@ NOTE: At least one platform (LiberaPay, Ko-fi or Stripe) must be configured for
|===
| Constant | Default | Description
| `TAG_INDEPENDENCE`
| `'indépendance'`
| Tag used by `getIndependenceVideos()` (no associated homepage section currently)
| `SHORTS_MAX_DURATION`
| `180`
| Max short duration (s); portrait ratio (`aspectRatio ≤ 1`) also required
@@ -1599,9 +1652,9 @@ NOTE: At least one platform (LiberaPay, Ko-fi or Stripe) must be configured for
| `6`
| Videos loaded per "Load more" click
| `FEATURED_VIDEOS_COUNT` / `INDEPENDENCE_VIDEOS_COUNT`
| `FEATURED_VIDEOS_COUNT`
| `6`
| Reserved (sections not currently displayed)
| Reserved (section not currently displayed)
|===
.Cache
@@ -1620,6 +1673,18 @@ NOTE: At least one platform (LiberaPay, Ko-fi or Stripe) must be configured for
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
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description
| `CSRF_SECRET`
| `'change-me-in-config-local-php'`
| Secret key used to sign stateless CSRF tokens. Must be replaced in `config.local.php` with a random value (`php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"`)
|===
."About" block (no default values — block hidden until defined)
[cols="2,3",options="header"]
|===
@@ -1709,7 +1774,7 @@ Desktop layout: hero/Castopod/Funkwhale + Mastodon + WordPress are arranged in c
- *Headers*: `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `X-XSS-Protection`, `Referrer-Policy: strict-origin-when-cross-origin`, restrictive `Permissions-Policy`, HSTS (HTTPS only), CORP/COOP `same-origin`
+
NOTE: The COEP header was *deliberately removed* (commit `62d4d99`): `require-corp` broke cross-origin embeds from PeerTube, Mastodon, Castopod and Funkwhale.
- *CSRF*: session token (`random_bytes(32)`) injected as a `<meta>` tag, verified with `hash_equals` on the AJAX endpoint; `Origin` header verification
- *CSRF*: stateless HMAC-SHA256 token (timestamp + signature, 1 h validity) injected as a `<meta>` tag, verified with `hash_equals` on the AJAX endpoint; `Origin` or `Referer` header verification
- *Anti-SSRF*: instance URL validation (http/https scheme, blocking private IPs and `localhost`), allowlist of PeerTube API endpoints, cURL without redirects
- *Anti-XSS*: systematic output escaping; input validation (video UUID, search query ≤ 200 chars, page numbers, category ID 120)
@@ -1841,22 +1906,63 @@ NOTE: `asciidoctor-pdf` (used by VSCodium's AsciiDoc extension) cannot be used h
==== ✅ Local checks and CI/CD
Before pushing, run the same checks as the CI:
Before pushing, run the full local checks:
[source,bash]
----
scripts/check.sh
----
This script runs: PHP lint (`php -l` on every file, samples included), JS lint (`node --check`), AsciiDoc rendering, JSON (`site.webmanifest.sample`) and XML (`sitemap.xml.sample`, `browserconfig.xml`) validation, shellcheck. A missing tool is reported and its check skipped; the CI runs everything.
This script runs: PHP lint (`php -l` on every file, samples included), JS lint (`node --check`), AsciiDoc rendering, JSON (`site.webmanifest.sample`) and XML (`sitemap.xml.sample`, `browserconfig.xml`) validation, shellcheck. A missing tool is reported and its check skipped.
Gitea Actions pipelines (`.gitea/workflows/`):
- *PR check* (`check-pr.yml`): on every pull request to `main`
- *PROD deployment* (`deploy-prod.yml`): on push to `main` — same checks, then SSH deployment to the server (`git pull --ff-only` + Service Worker version bump, which triggers the update modal for visitors)
- *PR check* (`check-pr.yml`): blocking PHP and JS lint plus PHP and JS unit tests on every pull request to `main`
- *PROD deployment* (`deploy-prod.yml`): PHP and JS lint plus PHP and JS unit tests on push to `main`, then SSH deployment to the server (`git pull --ff-only` + Service Worker version bump, which triggers the update modal for visitors)
AsciDoc, JSON, XML and shellcheck validation are not run in CI: they must be run locally with `scripts/check.sh`.
The full production server setup (clone, instance files, SSH keys, Gitea secrets) is documented in link:DEPLOY.adoc[DEPLOY.adoc].
==== 🧪 Tests
The project has three levels of tests, all *without heavy frameworks* (no dependencies committed to the repo):
- *PHP unit tests* (`tests/php/`): native PHP script (`php tests/php/run.php`). Covers validators (`security.php`), cache (`simple-cache.php`), formatters (`config.php`) and Markdown rendering.
- *JS unit tests* (`tests/js/`): native `node:test` (`node tests/js/run.js`). Covers `CountdownTimer` and the Pleroma → Mastodon adapter.
- *E2E tests* (`tests/e2e/`): Playwright (Python), installed locally only. Covers the homepage, video page and the "Load more" AJAX endpoint.
[source,bash]
----
# PHP and JS unit tests (also run by scripts/check.sh)
php tests/php/run.php
node tests/js/run.js
# E2E tests (requires Playwright installed outside the repo)
python3 -m venv /tmp/test-venv
/tmp/test-venv/bin/pip install playwright pytest
/tmp/test-venv/bin/playwright install chromium
/tmp/test-venv/bin/python -m pytest tests/e2e/
----
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
Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*
+12 -32
View File
@@ -2,6 +2,13 @@
// Inclure la configuration
require_once '../includes/config.php';
// Limitation de débit : 30 requêtes / minute par IP (l'endpoint proxifie PeerTube)
if (!checkRateLimit($_SERVER['REMOTE_ADDR'] ?? 'unknown', 30, 60)) {
http_response_code(429); // Trop de requêtes
echo json_encode(['error' => 'Trop de requêtes, réessayez plus tard']);
exit;
}
// Vérifier que la requête est faite par AJAX
if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') {
http_response_code(403); // Accès non autorisé
@@ -25,11 +32,13 @@ if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
// Récupérer les paramètres
$type = isset($_GET['type']) ? $_GET['type'] : '';
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
// Borner la page à [1, 1000] : au-delà il n'y a plus de contenu réel, et des
// pages arbitrairement grandes contourneraient le cache de l'API PeerTube.
$page = min(1000, validatePageNumber($_GET['page'] ?? 1));
$categoryId = isset($_GET['category']) ? intval($_GET['category']) : 0;
// Vérifier que le type est valide
if (!in_array($type, ['recent', 'trending', 'independence', 'category'])) {
if (!in_array($type, ['recent', 'trending', 'category'])) {
http_response_code(400); // Requête incorrecte
echo json_encode(['error' => 'Type de vidéos non valide']);
exit;
@@ -69,17 +78,6 @@ switch ($type) {
$videos = formatVideosData($data['data'] ?? []);
break;
case 'independence':
// Récupérer les vidéos sur l'indépendance
$data = callPeerTubeApi('videos', [
'tagsOneOf' => TAG_INDEPENDENCE,
'count' => LOAD_MORE_COUNT,
'start' => $offset,
'isLocal' => true
]);
$videos = formatVideosData($data['data'] ?? []);
break;
case 'category':
// Récupérer les vidéos de la catégorie
$data = callPeerTubeApi('videos', [
@@ -97,25 +95,7 @@ switch ($type) {
$html = '';
foreach ($videos as $video) {
$html .= '<div class="video-card" data-video-id="' . htmlspecialchars($video['id']) . '">';
$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>';
$html .= renderVideoCard($video);
}
// Retourner la réponse
+7 -35
View File
@@ -19,8 +19,8 @@ setSecurityHeaders();
$categoryId = isset($_GET['id']) ? $_GET['id'] : null;
$categoryId = $categoryId ? validateCategoryId($categoryId) : null;
// Récupérer les catégories disponibles
$allCategories = PEERTUBE_CATEGORIES;
// Récupérer les catégories disponibles (chargement paresseux, ARC-2)
$allCategories = getPeertubeCategories();
// Récupérer les vidéos de la catégorie si un ID est fourni
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.">
<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="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 -->
<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 -->
<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:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<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: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: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 -->
<?php
@@ -127,34 +127,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<?php else: ?>
<div class="video-grid category-videos">
<?php foreach ($videos 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 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 echo renderVideoCard($video); ?>
<?php endforeach; ?>
</div>
@@ -169,7 +142,6 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
<script src="js/categories.js"></script>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>
</html>
+9
View File
@@ -22,6 +22,13 @@ RewriteRule ^(includes|cache|docs|conf)/ - [F,L]
Require all denied
</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
Options -Indexes
@@ -40,7 +47,9 @@ RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^([^\.]+)$ $1.php [NC,L]
# Rediriger les URLs avec .php vers les URLs sans extension
# Sauf /ajax/ : un 301 transformerait le POST en GET et ferait perdre le token CSRF.
RewriteCond %{THE_REQUEST} /([^.]+)\.php [NC]
RewriteCond %{REQUEST_URI} !^/ajax/ [NC]
RewriteRule ^ /%1 [NC,L,R=301]
# Pour accéder à page.php via /page
+15
View File
@@ -40,6 +40,16 @@ server {
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
location ~ /\. {
deny all;
@@ -69,6 +79,11 @@ server {
# @rewrite ou directive index), on sert le PHP. Cela évite la boucle
# infinie /index → /index.php → /index → …
location ~ \.php$ {
# Ne pas rediriger les endpoints AJAX : un 301 transformerait le POST
# en GET et ferait perdre le token CSRF.
if ($request_uri ~ "^/ajax/") {
break;
}
if ($request_uri ~ "^(/[^?]+)\.php") {
return 301 $1$is_args$args;
}
+19 -17
View File
@@ -3,6 +3,8 @@
require_once 'includes/config.php';
// Inclure les fonctions de sécurité
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é
setSecurityHeaders();
?>
@@ -14,11 +16,11 @@ setSecurityHeaders();
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
<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 -->
<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 -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -38,12 +40,10 @@ setSecurityHeaders();
<!-- Open Graph Meta Tags -->
<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
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp());
echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE));
?>. Restez connectés !">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR">
@@ -52,15 +52,19 @@ setSecurityHeaders();
<meta name="twitter:card" content="summary_large_image">
<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
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp());
echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE));
?>. 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(); ?>">
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>
</head>
<body>
@@ -97,10 +101,8 @@ setSecurityHeaders();
<!-- Message -->
<div class="countdown-message">
<p>La plateforme ouvrira ses portes le <strong><?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y à %H:%M', $targetDate->getTimestamp());
<p>La plateforme ouvrira ses portes le <strong><?php
echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE), true);
?></strong> (heure de La Réunion).</p>
<!-- Heures des autres territoires -->
+39
View File
@@ -82,6 +82,45 @@
text-align: center;
}
/* Avertissement de transparence sur la page de dons */
.donation-disclaimer {
max-width: 1200px;
margin: 0 auto 30px;
padding: 20px 24px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-left: 4px solid var(--primary-red);
border-radius: 12px;
color: var(--text-color);
box-shadow: var(--card-shadow);
}
.donation-disclaimer p {
margin: 0;
line-height: 1.6;
}
.donation-disclaimer i {
color: var(--primary-red);
margin-right: 10px;
}
.donation-disclaimer a {
color: var(--primary-red);
text-decoration: underline;
font-weight: 600;
}
.donation-disclaimer a:hover {
text-decoration: none;
}
@media (max-width: 1024px) {
.donation-disclaimer {
margin-bottom: 20px;
}
}
/* Interface de don */
.donation-interface {
background: var(--card-bg);
+1 -1
View File
@@ -44,7 +44,7 @@
.search-results-count {
margin-bottom: 20px;
color: #666;
color: var(--text-secondary);
font-size: 14px;
}
+84 -3
View File
@@ -18,6 +18,11 @@
border: 0 !important;
}
/* Masquage d'éléments (remplace les attributs style="display: none;" bloqués par la CSP) */
.is-hidden {
display: none !important;
}
:root {
--primary-red: #FF0000;
--primary-green: #008000;
@@ -3167,7 +3172,8 @@ i.icon-mastodon,
text-align: center;
margin-bottom: 20px;
padding: 10px;
background-color: #f9f9f9;
background-color: var(--tag-bg);
color: var(--text-secondary);
border-radius: 4px;
}
@@ -3527,11 +3533,86 @@ i.icon-mastodon,
}
}
/* Section « Tendances » (aside de l'accueil) : carte « mise en avant » */
.tags-section-container {
position: relative;
align-items: center;
justify-content: center;
text-align: center;
gap: 6px;
border: 1px solid var(--tag-border);
overflow: hidden;
}
/* Barre d'accent identitaire en haut de la carte (rouge → jaune → vert) */
.tags-section-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--primary-red) 0%, #FFD700 50%, var(--primary-green) 100%);
}
/* Icône du titre */
.trending-title-icon {
color: var(--primary-red);
}
.tags-section-container .tags-section {
margin: 6px 0 2px;
padding: 4px 0 6px;
overflow-x: visible;
white-space: normal;
}
.tags-section-container .tag {
font-size: 1rem;
padding: 10px 22px;
border: 1px solid var(--tag-border);
}
/* Rangée d'emoji décorative au-dessus des hashtags « Tendances » (accueil) */
.trending-hashtag-emojis {
font-size: 1.15rem;
font-size: 1.35rem;
letter-spacing: 0.3em;
line-height: 1;
text-align: center;
margin: 0 0 12px;
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;
}
+11 -106
View File
@@ -9,6 +9,8 @@ if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
exit;
}
// Inclure le convertisseur Markdown
require_once 'includes/lib/markdown.php';
// Inclure les fonctions de données structurées
require_once 'includes/structured-data.php';
// 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.">
<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="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 -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -126,9 +128,9 @@ $liveStream = getLiveStream();
<i class="fas fa-circle"></i> EN DIRECT
</div>
<div class="live-player">
<iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1"
frameborder="0"
<iframe
src="<?php echo e(PEERTUBE_URL . '/videos/embed/' . $liveStream['id'] . '?autoplay=1'); ?>"
frameborder="0"
allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen"
title="<?php echo htmlspecialchars($liveStream['title']); ?>">
@@ -142,9 +144,9 @@ $liveStream = getLiveStream();
<i class="fas fa-user-circle"></i>
</div>
<?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; ?>
<span class="channel-name"><?php echo $liveStream['channel']; ?></span>
<span class="channel-name"><?php echo e($liveStream['channel']); ?></span>
</div>
<?php if (!empty($liveStream['description'])): ?>
<div class="live-description">
@@ -158,106 +160,9 @@ $liveStream = getLiveStream();
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live
// 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) . '\');';
}
?>
<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
// Afficher l'annonce du prochain live (partial partagé avec hero-section.php)
$nextLiveVariant = 'page';
include 'includes/partials/next-live.php';
} else {
?>
<div class="no-live-message">
+44 -17
View File
@@ -49,6 +49,24 @@ $currencySymbol = $currency === 'EUR' ? '€' : '$';
$stripeOneTimeLinks = defined('STRIPE_ONE_TIME_LINKS') ? STRIPE_ONE_TIME_LINKS : [];
$stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [];
/**
* Convertit les URLs d'un texte en liens cliquables tout en échappant le reste.
* Utilisé pour le message de transparence sur les dons.
*/
function linkUrlsInText(string $text): string {
$parts = preg_split('/(https?:\/\/[^\s<]+)/i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
$result = '';
foreach ($parts as $i => $part) {
if ($i % 2 === 1) {
$url = htmlspecialchars($part, ENT_QUOTES, 'UTF-8');
$result .= '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
} else {
$result .= htmlspecialchars($part, ENT_QUOTES, 'UTF-8');
}
}
return $result;
}
?>
<!DOCTYPE html>
<html lang="fr">
@@ -65,7 +83,7 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
<!-- Styles -->
<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="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 -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -105,7 +123,7 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
?>
<!-- Script pour éviter le flash en mode sombre -->
<script>
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -133,6 +151,13 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
<!-- Section principale de don -->
<section class="donation-main">
<!-- Avertissement de transparence (optionnel) -->
<?php if (defined('DONATIONS_OKI_DISCLAIMER') && !empty(DONATIONS_OKI_DISCLAIMER)): ?>
<div class="donation-disclaimer" role="note">
<p><i class="fas fa-info-circle"></i> <?php echo linkUrlsInText(DONATIONS_OKI_DISCLAIMER); ?></p>
</div>
<?php endif; ?>
<div class="donation-content">
<div class="donation-message">
<!-- PERSONNALISEZ: Votre message de don -->
@@ -198,10 +223,10 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
<!-- Onglets don ponctuel / mensuel -->
<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
</button>
<button class="tab-btn" onclick="switchTab('monthly')">
<button class="tab-btn" data-tab="monthly">
<i class="fas fa-sync-alt"></i> Don mensuel
</button>
</div>
@@ -283,20 +308,22 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
<?php include 'includes/mobile-menu.php'; ?>
<!-- Script pour les onglets -->
<script>
function switchTab(tab) {
// Cacher tous les contenus et désactiver tous les boutons
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
<script nonce="<?php echo getCspNonce(); ?>">
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
// Cacher tous les contenus et désactiver tous les boutons
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.querySelectorAll('.tab-btn').forEach(otherBtn => {
otherBtn.classList.remove('active');
});
// Afficher le contenu sélectionné et activer le bouton
document.getElementById(tab + '-tab').classList.add('active');
event.target.classList.add('active');
}
// Afficher le contenu sélectionné et activer le bouton
document.getElementById(this.dataset.tab + '-tab').classList.add('active');
this.classList.add('active');
});
});
</script>
<script src="js/main.js"></script>
+35 -16
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_DISPLAY_NAME')) define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');
if (!defined('API_KEY')) define('API_KEY', '');
if (!defined('TAG_INDEPENDENCE')) define('TAG_INDEPENDENCE', 'indépendance');
if (!defined('SHORTS_MAX_DURATION')) define('SHORTS_MAX_DURATION', 180); // 3 minutes max pour les shorts
// Pagination et affichage
@@ -27,7 +26,6 @@ if (!defined('RECENT_VIDEOS_COUNT')) define('RECENT_VIDEOS_COUNT', 6);
if (!defined('SHORTS_COUNT')) define('SHORTS_COUNT', 6);
if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100);
if (!defined('TRENDING_VIDEOS_COUNT')) define('TRENDING_VIDEOS_COUNT', 6);
if (!defined('INDEPENDENCE_VIDEOS_COUNT')) define('INDEPENDENCE_VIDEOS_COUNT', 6);
if (!defined('CATEGORY_VIDEOS_COUNT')) define('CATEGORY_VIDEOS_COUNT', 6);
if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6);
@@ -102,6 +100,16 @@ if (!defined('ENABLE_USER_ACCOUNTS')) define('ENABLE_USER_ACCOUNTS', false);
if (!defined('CACHE_ENABLED')) define('CACHE_ENABLED', true);
if (!defined('CACHE_DURATION')) define('CACHE_DURATION', 3600); // En secondes (1 heure)
// Clé secrète utilisée pour signer les tokens CSRF stateless.
// À remplacer impérativement dans config.local.php par une valeur aléatoire
// 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')) {
define('CSRF_SECRET', CSRF_SECRET_PLACEHOLDER);
}
// =========================================
// Configuration de la section Hero (bannière d'accueil)
// =========================================
@@ -222,32 +230,43 @@ if (!defined('WORDPRESS_ENABLED')) define('WORDPRESS_ENABLED', false);
// =========================================
// Activation du système de dons par défaut
if (!defined('DONATIONS_ENABLED')) define('DONATIONS_ENABLED', false);
// Par défaut les dons sont activés et pointent vers les comptes de l'association
// ORGANISATION KA INTERNATIONALE (OKI), mainteneur de l'application.
// Modifiez ces valeurs dans config.local.php pour utiliser vos propres comptes.
if (!defined('DONATIONS_ENABLED')) define('DONATIONS_ENABLED', true);
// URLs des plateformes de don
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', ''); // Ex: https://liberapay.com/votre-compte/donate
if (!defined('KOFI_URL')) define('KOFI_URL', ''); // Ex: https://ko-fi.com/votre-compte
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', 'https://liberapay.com/OKI/donate');
if (!defined('KOFI_URL')) define('KOFI_URL', 'https://ko-fi.com/J3J5TOZPI'); // Ex: https://ko-fi.com/votre-compte
if (!defined('STRIPE_ENABLED')) define('STRIPE_ENABLED', false);
// Message de transparence affiché sur la page de dons.
// Renseignez ce texte lorsque les liens pointent vers les comptes d'une organisation
// tierce (par exemple OKI) afin d'expliquer aux visiteurs pourquoi.
// Les URLs présentes dans ce texte sont automatiquement transformées en liens cliquables.
if (!defined('DONATIONS_OKI_DISCLAIMER')) {
define('DONATIONS_OKI_DISCLAIMER', 'Les liens ci-dessous pointent vers les comptes de donation de l\'association ORGANISATION KA INTERNATIONALE (OKI) — o-k-i.net. Pour soutenir spécifiquement OKI : o-k-i.net/dons. Pour adapter cette page à votre propre instance, voir la documentation (lien "Code Source" en bas de la page)');
}
// Configuration Stripe
if (!defined('STRIPE_ONE_TIME_LINKS')) {
define('STRIPE_ONE_TIME_LINKS', [
1 => '', // Lien Stripe pour don de 1€
5 => '', // Lien Stripe pour don de 5€
10 => '', // Lien Stripe pour don de 10€
20 => '', // Lien Stripe pour don de 20€
50 => '', // Lien Stripe pour don de 50€
'custom' => '' // Lien Stripe pour montant personnalisé
1 => 'https://don.o-k-i.net/b/aEUdTw5SD3SH7m0bII', // Lien pour don de 1€
5 => 'https://don.o-k-i.net/b/4gw6r480L1Kz5dScMO', // Lien pour don de 5€
10 => 'https://don.o-k-i.net/b/5kA02G1Cn4WLbCgeUV', // Lien pour don de 10€
20 => 'https://don.o-k-i.net/b/fZebLo94PfBpayc5kn', // Lien pour don de 20€
50 => 'https://don.o-k-i.net/b/6oE5n05SDdth49O004', // Lien pour don de 50€
'custom' => 'https://don.o-k-i.net/b/6oE2aO94P88X9u8eV4' // Lien pour montant personnalisé
]);
}
if (!defined('STRIPE_MONTHLY_LINKS')) {
define('STRIPE_MONTHLY_LINKS', [
1 => '', // Lien Stripe pour don mensuel de 1€
5 => '', // Lien Stripe pour don mensuel de 5€
10 => '', // Lien Stripe pour don mensuel de 10€
20 => '', // Lien Stripe pour don mensuel de 20€
50 => '', // Lien Stripe pour don mensuel de 50€
1 => 'https://don.o-k-i.net/b/7sI9Dgch14WL7m0fZ3', // Lien pour don mensuel de 1€
5 => 'https://don.o-k-i.net/b/8wM4iW4Ozbl95dS6ow', // Lien pour don mensuel de 5€
10 => 'https://don.o-k-i.net/b/8wM2aO80L74TcGk3ci', // Lien pour don mensuel de 10€
20 => 'https://don.o-k-i.net/b/00g7v894P88XgWAfZ7', // Lien pour don mensuel de 20€
50 => 'https://don.o-k-i.net/b/4gw8zc6WHgFtcGkbIP', // Lien pour don mensuel de 50€
]);
}
+17 -7
View File
@@ -71,9 +71,6 @@ define('APP_HOST_NAME', 'example.com');
// Filtres et tags
// =========================================
// Tag pour les vidéos sur l'indépendance
// define('TAG_INDEPENDANCE', 'indépendance');
// Tag pour les shorts
// define('TAG_SHORT', 'short');
@@ -113,9 +110,6 @@ define('SHORTS_MAX_DURATION', 180); // 3 minutes
// Nombre de vidéos tendances
// define('TRENDING_VIDEOS_COUNT', 6);
// Nombre de vidéos indépendance
// define('INDEPENDENCE_VIDEOS_COUNT', 6);
// Nombre de vidéos par catégorie
// define('CATEGORY_VIDEOS_COUNT', 6);
@@ -331,6 +325,11 @@ define('WORDPRESS_ENABLED', false);
// define('CACHE_ENABLED', true);
// define('CACHE_DURATION', 3600); // 1 heure recommandé
// Clé secrète utilisée pour signer les tokens CSRF stateless.
// Générez une valeur aléatoire unique pour votre instance :
// php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
// define('CSRF_SECRET', 'votre-cle-secrete-aleatoire');
// =========================================
// Intégration Funkwhale (Musique)
// =========================================
@@ -349,10 +348,14 @@ define('WORDPRESS_ENABLED', false);
// =========================================
// Activer/désactiver le système de dons
// Par défaut les dons sont activés et pointent vers le compte Liberapay de OKI.
// Décommentez et passez à false pour désactiver complètement la page de dons.
// define('DONATIONS_ENABLED', true);
// URLs des plateformes de don
// define('LIBERAPAY_URL', 'https://liberapay.com/votre-compte/donate');
// Les exemples ci-dessous reproduisent la configuration par défaut (OKI).
// Remplacez-les par vos propres comptes si vous déployez une instance indépendante.
// define('LIBERAPAY_URL', 'https://liberapay.com/OKI/donate');
// define('KOFI_URL', 'https://ko-fi.com/votre-compte');
// Activer/désactiver les dons via Stripe
@@ -383,6 +386,13 @@ define('WORDPRESS_ENABLED', false);
// Devise pour les dons
// define('DONATION_CURRENCY', 'EUR');
// Message de transparence affiché sur la page de dons.
// À renseigner si les liens pointent vers les comptes d'une organisation tierce
// (par exemple OKI) afin d'expliquer aux visiteurs pourquoi.
// Les URLs présentes dans ce texte sont automatiquement transformées en liens cliquables.
// Laissez vide ou commentez pour masquer ce message.
// define('DONATIONS_OKI_DISCLAIMER', 'Les liens ci-dessous pointent vers les comptes de donation de l\'association ORGANISATION KA INTERNATIONALE (OKI) — https://o-k-i.net. Pour soutenir spécifiquement OKI : https://o-k-i.net/dons. Pour adapter cette page à votre propre instance, voir la documentation : https://labola.o-k-i.net/cedric/annu-kute-ced');
// =========================================
// Texte de présentation du podcast
// =========================================
+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 -->
<?php require_once __DIR__ . '/nav-context.php'; ?>
<div class="footer">
<div class="footer-header">
<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">
<i class="fas fa-sun" aria-hidden="true"></i>
</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>
</button>
<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 class="hero-video-container">
<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"
allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen"
@@ -64,7 +64,7 @@ if ($heroType === 'none') {
<i class="fas fa-user-circle" aria-hidden="true"></i>
</div>
<?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; ?>
<span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span>
</div>
@@ -75,103 +75,9 @@ if ($heroType === 'none') {
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live
// 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) . '\');';
}
?>
<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
// Afficher l'annonce du prochain live (partial partagé avec direct.php)
$nextLiveVariant = 'hero';
include __DIR__ . '/partials/next-live.php';
} else {
?>
<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];
}
+55 -25
View File
@@ -13,26 +13,30 @@
function markdown_to_html($markdown) {
// Échapper tout le contenu pour éviter les injections XSS
$markdown = htmlspecialchars($markdown, ENT_QUOTES, 'UTF-8');
// Tableau pour stocker les liens convertis
$links = [];
$link_count = 0;
// Note : le texte étant déjà échappé ci-dessus, les URLs extraites le sont
// aussi (« & » est devenu « &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)
$markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) {
$text = $matches[1];
$url = $matches[2];
// Assurer que l'URL est correctement formée
if (!preg_match('/^https?:\/\//i', $url)) {
// Ajouter http:// si l'URL ne commence pas par http:// ou https://
$url = 'http://' . $url;
}
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
$links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
@@ -41,29 +45,29 @@ function markdown_to_html($markdown) {
$protocolUrlPattern = '/(https?:\/\/[^\s<]+[^\s<\.)])/i';
$markdown = preg_replace_callback($protocolUrlPattern, function($matches) use (&$links, &$link_count) {
$url = $matches[1];
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
$links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
// 2. Domaines sans protocole (comme "o-k-i.net", "gong.gp", "NUVEL.NU")
// Détecte les domaines avec TLD communs qui ne font pas partie d'autre chose
$domainPattern = '/\b([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z]{2,63})\b/';
$markdown = preg_replace_callback($domainPattern, function($matches) use (&$links, &$link_count) {
$domain = $matches[0];
// Éviter de convertir des éléments qui ressemblent à des versions/numéros ou qui sont déjà dans des liens
if (preg_match('/^v?\d+\.\d+/', $domain) || strpos($domain, '___LINK_') !== false) {
return $domain;
}
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="http://' . htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
$links[$placeholder] = '<a href="http://' . $domain . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
@@ -71,19 +75,45 @@ function markdown_to_html($markdown) {
$markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown);
$markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown);
// Conversion des listes à puces
$markdown = preg_replace('/^- (.*?)$/m', '<li>$1</li>', $markdown);
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ul>$0</ul>', $markdown);
// Conversion des listes numérotées
$markdown = preg_replace('/^\d+\. (.*?)$/m', '<li>$1</li>', $markdown);
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ol>$0</ol>', $markdown);
// Conversion des listes (à puces et numérotées) en une seule passe ligne à
// ligne : les items consécutifs de même type forment une seule liste ; une
// ligne hors liste ou un changement de type ferme la liste courante.
$lines = explode("\n", $markdown);
$markdown = '';
$listType = null; // 'ul', 'ol' ou null (hors liste)
foreach ($lines as $line) {
$itemType = null;
$itemText = null;
if (preg_match('/^- (.*)$/', $line, $matches)) {
$itemType = 'ul';
$itemText = $matches[1];
} elseif (preg_match('/^\d+\. (.*)$/', $line, $matches)) {
$itemType = 'ol';
$itemText = $matches[1];
}
if ($itemType !== $listType) {
if ($listType !== null) {
$markdown .= '</' . $listType . ">\n";
}
if ($itemType !== null) {
$markdown .= '<' . $itemType . ">\n";
}
$listType = $itemType;
}
$markdown .= ($itemType !== null ? '<li>' . $itemText . '</li>' : $line) . "\n";
}
if ($listType !== null) {
$markdown .= '</' . $listType . ">\n";
}
$markdown = rtrim($markdown, "\n");
// Gestion des retours à la ligne
$markdown = nl2br($markdown);
// Nettoyage des balises br dans les listes
$markdown = preg_replace('/<\/li><br \/>/', '</li>', $markdown);
// Nettoyage des balises br autour des listes
$markdown = preg_replace('/(<\/li>|<ul>|<ol>)<br \/>/', '$1', $markdown);
// Restaurer les liens
foreach ($links as $placeholder => $link) {
+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) -->
<?php require_once __DIR__ . '/nav-context.php'; ?>
<div class="mobile-menu">
<button class="mobile-menu-close">
<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; ?>
+289 -171
View File
@@ -3,6 +3,20 @@
* 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
*
@@ -79,102 +93,87 @@ function validateCategoryId($categoryId) {
}
/**
* Valide et assainit un User-Agent
*
* @param string $userAgent User-Agent à valider
* @return bool True si valide
* 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.
*/
function validateUserAgent($userAgent) {
if (empty($userAgent)) {
return false;
}
// Bloquer les User-Agents suspects
$blockedPatterns = [
'/curl/i',
'/wget/i',
'/python/i',
'/bot/i',
'/scanner/i',
'/sqlmap/i'
];
foreach ($blockedPatterns as $pattern) {
if (preg_match($pattern, $userAgent)) {
return false;
}
}
return true;
if (!defined('CSRF_SECRET_PLACEHOLDER')) {
define('CSRF_SECRET_PLACEHOLDER', 'change-me-in-config-local-php');
}
/**
* Valide les en-têtes HTTP pour détecter les tentatives d'attaque
*
* @return bool True si les en-têtes sont sûrs
* Retourne le secret CSRF effectif utilisé pour signer les tokens.
*
* Si CSRF_SECRET est absent, vide ou vaut encore la valeur par défaut, un
* avertissement critique est enregistré et un secret éphémère propre au
* processus est généré (bin2hex(random_bytes(32))) : les tokens restent
* signés, mais sont invalidés à chaque redémarrage du processus PHP.
*
* @return string Secret CSRF effectif
*/
function validateHttpHeaders() {
// Vérifier le User-Agent
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (!validateUserAgent($userAgent)) {
error_log('SECURITY: Suspicious User-Agent detected: ' . $userAgent);
return false;
function getCsrfSecret() {
static $secret = null;
if ($secret !== null) {
return $secret;
}
// Vérifier les en-têtes suspects
$suspiciousHeaders = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP'
];
foreach ($suspiciousHeaders as $header) {
if (isset($_SERVER[$header])) {
$value = $_SERVER[$header];
// Bloquer les IPs privées dans les en-têtes de forwarding
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
error_log('SECURITY: Suspicious IP in header ' . $header . ': ' . $value);
}
}
if (defined('CSRF_SECRET') && CSRF_SECRET !== '' && CSRF_SECRET !== CSRF_SECRET_PLACEHOLDER) {
$secret = CSRF_SECRET;
return $secret;
}
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;
}
/**
* Génère un token CSRF sécurisé
*
* @return string Token CSRF
* Génère un token CSRF stateless (HMAC + timestamp).
*
* Le token ne dépend pas de la session : il reste valide même si la page
* HTML est servie depuis un cache (Service Worker, CDN). Il expire après
* une durée limitée.
*
* @return string Token CSRF au format "timestamp:hash"
*/
function generateCSRFToken() {
// Démarrer la session seulement si les en-têtes n'ont pas été envoyés
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
$timestamp = time();
$hash = hash_hmac('sha256', (string) $timestamp, getCsrfSecret());
return $timestamp . ':' . $hash;
}
/**
* Valide un token CSRF
*
* Valide un token CSRF stateless
*
* @param string $token Token à valider
* @return bool True si le token est valide
* @return bool True si le token est valide et non expiré
*/
function validateCSRFToken($token) {
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
if (empty($token) || !is_string($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
$parts = explode(':', $token, 2);
if (count($parts) !== 2) {
return false;
}
[$timestamp, $hash] = $parts;
// Vérifier que le timestamp est numérique et pas trop ancien (1 heure)
if (!ctype_digit($timestamp)) {
return false;
}
$age = abs(time() - (int) $timestamp);
if ($age > 3600) {
return false;
}
$expectedHash = hash_hmac('sha256', $timestamp, getCsrfSecret());
return hash_equals($expectedHash, $hash);
}
/**
@@ -190,6 +189,104 @@ function getCspNonce() {
return $GLOBALS['csp_nonce'];
}
/**
* Extrait l'origine « scheme://host » d'une URL configurée (pour la CSP).
*
* @param string $url URL à analyser
* @return string Origine normalisée, ou chaîne vide si l'URL est vide/invalide
*/
function cspOriginFromUrl($url) {
if (empty($url)) {
return '';
}
$parsed = parse_url($url);
if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) {
return '';
}
return $parsed['scheme'] . '://' . $parsed['host'];
}
/**
* Construit la Content Security Policy de la requête courante.
*
* img-src et media-src sont restreints aux domaines réellement utilisés :
* PeerTube (vignettes), Mastodon (avatars/médias) et son S3 éventuel,
* Castopod (pochettes/flux audio), Funkwhale (pochettes/flux audio) et
* WordPress (images mises en avant). En développement local, HTTP(S) général
* reste autorisé pour faciliter les tests avec du contenu fédéré.
*
* @param string $nonce Nonce CSP de la requête
* @return string Politique CSP complète
*/
function buildContentSecurityPolicy($nonce) {
$mastodonDomain = defined('MASTODON_INSTANCE_URL') ? cspOriginFromUrl(MASTODON_INSTANCE_URL) : '';
$peertubeDomain = defined('PEERTUBE_URL') ? cspOriginFromUrl(PEERTUBE_URL) : '';
$castopodDomain = (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL'))
? cspOriginFromUrl(CASTOPOD_URL) : '';
$funkwhaleDomain = (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED && defined('FUNKWHALE_URL'))
? cspOriginFromUrl(FUNKWHALE_URL) : '';
$wordpressDomain = defined('WORDPRESS_URL') ? cspOriginFromUrl(WORDPRESS_URL) : '';
$s3Domain = defined('MASTODON_S3_MEDIA_URL') ? cspOriginFromUrl(MASTODON_S3_MEDIA_URL) : '';
// Détecter si on est en développement local
$isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']);
$csp = "default-src 'self'; ";
$csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; ";
$csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; ";
// Images : uniquement les services réellement affichés (https: général en dev uniquement)
$imgSrc = "'self' data:";
foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $wordpressDomain, $s3Domain] as $domain) {
if ($domain !== '') {
$imgSrc .= ' ' . $domain;
}
}
if ($isLocalDev) {
$imgSrc .= ' https: http:';
}
$csp .= "img-src " . $imgSrc . "; ";
$csp .= "font-src 'self' https://cdnjs.cloudflare.com; ";
// Frames : autoriser PeerTube uniquement
$frameSrc = "'self'" . ($peertubeDomain !== '' ? ' ' . $peertubeDomain : '');
if ($isLocalDev) {
$frameSrc .= ' https: http:';
}
$csp .= "frame-src " . $frameSrc . "; ";
// Connexions : autoriser Mastodon, PeerTube et Plausible
$connectSrc = "'self' https://plausible.io";
foreach ([$mastodonDomain, $peertubeDomain] as $domain) {
if ($domain !== '') {
$connectSrc .= ' ' . $domain;
}
}
if ($isLocalDev) {
$connectSrc .= ' ws: wss:'; // WebSockets pour le dev
}
$csp .= "connect-src " . $connectSrc . "; ";
// Médias : flux audio Castopod/Funkwhale, médias Mastodon (instance ou S3)
$mediaSrc = "'self'";
foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $s3Domain] as $domain) {
if ($domain !== '') {
$mediaSrc .= ' ' . $domain;
}
}
if ($isLocalDev) {
$mediaSrc .= ' https: http:';
}
$csp .= "media-src " . $mediaSrc . "; ";
$csp .= "object-src 'none'; ";
$csp .= "base-uri 'self'; ";
$csp .= "frame-ancestors 'self';";
return $csp;
}
/**
* Applique des en-têtes de sécurité HTTP
*/
@@ -200,8 +297,8 @@ function setSecurityHeaders() {
// Protection contre le MIME sniffing
header('X-Content-Type-Options: nosniff');
// Protection XSS basique
header('X-XSS-Protection: 1; mode=block');
// X-XSS-Protection volontairement absent : en-tête obsolète, supplanté par
// la CSP et pouvant introduire des vulnérabilités dans les anciens navigateurs.
// Politique de référent
header('Referrer-Policy: strict-origin-when-cross-origin');
@@ -215,91 +312,8 @@ function setSecurityHeaders() {
// Permissions Policy (feature policy)
header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()');
// Content Security Policy avec support Mastodon et PeerTube
$nonce = getCspNonce();
$mastodonDomain = '';
$peertubeDomain = '';
// Extraire le domaine Mastodon si configuré
if (defined('MASTODON_INSTANCE_URL')) {
$mastodonParsed = parse_url(MASTODON_INSTANCE_URL);
if ($mastodonParsed && isset($mastodonParsed['host'])) {
$mastodonDomain = $mastodonParsed['scheme'] . '://' . $mastodonParsed['host'];
}
}
// Extraire le domaine PeerTube si configuré
if (defined('PEERTUBE_URL')) {
$peertubeParsed = parse_url(PEERTUBE_URL);
if ($peertubeParsed && isset($peertubeParsed['host'])) {
$peertubeDomain = $peertubeParsed['scheme'] . '://' . $peertubeParsed['host'];
}
}
// Détecter si on est en développement local
$isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']);
$csp = "default-src 'self'; ";
$csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; ";
$csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; ";
// Images : autoriser les domaines connus plus HTTPS général pour le contenu fédéré
$imgSrc = "'self' data: " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : '');
if ($isLocalDev) {
$imgSrc .= " https: http:";
} else {
$imgSrc .= " https:";
}
$csp .= "img-src " . $imgSrc . "; ";
$csp .= "font-src 'self' https://cdnjs.cloudflare.com; ";
// Frames : autoriser PeerTube uniquement
$frameSrc = "'self' " . ($peertubeDomain ? $peertubeDomain : '');
if ($isLocalDev) {
$frameSrc .= " https: http:";
}
$csp .= "frame-src " . $frameSrc . "; ";
// Connexions : autoriser Mastodon, PeerTube et Plausible
$connectSrc = "'self' https://plausible.io " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : '');
if ($isLocalDev) {
$connectSrc .= " ws: wss:"; // WebSockets pour le dev
}
$csp .= "connect-src " . $connectSrc . "; ";
// Médias : autoriser 'self', Mastodon, PeerTube et S3 Mastodon
$mediaSrc = "'self'";
if ($mastodonDomain) {
$mediaSrc .= " " . $mastodonDomain;
}
if ($peertubeDomain) {
$mediaSrc .= " " . $peertubeDomain;
}
// Ajouter l'URL S3 Mastodon si configurée (pour les médias externalisés)
if (defined('MASTODON_S3_MEDIA_URL') && !empty(MASTODON_S3_MEDIA_URL)) {
$s3Parsed = parse_url(MASTODON_S3_MEDIA_URL);
if ($s3Parsed && isset($s3Parsed['host'])) {
$s3Domain = $s3Parsed['scheme'] . '://' . $s3Parsed['host'];
$mediaSrc .= " " . $s3Domain;
}
}
if ($isLocalDev) {
$mediaSrc .= " https: http:";
} else {
$mediaSrc .= " https:";
}
$csp .= "media-src " . $mediaSrc . "; ";
$csp .= "object-src 'none'; ";
$csp .= "base-uri 'self'; ";
$csp .= "frame-ancestors 'self';";
header('Content-Security-Policy: ' . $csp);
// Content Security Policy (domaines réellement utilisés uniquement)
header('Content-Security-Policy: ' . buildContentSecurityPolicy(getCspNonce()));
// HTTPS strict transport security (seulement si HTTPS)
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
@@ -309,19 +323,123 @@ function setSecurityHeaders() {
/**
* Valide l'origine de la requête pour les requêtes AJAX
*
*
* Accepte le header Origin, ou à défaut un Referer same-origin
* (certains navigateurs n'envoient pas Origin sur les requêtes same-origin).
*
* @return bool True si l'origine est valide
*/
function validateAjaxOrigin() {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$host = $_SERVER['HTTP_HOST'] ?? '';
if (empty($origin) || empty($host)) {
if (empty($host)) {
return false;
}
$expectedOrigin = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
return $origin === $expectedOrigin;
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http');
$expectedOrigin = $scheme . '://' . $host;
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (!empty($origin)) {
return $origin === $expectedOrigin;
}
// Fallback : vérifier le referer si Origin est absent
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (!empty($referer)) {
return strpos($referer, $expectedOrigin) === 0;
}
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 -->
<?php require_once __DIR__ . '/nav-context.php'; ?>
<nav class="sidebar" role="navigation" aria-label="Navigation principale">
<a href="/" class="logo" aria-label="Retour à l'accueil">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>">
</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">
<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>
+74 -28
View File
@@ -6,11 +6,11 @@
class SimpleAPICache {
private $cacheDir;
private $enabled;
public function __construct() {
$this->cacheDir = __DIR__ . '/../cache/api';
public function __construct($cacheDir = null) {
$this->cacheDir = $cacheDir ?? (__DIR__ . '/../cache/api');
$this->enabled = true;
// Créer le répertoire de cache s'il n'existe pas
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
@@ -66,9 +66,28 @@ class SimpleAPICache {
'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;
}
/**
* Nettoie le cache expiré
*/
@@ -91,44 +110,71 @@ class SimpleAPICache {
// Instance globale
$GLOBALS['simple_api_cache'] = new SimpleAPICache();
/**
* 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 getPeerTubeCacheTtl($endpoint) {
static $exactMap = [
'videos/categories' => 3600, // 1 heure
'videos' => 600, // 10 minutes
'search/videos' => 600, // 10 minutes
'wp-posts' => 900, // 15 minutes pour WordPress
'accounts' => 300 // 5 minutes pour live streams
];
static $prefixMap = [
'accounts/' => 300, // lives : accounts/{nom}/videos
'video-channels/' => 600, // vidéos d'une chaîne
'videos/' => 600 // videos/{id}, commentaires
];
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 = $GLOBALS['simple_api_cache'];
// TTL selon le type de contenu
$ttlMap = [
'videos/categories' => 3600, // 1 heure
'videos' => 600, // 10 minutes (augmenté)
'search/videos' => 600, // 10 minutes
'wp-posts' => 900, // 15 minutes pour WordPress
'accounts' => 300 // 5 minutes pour live streams
];
// TTL dynamique selon l'endpoint
$ttl = 300; // Défaut 5 minutes
foreach ($ttlMap as $pattern => $time) {
if (strpos($endpoint, $pattern) !== false) {
$ttl = $time;
break;
}
// 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
$cached = $cache->get($endpoint, $params);
if ($cached !== null) {
return $cached;
}
// Appeler l'API originale
$data = callPeerTubeApiOriginal($endpoint, $params);
// Mettre en cache si on a des données
if (!empty($data)) {
$cache->set($endpoint, $params, $data, $ttl);
}
return $data;
}
+121 -13
View File
@@ -5,6 +5,18 @@
* 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
*
@@ -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";
}
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)
];
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
];
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 int $length Longueur maximale
* @return string Texte tronqué
*/
function truncateText($text, $length = 200) {
if (strlen($text) <= $length) {
if (mb_strlen($text) <= $length) {
return $text;
}
$truncated = substr($text, 0, $length);
$lastSpace = strrpos($truncated, ' ');
$truncated = mb_substr($text, 0, $length);
$lastSpace = mb_strrpos($truncated, ' ');
if ($lastSpace !== false) {
$truncated = substr($truncated, 0, $lastSpace);
$truncated = mb_substr($truncated, 0, $lastSpace);
}
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
*/
function getBaseUrl() {
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host;
return $scheme . '://' . getAppHostName();
}
/**
* 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;
}
/**
+14 -101
View File
@@ -30,7 +30,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>">
<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="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'); ?>">
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?>
<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 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 Plausible ------ -->
@@ -421,9 +421,9 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
foreach ($shorts as $video):
?>
<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">
<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']); ?>">
<?php echo formatDuration($video['duration']); ?>
</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>';
} else {
foreach ($recentVideos as $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
echo renderVideoCard($video);
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>';
} else {
foreach ($trendingVideos as $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
echo renderVideoCard($video);
endforeach;
}
?>
@@ -586,45 +526,18 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
foreach ($displayCategories as $category):
if (!empty($category['videos'])):
?>
<!-- Section Catégorie: <?php echo $category['name']; ?> -->
<section class="video-section" data-category-id="<?php echo $category['id']; ?>" aria-labelledby="category-heading-<?php echo $category['id']; ?>">
<!-- Section Catégorie: <?php echo e($category['name']); ?> -->
<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">
<div class="section-logo">
<img src="img/logo.png" alt="" aria-hidden="true">
</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>
<div class="video-grid">
<?php foreach ($category['videos'] as $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 echo renderVideoCard($video); ?>
<?php endforeach; ?>
</div>
@@ -667,7 +580,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
<!-- Section Tendances Hashtags -->
<aside class="tags-section-container" aria-labelledby="hashtags-heading">
<h2 id="hashtags-heading" class="section-title centered">Tendances</h2>
<h2 id="hashtags-heading" class="section-title centered"><i class="fas fa-fire trending-title-icon" aria-hidden="true"></i> Tendances</h2>
<p class="trending-hashtag-emojis" aria-hidden="true">🎙️🔥📈🔝⬆️↗️🚩🇲🇶</p>
<div class="tags-section">
@@ -707,7 +620,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
// Afficher le bouton d'installation s'il existe
if (installButton) {
installButton.style.display = 'block';
installButton.classList.remove('is-hidden');
installButton.addEventListener('click', function() {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) {
@@ -715,7 +628,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
console.log('PWA installée');
}
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() {
console.log('PWA installée avec succès');
if (installButton) {
installButton.style.display = 'none';
installButton.classList.add('is-hidden');
}
});
</script>
-97
View File
@@ -1,97 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
// Gestion des clics sur les vidéos
const videoCards = document.querySelectorAll(".video-card");
for (const videoCard of videoCards) {
videoCard.addEventListener("click", function () {
const videoId = this.dataset.videoId;
if (videoId) {
window.location.href = `video.php?id=${videoId}`;
}
});
}
// Gestion du bouton "Voir plus"
const viewMoreBtn = document.querySelector(".view-more");
if (viewMoreBtn) {
viewMoreBtn.addEventListener("click", function () {
const page = Number.parseInt(this.dataset.page);
const categoryId =
document.querySelector(".video-section").dataset.categoryId;
// Changer le texte du bouton pendant le chargement
this.textContent = "Chargement...";
this.disabled = true;
// Préparer les données avec token CSRF
const formData = new FormData();
formData.append('csrf_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'));
// Faire la requête AJAX
fetch(
`ajax/load-more-videos.php?type=category&page=${page}&category=${categoryId}`,
{
method: 'POST',
headers: {
"X-Requested-With": "XMLHttpRequest",
},
body: formData
}
)
.then((response) => response.json())
.then((data) => {
if (data.success) {
// Ajouter les nouvelles vidéos à la grille
const videoGrid = document.querySelector(".video-grid");
const tempDiv = document.createElement("div");
tempDiv.innerHTML = data.html;
// Ajouter chaque vidéo à la grille
while (tempDiv.firstChild) {
videoGrid.appendChild(tempDiv.firstChild);
}
// Mettre à jour le numéro de page
this.dataset.page = data.page + 1;
// Réinitialiser le texte du bouton
this.textContent = "Voir plus";
this.disabled = false;
// Si plus de vidéos à charger, masquer le bouton
if (!data.hasMore) {
this.style.display = "none";
}
// Initialiser les clics sur les nouvelles vidéos
const cards = document.querySelectorAll(".video-card:not([data-click-initialized])")
for (const card of cards) {
card.setAttribute("data-click-initialized", "true");
card.addEventListener("click", function () {
const videoId = this.dataset.videoId;
if (videoId) {
window.location.href = `video.php?id=${videoId}`;
}
});
}
} else {
// Gérer l'erreur
this.textContent = "Erreur lors du chargement";
setTimeout(() => {
this.textContent = "Voir plus";
this.disabled = false;
}, 2000);
}
})
.catch((error) => {
console.error("Erreur:", error);
this.textContent = "Erreur lors du chargement";
setTimeout(() => {
this.textContent = "Voir plus";
this.disabled = false;
}, 2000);
});
});
}
});
+50 -1
View File
@@ -2,9 +2,58 @@
* 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 {
constructor(targetDate) {
this.targetDate = new Date(targetDate).getTime();
this.targetDate = parseTargetDate(targetDate);
this.elements = {
days: document.getElementById('countdown-days'),
hours: document.getElementById('countdown-hours'),
+12 -6
View File
@@ -428,8 +428,6 @@ document.addEventListener('DOMContentLoaded', function() {
videoType = 'recent';
} else if (sectionTitle.includes('tendances')) {
videoType = 'trending';
} else if (sectionTitle.includes('indépendance')) {
videoType = 'independence';
} else {
// Vérifier si c'est une section de catégorie
const categorySection = section.querySelector('[data-category-id]');
@@ -456,7 +454,7 @@ document.addEventListener('DOMContentLoaded', function() {
button.disabled = true;
// Préparer l'URL avec les paramètres
let url = `ajax/load-more-videos.php?type=${videoType}&page=${page}`;
let url = `ajax/load-more-videos?type=${videoType}&page=${page}`;
if (videoType === 'category' && categoryId) {
url += `&category=${categoryId}`;
}
@@ -502,16 +500,24 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialiser le lazy loading pour les nouvelles images
initLazyLoading();
} else {
// En cas d'erreur, afficher un message et réactiver le bouton
// En cas d'erreur, afficher un message temporaire et réactiver le bouton
console.error('Erreur lors du chargement des vidéos:', data.error);
button.textContent = 'Voir plus';
const originalText = 'Voir plus';
button.textContent = 'Erreur de chargement';
button.disabled = false;
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
})
.catch(error => {
console.error('Erreur lors de la requête AJAX:', error);
button.textContent = 'Voir plus';
const originalText = 'Voir plus';
button.textContent = 'Erreur de chargement';
button.disabled = false;
setTimeout(() => {
button.textContent = originalText;
}, 2000);
});
});
});
+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é.">
<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="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 -->
<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) === '#';
$searchTag = $isTagSearch ? substr($query, 1) : '';
// Rechercher les vidéos via l'API PeerTube si une requête est soumise
$searchResults = !empty($query) ? searchVideos($query, COUNT_VIDEO_SEARCH) : [];
// Définir le nombre total de résultats
$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
// Rechercher les vidéos via l'API PeerTube si une requête est soumise.
// 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.
$resultsCount = 0;
$currentPageVideos = [];
if ($resultsCount > 0) {
$currentPageVideos = array_slice($searchResults, $startIndex, VIDEOS_PER_PAGE);
$totalPages = 1;
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'; ?>">
<?php endif; ?>
<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 -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -74,8 +81,8 @@ if ($resultsCount > 0) {
<!-- Open Graph Meta Tags -->
<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:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<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: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: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)): ?>
<!-- Données structurées JSON-LD pour la page de recherche -->
@@ -160,34 +167,7 @@ if ($resultsCount > 0) {
<div class="video-grid category-videos">
<?php foreach ($currentPageVideos 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 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 echo renderVideoCard($video); ?>
<?php endforeach; ?>
</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";
+25 -3
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env bash
# Lance en local les mêmes vérifications que le CI (.gitea/workflows/).
# Lance en local l'ensemble des vérifications qualité du projet.
# À exécuter avant de pousser : scripts/check.sh
#
# Le CI (.gitea/workflows/) ne conserve que PHP lint et JS lint ; les autres
# vérifications (AsciiDoc, JSON, XML, shellcheck) sont désormais réservées au
# local et doivent être passées manuellement avec ce script.
#
# Un outil manquant n'est pas bloquant : le check correspondant est ignoré
# avec un avertissement (le CI, lui, exécute tout). Le script retourne un
# code non nul si une vérification échoue.
# avec un avertissement. Le script retourne un code non nul si une vérification
# échoue.
set -u
cd "$(dirname "$0")/.." || exit 1
@@ -94,6 +98,24 @@ if need shellcheck shellcheck; then
fi
fi
step "Tests unitaires PHP (tests/php/)"
if need php php-cli; then
if php tests/php/run.php; then
echo "✅ Tests PHP OK"
else
fail=1
fi
fi
step "Tests unitaires JS (tests/js/)"
if need node nodejs; then
if node tests/js/run.js; then
echo "✅ Tests JS OK"
else
fail=1
fi
fi
echo
if [ "$fail" -ne 0 ]; then
echo "❌ Des vérifications ont échoué — corrigez avant de pousser."
+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";
}
+22 -13
View File
@@ -2,8 +2,12 @@
// Tout changement de ce fichier déclenche l'installation d'un nouveau
// Service Worker chez les visiteurs ; les anciens caches sont purgés à
// l'activation (voir l'event 'activate' plus bas).
const STATIC_CACHE_NAME = 'annu-kute-ced-static-24072026-0612';
const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-24072026-0612';
const STATIC_CACHE_NAME = 'annu-kute-ced-static-26072026-1946';
const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-26072026-1946';
// Nombre maximal d'entrées conservées dans le cache dynamique (LRU :
// les entrées les moins récemment utilisées sont évincées en premier).
const DYNAMIC_CACHE_LIMIT = 50;
// Ressources à mettre en cache immédiatement
const STATIC_ASSETS = [
@@ -15,7 +19,6 @@ const STATIC_ASSETS = [
'/css/video-page.css',
'/css/mastodon-timeline.min.css',
'/js/main.js',
'/js/categories.js',
'/js/search.js',
'/js/mastodon-timeline.umd.js',
'/img/logo.png',
@@ -30,15 +33,6 @@ const STATIC_ASSETS = [
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css'
];
// Pages à mettre en cache
const PAGES_TO_CACHE = [
'/',
'/index.php',
'/categories.php',
'/recherche.php',
'/mentions-legales.php'
];
// Installation du Service Worker
self.addEventListener('install', event => {
console.log('Service Worker: Installation');
@@ -134,7 +128,13 @@ self.addEventListener('fetch', event => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE_NAME)
.then(cache => cache.put(request, responseClone));
.then(cache => {
// cache.put() replace une entrée existante en fin de liste
// (clés ordonnées par insertion) : la taille limitée évince
// donc bien les entrées les moins récemment utilisées.
return cache.put(request, responseClone)
.then(() => limitCacheSize(cache, DYNAMIC_CACHE_LIMIT));
});
}
return response;
})
@@ -176,6 +176,15 @@ self.addEventListener('fetch', event => {
});
// Fonctions utilitaires
// Évince les plus anciennes clés tant que le cache dépasse maxItems entrées.
function limitCacheSize(cache, maxItems) {
return cache.keys().then(keys => {
if (keys.length > maxItems) {
return cache.delete(keys[0]).then(() => limitCacheSize(cache, maxItems));
}
});
}
function isStaticAsset(url) {
return url.includes('/css/') ||
url.includes('/js/') ||
+180
View File
@@ -0,0 +1,180 @@
"""
Fixtures pytest pour les tests E2E : serveur PHP local + page Playwright.
Le serveur de développement PHP (`php -S`) est démarré une fois par session
de test sur 127.0.0.1:8000 (ou sur un port libre si 8000 est déjà pris).
Les appels aux API distantes (PeerTube, Castopod) passent par le vrai réseau :
le premier chargement remplit le cache du site (cache/api), ce qui peut
prendre un certain temps.
Lancement : /tmp/test-venv/bin/python -m pytest tests/e2e/
"""
import os
import re
import socket
import subprocess
import tempfile
import time
import urllib.request
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
PROJECT_ROOT = Path(__file__).resolve().parents[2]
SERVER_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
STARTUP_TIMEOUT = 30 # secondes pour que php -S accepte des connexions
WARMUP_TIMEOUT = 90 # premier hit : remplit les caches API (Castopod/PeerTube)
PAGE_TIMEOUT_MS = 60000
def _port_libre(port):
"""Retourne True si aucun service n'écoute sur le port donné."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex((SERVER_HOST, port)) != 0
def _trouver_port():
"""Utilise le port 8000 si possible, sinon un port libre quelconque."""
if _port_libre(DEFAULT_PORT):
return DEFAULT_PORT
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((SERVER_HOST, 0))
return s.getsockname()[1]
def lire_constante_config(nom, defaut=None):
"""
Lit une constante define('NOM', 'valeur') dans la configuration PHP.
Cherche dans config.default.php puis dans config.local.php (qui a le
dernier mot, comme dans includes/config.php). Ne gère que les valeurs
chaînes simples — suffisant pour SITE_NAME, PEERTUBE_URL, etc.
"""
valeur = defaut
for fichier in ("config.default.php", "config.local.php"):
chemin = PROJECT_ROOT / "includes" / fichier
if not chemin.is_file():
continue
texte = chemin.read_text(encoding="utf-8")
motif = r"define\(\s*'" + re.escape(nom) + r"'\s*,\s*'((?:[^'\\]|\\.)*)'\s*\)"
m = re.search(motif, texte)
if m:
valeur = m.group(1).replace("\\'", "'").replace("\\\\", "\\")
return valeur
@pytest.fixture(scope="session")
def base_url():
"""Démarre `php -S` à la racine du projet et fournit l'URL de base."""
port = _trouver_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(
mode="w+", prefix="php-e2e-", suffix=".log", delete=False
)
# PHP_CLI_SERVER_WORKERS : le serveur intégré est mono-thread par défaut,
# ce qui bloque quand le navigateur demande plusieurs PHP en parallèle.
env = dict(os.environ, PHP_CLI_SERVER_WORKERS="4")
proc = subprocess.Popen(
["php", "-S", f"{SERVER_HOST}:{port}", "-t", str(PROJECT_ROOT)],
cwd=str(PROJECT_ROOT),
stdout=log,
stderr=subprocess.STDOUT,
env=env,
)
# Attendre que le serveur accepte des connexions
deadline = time.monotonic() + STARTUP_TIMEOUT
pret = False
while time.monotonic() < deadline:
if proc.poll() is not None:
log.seek(0)
raise RuntimeError(
"Le serveur PHP s'est arrêté au démarrage :\n" + log.read()
)
try:
with socket.create_connection((SERVER_HOST, port), timeout=1):
pret = True
break
except OSError:
time.sleep(0.2)
if not pret:
proc.terminate()
if not config_local_exists:
config_local.unlink(missing_ok=True)
raise RuntimeError(
f"Le serveur PHP ne répond pas après {STARTUP_TIMEOUT} s"
)
# Pré-chauffe les caches (initCategories + appels API distants)
try:
with urllib.request.urlopen(url + "/index.php", timeout=WARMUP_TIMEOUT) as rep:
if rep.status != 200:
raise RuntimeError(f"index.php a répondu HTTP {rep.status}")
except Exception:
proc.terminate()
if not config_local_exists:
config_local.unlink(missing_ok=True)
log.seek(0)
raise RuntimeError(
"Impossible de charger la page d'accueil :\n" + log.read()
)
yield url
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
if not config_local_exists:
config_local.unlink(missing_ok=True)
log.close()
@pytest.fixture(scope="session")
def navigateur():
"""Instance Chromium partagée pour toute la session de test."""
with sync_playwright() as p:
browser = p.chromium.launch()
yield browser
browser.close()
@pytest.fixture()
def page(navigateur):
"""Nouvelle page vierge pour chaque test (contexte isolé)."""
context = navigateur.new_context()
context.set_default_timeout(PAGE_TIMEOUT_MS)
pg = context.new_page()
yield pg
context.close()
@pytest.fixture(scope="session")
def site_name():
"""Valeur de la constante SITE_NAME de la configuration du site."""
return lire_constante_config("SITE_NAME")
@pytest.fixture(scope="session")
def peertube_url():
"""Valeur de la constante PEERTUBE_URL de la configuration du site."""
return lire_constante_config("PEERTUBE_URL")
+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"
+62
View File
@@ -0,0 +1,62 @@
"""Tests E2E de la page d'accueil (index.php)."""
import re
import pytest
def test_homepage_title(page, base_url, site_name):
"""Le titre de la page correspond au SITE_NAME configuré."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
assert page.title() == site_name
def test_homepage_csrf_meta_token(page, base_url):
"""La balise meta csrf-token est présente au format 'timestamp:hmac'."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
token = page.locator('meta[name="csrf-token"]').get_attribute("content")
assert token, "La balise meta csrf-token est absente ou vide"
assert re.fullmatch(r"\d+:[0-9a-f]{64}", token), (
f"Format de token CSRF inattendu : {token!r}"
)
def test_homepage_main_sections(page, base_url):
"""Les sections principales sont rendues côté serveur."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
# Titres de sections vidéo (identifiants stables du markup)
assert page.locator("#shorts-heading").inner_text() == "Shorts"
assert page.locator("#recent-videos-heading").inner_text() == "Dernières vidéos"
assert page.locator("#trending-videos-heading").inner_text() == "Tendances"
# Section du fil d'actualités Mastodon
assert page.locator(".mastodon-section .mt-title").inner_text() != ""
# Au moins une grille de vidéos
assert page.locator(".video-section").count() >= 3
def test_homepage_view_more_buttons(page, base_url):
"""Chaque section vidéo propose un bouton 'Voir plus'."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
boutons = page.locator("button.view-more")
# Sections 'Dernières vidéos' et 'Tendances' toujours présentes
# (+ une par catégorie contenant des vidéos)
assert boutons.count() >= 2
for i in range(boutons.count()):
# text_content : le texte DOM brut (inner_text subit text-transform: uppercase)
assert boutons.nth(i).text_content() == "Voir plus"
def test_homepage_video_cards(page, base_url):
"""Des cartes vidéo remontent de l'API PeerTube (nécessite le réseau)."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
cartes = page.locator(".video-card")
if cartes.count() == 0:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
# Chaque carte expose un identifiant vidéo exploitable
for i in range(cartes.count()):
assert cartes.nth(i).get_attribute("data-video-id")
+106
View File
@@ -0,0 +1,106 @@
"""Tests E2E de l'endpoint AJAX ajax/load-more-videos.php.
L'endpoint exige trois protections cumulées (dans l'ordre) :
1. l'en-tête X-Requested-With: XMLHttpRequest ;
2. une origine (Origin, ou Referer à défaut) strictement identique au site ;
3. un token CSRF stateless valide (champ POST csrf_token).
"""
import pytest
ENDPOINT = "/ajax/load-more-videos.php"
@pytest.fixture()
def csrf_token(page, base_url):
"""Récupère un token CSRF frais depuis la balise meta de l'accueil."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
token = page.locator('meta[name="csrf-token"]').get_attribute("content")
assert token, "La balise meta csrf-token est absente de l'accueil"
return token
def _post(page, base_url, query="", token=None, ajax=True, origin=None):
"""POST sur l'endpoint avec les protections activables une à une."""
headers = {}
if ajax:
headers["X-Requested-With"] = "XMLHttpRequest"
if origin is not None:
headers["Origin"] = origin
form = {}
if token is not None:
form["csrf_token"] = token
return page.request.post(
base_url + ENDPOINT + query, form=form, headers=headers
)
def test_load_more_rejects_non_ajax(page, base_url, csrf_token):
"""Sans X-Requested-With, la requête est refusée (403)."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, ajax=False, origin=base_url)
assert rep.status == 403
assert rep.json() == {"error": "Accès non autorisé"}
def test_load_more_rejects_bad_origin(page, base_url, csrf_token):
"""Une origine étrangère est refusée (403)."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, origin="https://example.com")
assert rep.status == 403
assert rep.json() == {"error": "Origine non autorisée"}
def test_load_more_rejects_bad_csrf(page, base_url):
"""Un token CSRF forgé est refusé (403)."""
rep = _post(page, base_url, "?type=recent&page=1",
token="1234567890:tokenforge", origin=base_url)
assert rep.status == 403
assert rep.json() == {"error": "Token CSRF invalide"}
def test_load_more_rejects_invalid_type(page, base_url, csrf_token):
"""Un type de vidéos inconnu est refusé (400)."""
rep = _post(page, base_url, "?type=inconnu&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 400
assert rep.json() == {"error": "Type de vidéos non valide"}
def test_load_more_category_requires_id(page, base_url, csrf_token):
"""Le type 'category' sans ID de catégorie est refusé (400)."""
rep = _post(page, base_url, "?type=category&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 400
assert rep.json() == {"error": "ID de catégorie manquant ou invalide"}
def test_load_more_recent_structure(page, base_url, csrf_token):
"""Une requête valide renvoie la structure JSON attendue."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 200
payload = rep.json()
assert payload["success"] is True
assert payload["page"] == 1
assert isinstance(payload["hasMore"], bool)
assert isinstance(payload["html"], str)
def test_load_more_recent_returns_videos(page, base_url, csrf_token):
"""Le HTML retourné contient des cartes vidéo (nécessite le réseau)."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 200
payload = rep.json()
if not payload["html"]:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
assert 'class="video-card"' in payload["html"]
assert "data-video-id=" in payload["html"]
# L'endpoint n'a plus rien à servir bien au-delà du catalogue
rep_fin = _post(page, base_url, "?type=recent&page=10000",
token=csrf_token, origin=base_url)
assert rep_fin.status == 200
assert rep_fin.json()["hasMore"] is False
+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)
+115
View File
@@ -0,0 +1,115 @@
"""Tests E2E de la page vidéo (video.php)."""
import json
import pytest
def _premier_video_id(page, base_url):
"""Récupère l'identifiant de la première carte vidéo de l'accueil."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
ids = page.locator(".video-card").evaluate_all(
"(els) => els.map(e => e.dataset.videoId).filter(Boolean)"
)
# Déduplique en conservant l'ordre (la même vidéo peut être en short et en grille)
uniques = list(dict.fromkeys(ids))
if not uniques:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
return uniques[0]
def _jsonld_blocks(page):
"""Parse tous les blocs <script type='application/ld+json'> de la page."""
blocs = []
for brut in page.locator('script[type="application/ld+json"]').all_text_contents():
blocs.append(json.loads(brut))
return blocs
def test_video_page_jsonld_videoobject(page, base_url):
"""La page vidéo expose des données structurées VideoObject valides."""
video_id = _premier_video_id(page, base_url)
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
blocs = _jsonld_blocks(page)
types = [b.get("@type") for b in blocs]
assert "VideoObject" in types, f"Pas de VideoObject dans les JSON-LD : {types}"
video_ld = blocs[types.index("VideoObject")]
assert video_ld.get("@context") == "https://schema.org"
assert video_ld.get("name"), "Le nom du VideoObject est vide"
assert video_ld.get("embedUrl", "").endswith(f"/videos/embed/{video_id}")
assert video_ld.get("thumbnailUrl"), "thumbnailUrl manquant"
assert video_ld.get("uploadDate"), "uploadDate manquant"
assert video_ld.get("duration"), "duration manquante"
# Le fil d'Ariane est également publié
assert "BreadcrumbList" in types
def test_video_page_peertube_embed(page, base_url, peertube_url):
"""Le lecteur embarque l'iframe PeerTube de la vidéo demandée."""
video_id = _premier_video_id(page, base_url)
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
iframe = page.locator(".video-player iframe")
assert iframe.count() == 1, "L'iframe du lecteur est absente"
src = iframe.get_attribute("src")
assert src.startswith(f"{peertube_url}/videos/embed/{video_id}"), (
f"Source d'iframe inattendue : {src}"
)
def test_video_page_suggestions(page, base_url):
"""La colonne de suggestions propose d'autres vidéos du site."""
video_id = _premier_video_id(page, base_url)
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
suggestions = page.locator(".video-suggestions")
assert suggestions.count() == 1, "La section de suggestions est absente"
assert suggestions.locator("h2").inner_text() == "Vidéos suggérées"
cartes = suggestions.locator(".suggested-video")
if cartes.count() == 0:
pytest.skip("Aucune vidéo suggérée (API PeerTube indisponible ?)")
hrefs = cartes.locator("a.suggested-video-link").evaluate_all(
"(els) => els.map(e => e.getAttribute('href'))"
)
for href in hrefs:
assert href and href.startswith("video.php?id="), (
f"Lien de suggestion inattendu : {href}"
)
# La vidéo courante ne doit pas se suggérer elle-même
assert f"video.php?id={video_id}" not in hrefs
def test_video_page_invalid_id_redirects(page, base_url):
"""Un identifiant vidéo invalide redirige vers l'accueil (sans appel réseau)."""
page.goto(base_url + "/video.php?id=pas-un-uuid-valide",
wait_until="domcontentloaded")
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}"
)
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests unitaires pour js/countdown.js (classe CountdownTimer).
*
* Le script est chargé dans un contexte vm avec un DOM simulé :
* la date courante est figée (FakeDate) et setInterval est factice,
* ce qui rend les calculs de temps restant déterministes.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const COUNTDOWN_PATH = path.join(__dirname, '..', '..', 'js', 'countdown.js');
const COUNTDOWN_SOURCE = fs.readFileSync(COUNTDOWN_PATH, 'utf8');
// Date courante figée pour tous les tests
const FIXED_NOW = new Date('2030-01-01T00:00:00Z').getTime();
function makeElements() {
return {
'countdown-days': { textContent: '' },
'countdown-hours': { textContent: '' },
'countdown-minutes': { textContent: '' },
'countdown-seconds': { textContent: '' }
};
}
/**
* Charge countdown.js dans un contexte vm isolé et retourne la classe.
*
* @param {object|null} options.elements Éléments DOM simulés (null = absents)
* @param {number} options.now Timestamp courant simulé
*/
function loadCountdown({ elements = null, now = FIXED_NOW } = {}) {
class FakeDate extends Date {
constructor(...args) {
if (args.length === 0) {
super(now);
} else {
super(...args);
}
}
static now() {
return now;
}
}
const windowMock = {
addEventListener() {},
location: { href: '' }
};
// Timers factices : le timer ne tourne jamais tout seul,
// on enregistre simplement les appels pour vérification.
const timers = { intervals: [], cleared: [] };
let nextIntervalId = 1;
const sandbox = {
document: {
getElementById: (id) => (elements ? elements[id] || null : null),
querySelectorAll: () => [],
addEventListener() {}
},
window: windowMock,
Date: FakeDate,
setInterval: (fn, delay) => {
const id = nextIntervalId++;
timers.intervals.push({ id, fn, delay });
return id;
},
clearInterval: (id) => {
timers.cleared.push(id);
},
setTimeout: () => 0,
console
};
vm.createContext(sandbox);
const { CountdownTimer, parseTargetDate } = vm.runInContext(
COUNTDOWN_SOURCE + '\n({ CountdownTimer, parseTargetDate });',
sandbox
);
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', () => {
test('formatNumber complète les nombres sur deux chiffres', () => {
const { CountdownTimer } = loadCountdown();
assert.strictEqual(CountdownTimer.prototype.formatNumber(0), '00');
assert.strictEqual(CountdownTimer.prototype.formatNumber(5), '05');
assert.strictEqual(CountdownTimer.prototype.formatNumber(42), '42');
assert.strictEqual(CountdownTimer.prototype.formatNumber(123), '123');
});
test('calcule correctement le temps restant (jours/heures/minutes/secondes)', () => {
const distance =
2 * 24 * 60 * 60 * 1000 + // 2 jours
3 * 60 * 60 * 1000 + // 3 heures
4 * 60 * 1000 + // 4 minutes
5 * 1000; // 5 secondes
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer(toISO(FIXED_NOW + distance));
try {
assert.strictEqual(elements['countdown-days'].textContent, '02');
assert.strictEqual(elements['countdown-hours'].textContent, '03');
assert.strictEqual(elements['countdown-minutes'].textContent, '04');
assert.strictEqual(elements['countdown-seconds'].textContent, '05');
} finally {
timer.stop();
}
});
test('reporte les heures au-delà de 24h dans les jours', () => {
const distance = 36 * 60 * 60 * 1000; // 36 heures = 1 jour + 12 heures
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
const timer = new CountdownTimer(toISO(FIXED_NOW + distance));
try {
assert.strictEqual(elements['countdown-days'].textContent, '01');
assert.strictEqual(elements['countdown-hours'].textContent, '12');
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
} finally {
timer.stop();
}
});
test('une date passée arrête le compte à rebours et redirige vers /', () => {
const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements });
const timer = new CountdownTimer(toISO(FIXED_NOW - 1000));
try {
// onComplete() redirige vers la page principale
assert.strictEqual(window.location.href, '/');
// Les éléments ne sont pas mis à jour quand le compte est terminé
assert.strictEqual(elements['countdown-days'].textContent, '');
assert.strictEqual(elements['countdown-hours'].textContent, '');
assert.strictEqual(elements['countdown-minutes'].textContent, '');
assert.strictEqual(elements['countdown-seconds'].textContent, '');
} finally {
timer.stop();
}
});
test('une distance nulle affiche zéro partout sans rediriger', () => {
const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements });
const timer = new CountdownTimer(toISO(FIXED_NOW));
try {
assert.strictEqual(elements['countdown-days'].textContent, '00');
assert.strictEqual(elements['countdown-hours'].textContent, '00');
assert.strictEqual(elements['countdown-minutes'].textContent, '00');
assert.strictEqual(elements['countdown-seconds'].textContent, '00');
assert.strictEqual(window.location.href, '');
} finally {
timer.stop();
}
});
test('fonctionne sans éléments DOM présents', () => {
const { CountdownTimer } = loadCountdown({ elements: null });
const timer = new CountdownTimer(toISO(FIXED_NOW + 60 * 1000));
// Aucune erreur attendue malgré l'absence des éléments
timer.stop();
});
test('démarre un intervalle d\'une seconde et stop() le nettoie', () => {
const { CountdownTimer, timers } = loadCountdown();
const timer = new CountdownTimer(toISO(FIXED_NOW + 60 * 1000));
assert.strictEqual(timers.intervals.length, 1);
assert.strictEqual(timers.intervals[0].delay, 1000);
assert.strictEqual(timers.cleared.length, 0);
timer.stop();
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());
});
});
+311
View File
@@ -0,0 +1,311 @@
/**
* Unit tests for js/pleroma-adapter.js.
*
* The adapter is an IIFE that wraps window.fetch. It is loaded in a vm
* context with a mocked window object, so tests can call the wrapped
* fetch and inspect how Pleroma API responses are mapped to the
* Mastodon format expected by mastodon-timeline.umd.js.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ADAPTER_PATH = path.join(__dirname, '..', '..', 'js', 'pleroma-adapter.js');
const ADAPTER_SOURCE = fs.readFileSync(ADAPTER_PATH, 'utf8');
const TIMELINE_URL = 'https://pleroma.example/api/v1/timelines/public';
const ACCOUNT_STATUSES_URL = 'https://pleroma.example/api/v1/accounts/42/statuses';
/**
* Loads the adapter in an isolated vm context.
*
* @param {Function} handler Fake backend: receives the URL, returns a Response
* @returns {{fetch: Function, warnings: string[]}} The wrapped fetch and logged warnings
*/
function loadAdapter(handler) {
const warnings = [];
const windowMock = {
fetch: async (url) => handler(url)
};
const sandbox = {
window: windowMock,
Response,
console: {
log() {},
warn: (...args) => warnings.push(args.join(' ')),
error() {}
}
};
vm.createContext(sandbox);
vm.runInContext(ADAPTER_SOURCE, sandbox);
return { fetch: windowMock.fetch, warnings };
}
function jsonResponse(data, init = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
...init
});
}
describe('Pleroma adapter', () => {
test('adds default meta to timeline attachments missing meta', async () => {
const posts = [
{
id: '1',
content: 'hello',
media_attachments: [
{ id: 'a1', type: 'image', url: 'https://pleroma.example/img.png' }
]
},
{ id: '2', content: 'no media' }
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.strictEqual(res.status, 200);
assert.strictEqual(data.length, 2);
const attachment = data[0].media_attachments[0];
assert.deepStrictEqual(attachment.meta.original, {
width: 1280,
height: 720,
aspect: 1280 / 720
});
assert.deepStrictEqual(attachment.meta.small, {
width: 640,
height: 360,
aspect: 1280 / 720
});
// Other fields are preserved
assert.strictEqual(attachment.url, 'https://pleroma.example/img.png');
assert.strictEqual(data[0].content, 'hello');
assert.strictEqual(data[1].content, 'no media');
assert.strictEqual(data[1].media_attachments, undefined);
});
test('uses 1920x1080 for video attachments (pleroma mime_type)', async () => {
const posts = [
{
id: '9',
media_attachments: [
{ id: 'v1', pleroma: { mime_type: 'video/mp4' } }
]
}
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(ACCOUNT_STATUSES_URL);
const data = await res.json();
const meta = data[0].media_attachments[0].meta;
assert.deepStrictEqual(meta.original, {
width: 1920,
height: 1080,
aspect: 1920 / 1080
});
assert.deepStrictEqual(meta.small, {
width: 960,
height: 540,
aspect: 1920 / 1080
});
});
test('uses 1200x800 for image attachments (pleroma mime_type)', async () => {
const post = {
id: '10',
media_attachments: [
{ id: 'i1', pleroma: { mime_type: 'image/jpeg' } }
]
};
const { fetch } = loadAdapter(() => jsonResponse(post));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
const meta = data.media_attachments[0].meta;
assert.deepStrictEqual(meta.original, {
width: 1200,
height: 800,
aspect: 1200 / 800
});
assert.deepStrictEqual(meta.small, {
width: 600,
height: 400,
aspect: 1200 / 800
});
});
test('adapts a single (non-array) post object', async () => {
const post = {
id: 'single',
media_attachments: [{ id: 's1' }]
};
const { fetch } = loadAdapter(() => jsonResponse(post));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.strictEqual(Array.isArray(data), false);
assert.strictEqual(data.id, 'single');
assert.deepStrictEqual(data.media_attachments[0].meta.original, {
width: 1280,
height: 720,
aspect: 1280 / 720
});
});
test('leaves attachments with complete meta untouched', async () => {
const completeMeta = {
original: { width: 640, height: 480, aspect: 640 / 480 },
small: { width: 320, height: 240, aspect: 640 / 480 }
};
const posts = [
{ id: 'm1', media_attachments: [{ id: 'ok', meta: completeMeta }] }
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.deepStrictEqual(data[0].media_attachments[0].meta, completeMeta);
});
test('keeps existing partial meta.original and fills meta.small', async () => {
const posts = [
{
id: 'p1',
media_attachments: [
{
id: 'partial',
meta: { original: { width: 640, height: 480, aspect: 640 / 480 } }
}
]
}
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
const meta = data[0].media_attachments[0].meta;
assert.deepStrictEqual(meta.original, { width: 640, height: 480, aspect: 640 / 480 });
// meta.small is created from the default dimensions
assert.deepStrictEqual(meta.small, {
width: 640,
height: 360,
aspect: 1280 / 720
});
});
test('adapts media attachments inside reblogs recursively', async () => {
const posts = [
{
id: 'r1',
reblog: {
id: 'r2',
media_attachments: [
{ id: 'rb', pleroma: { mime_type: 'image/png' } }
]
}
}
];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
const meta = data[0].reblog.media_attachments[0].meta;
assert.deepStrictEqual(meta.original, {
width: 1200,
height: 800,
aspect: 1200 / 800
});
});
test('does not mutate the original payload objects', async () => {
const attachment = { id: 'orig' };
const posts = [{ id: 'n1', media_attachments: [attachment] }];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
await res.json();
assert.strictEqual(attachment.meta, undefined);
assert.deepStrictEqual(posts[0].media_attachments, [attachment]);
});
test('passes non-object array entries through unchanged', async () => {
const posts = [null, 'not-a-post', { id: 'real', media_attachments: [{ id: 'x' }] }];
const { fetch } = loadAdapter(() => jsonResponse(posts));
const res = await fetch(TIMELINE_URL);
const data = await res.json();
assert.strictEqual(data[0], null);
assert.strictEqual(data[1], 'not-a-post');
assert.ok(data[2].media_attachments[0].meta);
});
test('intercepts account statuses URLs only when they contain /statuses', async () => {
const posts = [{ id: 'a', media_attachments: [{ id: 'x' }] }];
const { fetch } = loadAdapter(() => jsonResponse(posts));
// /api/v1/accounts/42/statuses is adapted
const resStatuses = await fetch(ACCOUNT_STATUSES_URL);
const dataStatuses = await resStatuses.json();
assert.ok(dataStatuses[0].media_attachments[0].meta);
// /api/v1/accounts/42 (no /statuses) is left as-is
const resAccount = await fetch('https://pleroma.example/api/v1/accounts/42');
const dataAccount = await resAccount.json();
assert.strictEqual(dataAccount[0].media_attachments[0].meta, undefined);
});
test('returns non-API responses untouched (same Response object)', async () => {
let original;
const { fetch } = loadAdapter(() => {
original = jsonResponse({ ok: true });
return original;
});
const res = await fetch('https://example.com/about');
assert.strictEqual(res, original);
assert.deepStrictEqual(await res.json(), { ok: true });
});
test('falls back to the original response when JSON parsing fails', async () => {
let original;
const { fetch, warnings } = loadAdapter(() => {
original = new Response('not-json', { status: 200 });
return original;
});
const res = await fetch(TIMELINE_URL);
assert.strictEqual(res, original);
assert.strictEqual(await res.text(), 'not-json');
assert.strictEqual(warnings.length, 1);
});
test('preserves response status on adapted responses', async () => {
const posts = [{ id: 's', media_attachments: [] }];
const { fetch } = loadAdapter(() => jsonResponse(posts, { status: 201 }));
const res = await fetch(TIMELINE_URL);
assert.strictEqual(res.status, 201);
assert.deepStrictEqual(await res.json(), posts);
});
});
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Lanceur des tests unitaires JS.
*
* Utilise le runner natif de Node (node:test), aucune dépendance npm.
* Usage : node tests/js/run.js
*/
'use strict';
const { run } = require('node:test');
const { spec } = require('node:test/reporters');
const fs = require('node:fs');
const path = require('node:path');
const testDir = __dirname;
const files = fs
.readdirSync(testDir)
.filter((name) => name.endsWith('-test.js'))
.sort()
.map((name) => path.join(testDir, name));
if (files.length === 0) {
console.error('Aucun fichier *-test.js trouvé dans ' + testDir);
process.exit(1);
}
const stream = run({ files });
let failures = 0;
stream.on('test:fail', () => {
failures += 1;
});
stream.on('error', (err) => {
console.error(err);
process.exitCode = 1;
});
stream.compose(spec).pipe(process.stdout);
stream.on('end', () => {
if (failures > 0) {
process.exitCode = 1;
}
});
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* Bootstrap des tests unitaires PHP.
*
* Définit des constantes de test puis charge les includes du projet
* SANS aucun appel réseau : PEERTUBE_URL pointe volontairement sur
* 127.0.0.1, ce qui fait échouer isValidPeerTubeUrl() et court-circuite
* callPeerTubeApiOriginal() (retour [] immédiat, jamais de cURL). De ce
* fait, getPeertubeCategories() — chargement paresseux des catégories —
* retourne un tableau vide si un test l'appelle.
*
* 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()).
*/
define('APP_HOST_NAME', 'test.local');
define('SITE_NAME', 'Site de test');
define('CSRF_SECRET', 'secret-csrf-pour-tests-unitaires');
define('PEERTUBE_URL', 'http://127.0.0.1'); // URL privée volontaire : bloque tout appel API réel
define('CACHE_ENABLED', true);
define('CACHE_DURATION', 3600);
define('DEFAULT_TIMEZONE', 'UTC');
// Ne pas polluer la sortie des tests avec les error_log() de sécurité
// (ex. "Invalid PeerTube URL", attendu avec l'URL factice ci-dessus)
ini_set('error_log', sys_get_temp_dir() . '/annu-kute-tests-errors.log');
$includes_dir = dirname(__DIR__, 2) . '/includes';
require_once $includes_dir . '/security.php';
require_once $includes_dir . '/simple-cache.php';
require_once $includes_dir . '/lib/markdown.php';
require_once $includes_dir . '/structured-data.php';
require_once $includes_dir . '/config.php'; // bootstrap (charge les modules de includes/lib/)
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* Tests unitaires pour la classe SimpleAPICache (includes/simple-cache.php).
*
* Le répertoire de cache est redirigé vers un dossier temporaire via
* réflexion afin de ne jamais toucher au cache réel (cache/api).
*/
$cache = new SimpleAPICache();
$cacheDirProperty = new ReflectionProperty(SimpleAPICache::class, 'cacheDir');
$cacheDirProperty->setAccessible(true);
$tmpDir = sys_get_temp_dir() . '/annu-kute-cache-test-' . getmypid();
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
$cacheDirProperty->setValue($cache, $tmpDir);
// --- get sur un cache vide --------------------------------------------------
assertNull($cache->get('videos'), 'get retourne null quand aucune entrée n\'existe');
// --- set puis get -----------------------------------------------------------
$content = ['data' => ['video1', 'video2']];
$cache->set('videos', ['count' => 6], $content);
assertEquals($content, $cache->get('videos', ['count' => 6]), 'get retourne le contenu après set');
// Le fichier stocké contient bien content / expires / created
$files = glob($tmpDir . '/cache_*.json');
assertEquals(1, count($files), 'set crée exactement un fichier de cache');
$stored = json_decode(file_get_contents($files[0]), true);
assertEquals($content, $stored['content'], 'le fichier stocke le contenu sous la clé "content"');
assertTrue(isset($stored['expires']) && $stored['expires'] > time(), 'le fichier stocke une expiration future');
assertTrue(isset($stored['created']), 'le fichier stocke la date de création');
// --- Ordre des paramètres ---------------------------------------------------
// ksort() dans getCacheKey : l'ordre des paramètres ne change pas la clé
$cache->set('videos', ['b' => 2, 'a' => 1], 'ordonné');
assertEquals(
'ordonné',
$cache->get('videos', ['a' => 1, 'b' => 2]),
'get retrouve l\'entrée malgré un ordre de paramètres différent'
);
assertNull(
$cache->get('videos', ['a' => 9]),
'get retourne null pour des paramètres différents'
);
// --- Expiration -------------------------------------------------------------
$cache->set('videos', ['old' => 1], 'périmé', -1); // TTL négatif : déjà expiré
$filesBefore = count(glob($tmpDir . '/cache_*.json'));
assertNull($cache->get('videos', ['old' => 1]), 'get retourne null pour une entrée expirée');
assertEquals(
$filesBefore - 1,
count(glob($tmpDir . '/cache_*.json')),
'get supprime le fichier expiré'
);
// --- cleanup ----------------------------------------------------------------
$cache->set('cle-valide', [], 'valide', 300);
$cache->set('cle-perimee', [], 'périmée', -10);
$cleaned = $cache->cleanup();
assertEquals(1, $cleaned, 'cleanup supprime uniquement les entrées expirées');
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');
// --- É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 ----------------------------------------
foreach (glob($tmpDir . '/cache_*.json') as $file) {
unlink($file);
}
rmdir($tmpDir);
+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);
}
+248
View File
@@ -0,0 +1,248 @@
<?php
/**
* Tests unitaires pour les fonctions de formatage de includes/lib/format.php
* (formatDuration, formatViewCount, formatDate, formatVideosData)
* et truncateText de includes/structured-data.php
*/
// --- formatDuration ---------------------------------------------------------
assertEquals('0:00', formatDuration(0), 'formatDuration(0) donne 0:00');
assertEquals('0:59', formatDuration(59), 'formatDuration formate les secondes');
assertEquals('1:05', formatDuration(65), 'formatDuration formate minutes:secondes avec zéro');
assertEquals('1:00:00', formatDuration(3600), 'formatDuration passe au format heures');
assertEquals('1:01:01', formatDuration(3661), 'formatDuration formate heures:minutes:secondes');
// --- formatViewCount --------------------------------------------------------
assertEquals(999, formatViewCount(999), 'formatViewCount laisse les valeurs < 1000 intactes');
assertEquals('1K', formatViewCount(1000), 'formatViewCount abrège en K à partir de 1000');
assertEquals('1.5K', formatViewCount(1500), 'formatViewCount arrondit les K à une décimale');
assertEquals('1M', formatViewCount(1000000), 'formatViewCount abrège en M à partir de 1 million');
assertEquals('2.5M', formatViewCount(2500000), 'formatViewCount arrondit les M à une décimale');
// --- formatDate -------------------------------------------------------------
assertEquals('Aujourd\'hui', formatDate(date('Y-m-d H:i:s')), 'formatDate affiche Aujourd\'hui');
assertEquals('Hier', formatDate(date('Y-m-d H:i:s', time() - 86400)), 'formatDate affiche Hier');
assertEquals(
'Il y a 3 jours',
formatDate(date('Y-m-d H:i:s', time() - 3 * 86400)),
'formatDate affiche les jours sous une semaine'
);
assertEquals(
'Il y a 1 semaine',
formatDate(date('Y-m-d H:i:s', time() - 10 * 86400)),
'formatDate affiche la semaine au singulier'
);
assertEquals(
'Il y a 2 semaines',
formatDate(date('Y-m-d H:i:s', time() - 20 * 86400)),
'formatDate affiche les semaines au pluriel'
);
assertEquals(
'Il y a 2 mois',
formatDate(date('Y-m-d H:i:s', time() - 60 * 86400)),
'formatDate affiche les mois'
);
assertEquals(
'Il y a 1 an',
formatDate(date('Y-m-d H:i:s', time() - 400 * 86400)),
'formatDate affiche l\'année au singulier'
);
assertEquals(
'Il y a 2 ans',
formatDate(date('Y-m-d H:i:s', time() - 800 * 86400)),
'formatDate affiche les années au pluriel'
);
// --- formatDate : dates malformées (repli sur la chaîne brute) ---------------
assertEquals(
'pas une date',
formatDate('pas une date'),
'formatDate retourne la chaîne brute si la date est invalide'
);
assertEquals(
'2024-13-45T99:99:99Z',
formatDate('2024-13-45T99:99:99Z'),
'formatDate retourne une date ISO malformée telle quelle'
);
assertEquals('', formatDate(''), 'formatDate retourne une chaîne vide telle quelle');
assertEquals(' ', formatDate(' '), 'formatDate retourne une chaîne blanche telle quelle');
// --- formatVideosData -------------------------------------------------------
$rawVideos = [
[
'uuid' => 'abc-123',
'name' => 'Ma vidéo',
'previewPath' => '/lazy/abc.jpg',
'duration' => 125,
'channel' => [
'displayName' => 'Chaîne',
'avatars' => [['path' => '/avatars/a.png']]
],
'views' => 42,
'publishedAt' => '2024-01-01T00:00:00.000Z',
'aspectRatio' => 1.78,
'description' => 'Une description',
'tags' => ['musique'],
'isLive' => true,
],
[
// Vidéo minimale : sans vignette, avatar, description, tags ni isLive
'uuid' => 'def-456',
'name' => 'Minimale',
'duration' => 5,
'channel' => ['displayName' => 'Autre chaîne'],
'views' => 0,
'publishedAt' => '2024-01-02T00:00:00.000Z',
'aspectRatio' => 1.78,
],
];
$videos = formatVideosData($rawVideos);
assertEquals(2, count($videos), 'formatVideosData retourne une entrée par vidéo');
assertEquals('abc-123', $videos[0]['id'], 'formatVideosData mappe uuid vers id');
assertEquals('Ma vidéo', $videos[0]['title'], 'formatVideosData mappe name vers title');
assertEquals(
PEERTUBE_URL . '/lazy/abc.jpg',
$videos[0]['thumbnail'],
'formatVideosData préfixe la vignette avec PEERTUBE_URL'
);
assertEquals(
PEERTUBE_URL . '/avatars/a.png',
$videos[0]['channelAvatar'],
'formatVideosData préfixe l\'avatar avec PEERTUBE_URL'
);
assertEquals(125, $videos[0]['duration'], 'formatVideosData conserve la durée');
assertEquals('Chaîne', $videos[0]['channel'], 'formatVideosData mappe le nom de la chaîne');
assertEquals(42, $videos[0]['views'], 'formatVideosData conserve les vues');
assertEquals('2024-01-01T00:00:00.000Z', $videos[0]['date'], 'formatVideosData mappe publishedAt vers date');
assertEquals('Une description', $videos[0]['description'], 'formatVideosData conserve la description');
assertEquals(['musique'], $videos[0]['tags'], 'formatVideosData conserve les tags');
assertTrue($videos[0]['isLive'], 'formatVideosData conserve isLive');
assertEquals(
'img/default-thumbnail.jpg',
$videos[1]['thumbnail'],
'formatVideosData utilise une vignette par défaut si absente'
);
assertEquals(
'img/default-avatar.png',
$videos[1]['channelAvatar'],
'formatVideosData utilise un avatar par défaut si absent'
);
assertEquals('', $videos[1]['description'], 'formatVideosData met une description vide 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');
// --- formatVideosData : données API incomplètes -------------------------------
$incompleteVideos = formatVideosData([
[
// Sans uuid : doit être ignorée
'name' => 'Sans uuid',
'duration' => 10,
],
[
// uuid vide : doit être ignorée aussi
'uuid' => '',
'name' => 'Uuid vide',
],
[
// uuid seul : valeurs par défaut partout ailleurs
'uuid' => 'ghi-789',
],
[
// channel présent mais sans displayName ni avatars
'uuid' => 'jkl-012',
'channel' => ['name' => 'compte'],
],
]);
assertEquals(2, count($incompleteVideos), 'formatVideosData ignore les entrées sans uuid');
assertEquals('ghi-789', $incompleteVideos[0]['id'], 'formatVideosData conserve l\'uuid seul');
assertEquals('', $incompleteVideos[0]['title'], 'formatVideosData met un titre vide par défaut');
assertEquals(0, $incompleteVideos[0]['duration'], 'formatVideosData met une durée à 0 par défaut');
assertEquals('', $incompleteVideos[0]['channel'], 'formatVideosData met une chaîne vide par défaut');
assertEquals(0, $incompleteVideos[0]['views'], 'formatVideosData met les vues à 0 par défaut');
assertEquals('', $incompleteVideos[0]['date'], 'formatVideosData met une date vide par défaut');
assertNull($incompleteVideos[0]['aspectRatio'], 'formatVideosData met aspectRatio à null par défaut');
assertFalse($incompleteVideos[0]['isLive'], 'formatVideosData met isLive à false par défaut');
assertEquals(
'img/default-thumbnail.jpg',
$incompleteVideos[0]['thumbnail'],
'formatVideosData met la vignette par défaut pour une entrée minimale'
);
assertEquals(
'img/default-avatar.png',
$incompleteVideos[0]['channelAvatar'],
'formatVideosData met l\'avatar par défaut pour une entrée minimale'
);
assertEquals('', $incompleteVideos[1]['channel'], 'formatVideosData met une chaîne vide si displayName absent');
assertEquals(
'img/default-avatar.png',
$incompleteVideos[1]['channelAvatar'],
'formatVideosData met l\'avatar par défaut si le tableau avatars est absent'
);
// --- truncateText (includes/structured-data.php) ----------------------------
assertEquals('court', truncateText('court', 200), 'truncateText laisse un texte court intact');
assertEquals('exact', truncateText('exact', 5), 'truncateText laisse un texte à la limite exacte intact');
$textWithSpace = str_repeat('a', 150) . ' ' . str_repeat('b', 100); // 251 caractères
assertEquals(
str_repeat('a', 150) . '...',
truncateText($textWithSpace, 200),
'truncateText coupe au dernier espace avant la limite'
);
$textNoSpace = str_repeat('a', 250);
assertEquals(
str_repeat('a', 200) . '...',
truncateText($textNoSpace, 200),
'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'
);
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* Tests unitaires pour markdown_to_html (includes/lib/markdown.php)
*/
// --- Échappement XSS ---------------------------------------------------------
assertEquals(
'&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;',
markdown_to_html('<script>alert("x")</script>'),
'markdown_to_html échappe le HTML brut'
);
assertNotContains(
'<script>',
markdown_to_html('<script>alert("x")</script>'),
'markdown_to_html ne laisse passer aucune balise script'
);
// --- Gras et italique --------------------------------------------------------
assertEquals(
'<strong>gras</strong>',
markdown_to_html('**gras**'),
'markdown_to_html convertit **texte** en <strong>'
);
assertEquals(
'<em>ital</em>',
markdown_to_html('*ital*'),
'markdown_to_html convertit *texte* en <em>'
);
// --- Liens Markdown ----------------------------------------------------------
assertEquals(
'<a href="https://exemple.com" target="_blank" rel="noopener noreferrer">exemple</a>',
markdown_to_html('[exemple](https://exemple.com)'),
'markdown_to_html convertit un lien Markdown avec attributs de sécurité'
);
assertEquals(
'<a href="http://exemple.com" target="_blank" rel="noopener noreferrer">site</a>',
markdown_to_html('[site](exemple.com)'),
'markdown_to_html ajoute http:// aux liens sans protocole'
);
// --- URLs brutes -------------------------------------------------------------
assertEquals(
'Visite <a href="https://exemple.com/page" target="_blank" rel="noopener noreferrer">https://exemple.com/page</a> pour info',
markdown_to_html('Visite https://exemple.com/page pour info'),
'markdown_to_html rend les URLs http(s) brutes cliquables'
);
assertEquals(
'va sur <a href="http://o-k-i.net" target="_blank" rel="noopener noreferrer">o-k-i.net</a> maintenant',
markdown_to_html('va sur o-k-i.net maintenant'),
'markdown_to_html rend les domaines nus cliquables (avec http://)'
);
assertEquals(
'version v1.2 dispo',
markdown_to_html('version v1.2 dispo'),
'markdown_to_html ne transforme pas un numéro de version en lien'
);
// --- URLs contenant « & » : pas de double encodage ---------------------------
// Le texte est échappé une seule fois : « & » devient « &amp; », jamais « &amp;amp; »
assertEquals(
'Voir <a href="https://exemple.com/page?a=1&amp;b=2" target="_blank" rel="noopener noreferrer">https://exemple.com/page?a=1&amp;b=2</a> suite',
markdown_to_html('Voir https://exemple.com/page?a=1&b=2 suite'),
'markdown_to_html n\'encode pas deux fois le & des URLs brutes'
);
assertEquals(
'<a href="https://exemple.com/?x=1&amp;y=2" target="_blank" rel="noopener noreferrer">lien</a>',
markdown_to_html('[lien](https://exemple.com/?x=1&y=2)'),
'markdown_to_html n\'encode pas deux fois le & des liens Markdown'
);
assertNotContains(
'&amp;amp;',
markdown_to_html('https://exemple.com/page?a=1&b=2'),
'markdown_to_html ne produit jamais de &amp;amp;'
);
// --- Listes ------------------------------------------------------------------
assertEquals(
"<ul>\n<li>a</li>\n<li>b</li>\n</ul>",
markdown_to_html("- a\n- b"),
'markdown_to_html convertit les listes à puces en <ul> uniquement'
);
assertEquals(
"<ol>\n<li>a</li>\n<li>b</li>\n</ol>",
markdown_to_html("1. a\n2. b"),
'markdown_to_html convertit les listes numérotées en <ol>'
);
assertEquals(
"<ul>\n<li>a</li>\n</ul><br />\n<ol>\n<li>b</li>\n</ol><br />\n<ul>\n<li>c</li>\n</ul>",
markdown_to_html("- a\n1. b\n- c"),
'markdown_to_html sépare les listes de types différents'
);
assertEquals(
"<ul>\n<li>a</li>\n</ul><br />\ntexte",
markdown_to_html("- a\ntexte"),
'markdown_to_html ferme la liste avant le texte qui suit'
);
// --- Retours à la ligne ------------------------------------------------------
assertEquals(
"ligne1<br />\nligne2",
markdown_to_html("ligne1\nligne2"),
'markdown_to_html convertit les sauts de ligne en <br />'
);
+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);
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* Mini-lanceur de tests unitaires PHP (aucune dépendance externe).
*
* Usage : php tests/php/run.php
*
* Exécute tous les fichiers tests/php/*-test.php puis affiche un résumé.
* Code de sortie : 0 si tous les tests passent, 1 sinon.
*/
error_reporting(E_ALL);
require_once __DIR__ . '/bootstrap.php';
// --- Assertions -----------------------------------------------------------
$GLOBALS['tests_passed'] = 0;
$GLOBALS['tests_failed'] = 0;
$GLOBALS['tests_failures'] = [];
/**
* Formate une valeur pour l'affichage dans les messages d'échec
*/
function test_export($value) {
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
return var_export($value, true);
}
/**
* Enregistre le résultat d'une assertion et affiche la ligne correspondante
*/
function test_record($ok, $label, $detail = '') {
if ($ok) {
$GLOBALS['tests_passed']++;
echo " [OK] $label\n";
} else {
$GLOBALS['tests_failed']++;
$GLOBALS['tests_failures'][] = $label;
echo " [ÉCHEC] $label" . ($detail !== '' ? "$detail" : '') . "\n";
}
}
function assertEquals($expected, $actual, $label) {
test_record(
$expected == $actual,
$label,
'attendu ' . test_export($expected) . ', obtenu ' . test_export($actual)
);
}
function assertTrue($actual, $label) {
test_record($actual === true, $label, 'attendu true, obtenu ' . test_export($actual));
}
function assertFalse($actual, $label) {
test_record($actual === false, $label, 'attendu false, obtenu ' . test_export($actual));
}
function assertNull($actual, $label) {
test_record($actual === null, $label, 'attendu null, obtenu ' . test_export($actual));
}
function assertContains($needle, $haystack, $label) {
test_record(
strpos($haystack, $needle) !== false,
$label,
test_export($needle) . ' introuvable dans ' . test_export($haystack)
);
}
function assertNotContains($needle, $haystack, $label) {
test_record(
strpos($haystack, $needle) === false,
$label,
test_export($needle) . ' trouvé dans ' . test_export($haystack)
);
}
// --- Exécution des fichiers de test ---------------------------------------
$testFiles = glob(__DIR__ . '/*-test.php');
sort($testFiles);
if (empty($testFiles)) {
echo "Aucun fichier de test trouvé dans " . __DIR__ . "\n";
exit(1);
}
foreach ($testFiles as $file) {
echo "\n== " . basename($file) . " ==\n";
try {
require $file;
} catch (Throwable $e) {
test_record(false, basename($file) . ' : exception non interceptée — ' . $e->getMessage());
}
}
// --- Résumé ----------------------------------------------------------------
$passed = $GLOBALS['tests_passed'];
$failed = $GLOBALS['tests_failed'];
echo "\n----------------------------------------\n";
echo "Résumé : $passed réussi(s), $failed échoué(s).\n";
if ($failed > 0) {
echo "Tests en échec :\n";
foreach ($GLOBALS['tests_failures'] as $failure) {
echo " - $failure\n";
}
exit(1);
}
exit(0);
+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);
+339
View File
@@ -0,0 +1,339 @@
<?php
/**
* Tests unitaires pour includes/security.php
* (et les validateurs d'URL de includes/config.php)
*/
// --- validateVideoId --------------------------------------------------------
assertEquals(
'9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
validateVideoId('9c5de94d-8e1a-4b3c-9d2e-1234567890ab'),
'validateVideoId accepte un UUID valide'
);
assertEquals(
'9C5DE94D-8E1A-4B3C-9D2E-1234567890AB',
validateVideoId('9C5DE94D-8E1A-4B3C-9D2E-1234567890AB'),
'validateVideoId accepte un UUID en majuscules'
);
assertEquals(
'9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
validateVideoId(' 9c5de94d-8e1a-4b3c-9d2e-1234567890ab '),
'validateVideoId nettoie les espaces autour de l\'UUID'
);
assertFalse(validateVideoId(''), 'validateVideoId refuse une chaîne vide');
assertFalse(validateVideoId('pas-un-uuid'), 'validateVideoId refuse une chaîne quelconque');
assertFalse(
validateVideoId('9c5de94d-8e1a-4b3c-1234567890ab'),
'validateVideoId refuse un UUID tronqué'
);
// --- validateSearchQuery ----------------------------------------------------
assertEquals('indépendance', validateSearchQuery('indépendance'), 'validateSearchQuery conserve les accents');
assertEquals(
'script',
validateSearchQuery('<script>'),
'validateSearchQuery supprime les chevrons'
);
assertEquals(
'recherche btest/b',
validateSearchQuery('recherche <b>test</b>'),
'validateSearchQuery supprime les caractères dangereux <>"\''
);
assertEquals('terme', validateSearchQuery(' terme '), 'validateSearchQuery nettoie les espaces');
assertFalse(validateSearchQuery(''), 'validateSearchQuery refuse une chaîne vide');
assertFalse(
validateSearchQuery(str_repeat('a', 201)),
'validateSearchQuery refuse plus de 200 caractères'
);
assertEquals(
str_repeat('a', 200),
validateSearchQuery(str_repeat('a', 200)),
'validateSearchQuery accepte exactement 200 caractères'
);
// --- validateCategoryId -----------------------------------------------------
assertEquals(1, validateCategoryId(1), 'validateCategoryId accepte la borne basse 1');
assertEquals(20, validateCategoryId(20), 'validateCategoryId accepte la borne haute 20');
assertEquals(5, validateCategoryId('5'), 'validateCategoryId convertit une chaîne numérique');
assertFalse(validateCategoryId(0), 'validateCategoryId refuse 0');
assertFalse(validateCategoryId(21), 'validateCategoryId refuse 21');
assertFalse(validateCategoryId(-3), 'validateCategoryId refuse un nombre négatif');
assertFalse(validateCategoryId('abc'), 'validateCategoryId refuse une chaîne non numérique');
// --- validatePageNumber -----------------------------------------------------
assertEquals(1, validatePageNumber(0), 'validatePageNumber remonte 0 à 1');
assertEquals(1, validatePageNumber(-5), 'validatePageNumber remonte un négatif à 1');
assertEquals(3, validatePageNumber(3), 'validatePageNumber conserve une page valide');
assertEquals(2, validatePageNumber('2'), 'validatePageNumber convertit une chaîne numérique');
assertEquals(1, validatePageNumber('abc'), 'validatePageNumber ramène une chaîne non numérique à 1');
// --- generateCSRFToken / validateCSRFToken ----------------------------------
$token = generateCSRFToken();
assertContains(':', $token, 'generateCSRFToken produit le format "timestamp:hash"');
[$timestamp, $hash] = explode(':', $token, 2);
assertTrue(ctype_digit($timestamp), 'generateCSRFToken : la partie timestamp est numérique');
assertEquals(
hash_hmac('sha256', $timestamp, CSRF_SECRET),
$hash,
'generateCSRFToken : le hash est un HMAC-SHA256 du timestamp avec CSRF_SECRET'
);
assertTrue(validateCSRFToken($token), 'validateCSRFToken accepte un token fraîchement généré');
assertFalse(validateCSRFToken(''), 'validateCSRFToken refuse une chaîne vide');
assertFalse(validateCSRFToken('sans-deux-points'), 'validateCSRFToken refuse un token sans séparateur');
assertFalse(validateCSRFToken('abc:' . $hash), 'validateCSRFToken refuse un timestamp non numérique');
assertFalse(
validateCSRFToken($timestamp . ':' . hash_hmac('sha256', $timestamp, 'autre-secret')),
'validateCSRFToken refuse un hash signé avec un autre secret'
);
assertFalse(
validateCSRFToken($timestamp . ':0' . $hash),
'validateCSRFToken refuse un hash modifié'
);
// Token expiré : horodatage d'il y a 2 heures (limite : 1 heure)
$oldTimestamp = (string) (time() - 7200);
$oldToken = $oldTimestamp . ':' . hash_hmac('sha256', $oldTimestamp, CSRF_SECRET);
assertFalse(validateCSRFToken($oldToken), 'validateCSRFToken refuse un token expiré (> 1 heure)');
// Timestamp trop loin dans le futur (l'écart absolu est également plafonné)
$futureTimestamp = (string) (time() + 7200);
$futureToken = $futureTimestamp . ':' . hash_hmac('sha256', $futureTimestamp, CSRF_SECRET);
assertFalse(validateCSRFToken($futureToken), 'validateCSRFToken refuse un timestamp trop futur');
// --- isValidPeerTubeUrl (includes/config.php) -------------------------------
assertTrue(isValidPeerTubeUrl('https://peertube.example.com'), 'isValidPeerTubeUrl accepte une URL HTTPS publique');
assertTrue(isValidPeerTubeUrl('http://peertube.example.com'), 'isValidPeerTubeUrl accepte HTTP (développement)');
assertTrue(isValidPeerTubeUrl('https://8.8.8.8'), 'isValidPeerTubeUrl accepte une IP publique');
assertFalse(isValidPeerTubeUrl('ftp://example.com'), 'isValidPeerTubeUrl refuse un schéma non HTTP(S)');
assertFalse(isValidPeerTubeUrl('pas-une-url'), 'isValidPeerTubeUrl refuse une chaîne mal formée');
assertFalse(isValidPeerTubeUrl('https://localhost'), 'isValidPeerTubeUrl refuse localhost');
assertFalse(isValidPeerTubeUrl('http://127.0.0.1'), 'isValidPeerTubeUrl refuse 127.0.0.1');
assertFalse(isValidPeerTubeUrl('http://192.168.1.1'), 'isValidPeerTubeUrl refuse une IP privée');
assertFalse(isValidPeerTubeUrl('http://10.0.0.5'), 'isValidPeerTubeUrl refuse une IP privée (10.x)');
assertFalse(
isValidPeerTubeUrl('https://metadata.google.internal'),
'isValidPeerTubeUrl refuse metadata.google.internal'
);
// --- isValidApiEndpoint (includes/config.php) -------------------------------
assertTrue(isValidApiEndpoint('videos'), 'isValidApiEndpoint accepte "videos"');
assertTrue(isValidApiEndpoint('videos/categories'), 'isValidApiEndpoint accepte "videos/categories"');
assertTrue(isValidApiEndpoint('search/videos'), 'isValidApiEndpoint accepte "search/videos"');
assertTrue(isValidApiEndpoint('accounts'), 'isValidApiEndpoint accepte "accounts"');
assertTrue(
isValidApiEndpoint('videos/9c5de94d-8e1a-4b3c-9d2e-1234567890ab'),
'isValidApiEndpoint accepte un endpoint vidéo dynamique'
);
assertTrue(
isValidApiEndpoint('videos/abc/comment-threads'),
'isValidApiEndpoint accepte les fils de commentaires'
);
assertTrue(
isValidApiEndpoint('accounts/annu_kute_ced/videos'),
'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('videos/../x'), 'isValidApiEndpoint refuse ".." dans le chemin');
assertFalse(isValidApiEndpoint('videos//categories'), 'isValidApiEndpoint refuse un double slash');
assertFalse(isValidApiEndpoint('wp-admin'), 'isValidApiEndpoint refuse un endpoint hors liste blanche');
assertFalse(isValidApiEndpoint('videos.json'), 'isValidApiEndpoint refuse les caractères non autorisés (.)');
assertFalse(isValidApiEndpoint('videos?count=1'), 'isValidApiEndpoint refuse une query string');
assertFalse(isValidApiEndpoint(''), 'isValidApiEndpoint refuse une chaîne vide');
// --- getCspNonce ------------------------------------------------------------
$nonce = getCspNonce();
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($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');
+43 -41
View File
@@ -126,7 +126,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<?php endif; ?>
<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="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 -->
<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: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'])): ?>
<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; ?>
<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:site_name" content="<?php echo SITE_NAME; ?>">
<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: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'])): ?>
<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 if (!isset($videoNotFound) && !empty($video)): ?>
@@ -224,7 +224,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<?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>
<?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 class="video-actions">
@@ -301,20 +301,20 @@ if (empty($videoData) || isset($videoData['error'])) {
$channelUrl = PEERTUBE_URL . '/c/' . $video['channelHandle'];
?>
<?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">
<i class="fas fa-user-circle" aria-hidden="true"></i>
</div>
</a>
<?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">
<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>
</a>
<?php endif; ?>
<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>
</a>
</div>
@@ -335,7 +335,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<i class="fas fa-chevron-down" aria-hidden="true"></i>
</button>
</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']); ?>
<button class="show-less-btn" aria-expanded="true" aria-controls="full-description">
<span>Voir moins</span>
@@ -362,7 +362,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="section-title-wrapper">
<h2 id="comments-heading" class="section-title">Commentaires</h2>
</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; ?>
</a>
</header>
@@ -375,7 +375,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<article class="comment">
<div class="comment-avatar">
<?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: ?>
<div class="channel-avatar-placeholder mini" role="img" aria-label="Avatar par défaut">
<i class="fas fa-user-circle" aria-hidden="true"></i>
@@ -385,14 +385,14 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="comment-content">
<header class="comment-header">
<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>
<div class="comment-text"><?php echo nl2br(htmlspecialchars($comment['text'])); ?></div>
<?php if (isset($comment['totalReplies']) && $comment['totalReplies'] > 0): ?>
<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">
<i class="fas fa-reply" aria-hidden="true"></i> Voir les <?php echo $comment['totalReplies']; ?> réponse<?php echo $comment['totalReplies'] > 1 ? 's' : ''; ?>
<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 e($comment['totalReplies']); ?> réponse<?php echo $comment['totalReplies'] > 1 ? 's' : ''; ?>
</a>
</div>
<?php endif; ?>
@@ -404,13 +404,13 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="comments-info">
<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>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>
<?php else: ?>
<div class="no-comments">
<i class="fas fa-comments"></i>
<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>
<?php endif; ?>
</div>
@@ -424,27 +424,27 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="suggestion-list">
<?php foreach ($suggestedVideos as $suggestedVideo): ?>
<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">
<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 class="suggested-video-info">
<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">
<?php
<?php
// Vérifier si un avatar de chaîne est disponible pour la vidéo suggérée
$suggestedAvatar = isset($suggestedVideo['channelAvatar']) ? $suggestedVideo['channelAvatar'] : '';
if (empty($suggestedAvatar) || strpos($suggestedAvatar, 'default-avatar') !== false):
if (empty($suggestedAvatar) || strpos($suggestedAvatar, 'default-avatar') !== false):
?>
<div class="channel-avatar-placeholder mini">
<i class="fas fa-user-circle"></i>
</div>
<?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; ?>
<span class="channel-name"><?php echo $suggestedVideo['channel']; ?></span>
<span class="channel-name"><?php echo e($suggestedVideo['channel']); ?></span>
</div>
<div class="suggested-video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
@@ -466,7 +466,8 @@ if (empty($videoData) || isset($videoData['error'])) {
</main>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<?php if (!isset($videoNotFound)): ?>
<!-- Modal de téléchargement -->
<div id="download-modal" class="modal">
<div class="modal-content">
@@ -482,13 +483,13 @@ if (empty($videoData) || isset($videoData['error'])) {
if (!empty($downloadOptions)):
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">
<i class="fas fa-film"></i>
<span><?php echo $option['resolution']; ?></span>
<span><?php echo e($option['resolution']); ?></span>
</div>
<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>
</div>
</a>
@@ -517,7 +518,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="share-link-container">
<p>Lien de la vidéo :</p>
<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">
<i class="fas fa-copy"></i>
</button>
@@ -526,32 +527,32 @@ if (empty($videoData) || isset($videoData['error'])) {
<p class="share-platforms-title">Partager sur :</p>
<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>
<span>E-mail</span>
</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>
<span>Facebook</span>
</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>
<span>X</span>
</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>
<span>WhatsApp</span>
</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>
<span>LinkedIn</span>
</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>
<span>Telegram</span>
</a>
@@ -560,7 +561,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<div class="share-embed">
<p>Intégrer la vidéo :</p>
<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">
<i class="fas fa-copy"></i>
</button>
@@ -580,15 +581,15 @@ if (empty($videoData) || isset($videoData['error'])) {
if (showMoreBtn) {
showMoreBtn.addEventListener('click', function() {
document.querySelector('.truncated-description').style.display = 'none';
document.querySelector('.full-description').style.display = 'block';
document.querySelector('.truncated-description').classList.add('is-hidden');
document.querySelector('.full-description').classList.remove('is-hidden');
});
}
if (showLessBtn) {
showLessBtn.addEventListener('click', function() {
document.querySelector('.full-description').style.display = 'none';
document.querySelector('.truncated-description').style.display = 'block';
document.querySelector('.full-description').classList.add('is-hidden');
document.querySelector('.truncated-description').classList.remove('is-hidden');
});
}
@@ -672,6 +673,7 @@ if (empty($videoData) || isset($videoData['error'])) {
}
});
</script>
<?php endif; ?>
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
</body>