Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
745726dad7 | ||
|
|
d0f9ee4e6b | ||
|
|
0e6506b636 | ||
|
|
7cbb7334f0 | ||
|
|
ddd82fdff6 | ||
|
|
cb13fcfb9f | ||
|
|
8181dbd57c | ||
|
|
b994383bf4 | ||
|
|
3b074f00d8 | ||
|
|
71e367b86f | ||
|
|
d81dd71a46 | ||
|
|
d9da213341 | ||
|
|
df55445480 | ||
|
|
4778f0e9e3 | ||
|
|
a28ba8bfec | ||
|
|
442c262539 | ||
|
|
9b460a2550 | ||
|
|
f895800c9d | ||
|
|
606175d718 | ||
|
|
54a4368ea8 | ||
|
|
c02180fb4d | ||
|
|
5c726f91b2
|
||
|
|
98c2818c3f
|
||
|
|
6c432c517f
|
||
|
|
090663dc3b
|
||
|
|
5899dfb856
|
||
|
|
9109068b0b
|
||
|
|
0d45b716fb
|
||
|
|
b12d66195d
|
||
|
|
050d583b6b
|
||
|
|
bc64981023
|
||
|
|
3620010212
|
||
|
|
aa86bf65e4
|
||
|
|
e673b484ce
|
||
|
|
5457f6fae0
|
||
|
|
33646f8d19
|
||
|
|
acd3a44c55
|
||
|
|
609677f679
|
||
|
|
e5c7db1355
|
||
|
|
f04b3f5e64
|
||
|
|
9eddf5d9cd
|
||
|
|
9449f7e149
|
||
|
|
dc217e77f1
|
||
|
|
4a4b56296e
|
||
|
|
84013376f2
|
||
|
|
dafb5e5753
|
||
|
|
4114b9e8e3
|
||
|
|
521ad8c7f1
|
||
|
|
576a72a0e1
|
||
|
|
9cf7487f24
|
||
|
|
681683c059
|
||
|
|
7e8f078b60
|
||
|
|
7f1bdaa2ab
|
||
|
|
f760dfb72c
|
||
|
|
4e180c0248
|
||
|
|
cc6580ccbb
|
||
|
|
5b1a78782a
|
||
|
|
3d28d3832f
|
||
|
|
eaacd23702
|
@@ -0,0 +1,44 @@
|
|||||||
|
name: Vérification PR
|
||||||
|
run-name: Vérification PR de ${{ gitea.actor }}
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: curl, intl, mbstring, xml
|
||||||
|
|
||||||
|
- name: Lint PHP (tous les fichiers, samples inclus)
|
||||||
|
run: |
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r f; do
|
||||||
|
php -l "$f" > /dev/null || { echo "::error file=$f::Erreur de syntaxe PHP"; fail=1; }
|
||||||
|
done < <(find . -path ./.git -prune -o \( -name '*.php' -o -name '*.php.sample' \) -print)
|
||||||
|
[ "$fail" -eq 0 ] && echo "PHP lint OK" || exit 1
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Lint JS (sw.js et js/*.js)
|
||||||
|
run: |
|
||||||
|
for f in sw.js js/*.js; do
|
||||||
|
node --check "$f" || exit 1
|
||||||
|
done
|
||||||
|
echo "JS lint OK"
|
||||||
|
|
||||||
|
- name: Tests unitaires PHP
|
||||||
|
run: php tests/php/run.php
|
||||||
|
|
||||||
|
- name: Tests unitaires JS
|
||||||
|
run: node tests/js/run.js
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Déploiement PROD
|
||||||
|
run-name: ${{ gitea.actor }} déploie en PROD
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: curl, intl, mbstring, xml
|
||||||
|
|
||||||
|
- name: Lint PHP (tous les fichiers, samples inclus)
|
||||||
|
run: |
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r f; do
|
||||||
|
php -l "$f" > /dev/null || { echo "::error file=$f::Erreur de syntaxe PHP"; fail=1; }
|
||||||
|
done < <(find . -path ./.git -prune -o \( -name '*.php' -o -name '*.php.sample' \) -print)
|
||||||
|
[ "$fail" -eq 0 ] && echo "PHP lint OK" || exit 1
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Lint JS (sw.js et js/*.js)
|
||||||
|
run: |
|
||||||
|
for f in sw.js js/*.js; do
|
||||||
|
node --check "$f" || exit 1
|
||||||
|
done
|
||||||
|
echo "JS lint OK"
|
||||||
|
|
||||||
|
- name: Tests unitaires PHP
|
||||||
|
run: php tests/php/run.php
|
||||||
|
|
||||||
|
- name: Tests unitaires JS
|
||||||
|
run: node tests/js/run.js
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Déployer sur le serveur
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd ${{ secrets.PROD_DEPLOY_PATH }}
|
||||||
|
|
||||||
|
# Annuler le bump de version du déploiement précédent pour
|
||||||
|
# garantir le fast-forward, puis récupérer la dernière version.
|
||||||
|
git checkout -- sw.js
|
||||||
|
git pull --ff-only origin main
|
||||||
|
|
||||||
|
# Bumper la version des caches du Service Worker : chaque
|
||||||
|
# déploiement déclenche le modal de mise à jour PWA chez les
|
||||||
|
# visiteurs (voir js/pwa-update.js). Ce bump n'est pas commité.
|
||||||
|
VERSION=$(date +%d%m%Y-%H%M)
|
||||||
|
sed -i "s/annu-kute-ced-static-[0-9]\{8\}-[0-9]\{4\}/annu-kute-ced-static-$VERSION/" sw.js
|
||||||
|
sed -i "s/annu-kute-ced-dynamic-[0-9]\{8\}-[0-9]\{4\}/annu-kute-ced-dynamic-$VERSION/" sw.js
|
||||||
|
echo "Version des caches bumpée : $VERSION"
|
||||||
@@ -8,6 +8,8 @@ robots.txt
|
|||||||
site.webmanifest
|
site.webmanifest
|
||||||
mentions-legales.php
|
mentions-legales.php
|
||||||
dons.php
|
dons.php
|
||||||
|
404.php
|
||||||
|
500.php
|
||||||
|
|
||||||
# Fichiers de l'IDE/éditeur
|
# Fichiers de l'IDE/éditeur
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -25,6 +27,10 @@ temp/
|
|||||||
# Fichiers de cache
|
# Fichiers de cache
|
||||||
cache/
|
cache/
|
||||||
|
|
||||||
|
# Artefacts de documentation générés (docs/generate-readme-pdf.sh)
|
||||||
|
/*.html
|
||||||
|
/*.pdf
|
||||||
|
|
||||||
# Dossier pour les images d'annonces (tout ignorer sauf .gitkeep)
|
# Dossier pour les images d'annonces (tout ignorer sauf .gitkeep)
|
||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
@@ -33,4 +39,12 @@ uploads/*
|
|||||||
# vendor/
|
# vendor/
|
||||||
# node_modules/
|
# node_modules/
|
||||||
|
|
||||||
|
# Rapport d'audit interne OKI (ne pas committer)
|
||||||
|
audit/
|
||||||
|
|
||||||
|
# Artefacts de tests locaux
|
||||||
|
.pytest_cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
img/movement_presentation.png
|
img/movement_presentation.png
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
= 🚀 DEPLOY — Mise en production et CI/CD
|
||||||
|
:toc: left
|
||||||
|
:toc-title: Sommaire
|
||||||
|
:toclevels: 3
|
||||||
|
|
||||||
|
Ce document décrit la mise en production complète d'*ANNU KUTE CED* sur un serveur fraîchement installé, puis l'activation du déploiement continu via *Gitea Actions*.
|
||||||
|
|
||||||
|
Il est complémentaire au link:README.adoc[README] (installation manuelle, configuration de l'application).
|
||||||
|
|
||||||
|
== 🧭 Vue d'ensemble
|
||||||
|
|
||||||
|
L'architecture retenue (identique à celle de pawol.nu) :
|
||||||
|
|
||||||
|
[source]
|
||||||
|
----
|
||||||
|
┌─────────────┐ push main ┌──────────────────┐ SSH (git pull) ┌─────────────┐
|
||||||
|
│ Dépôt git │ ─────────────▶│ Gitea Actions │ ─────────────────▶│ Serveur │
|
||||||
|
│ (LaBola) │ │ check → deploy │ │ production │
|
||||||
|
└─────────────┘ └──────────────────┘ └─────────────┘
|
||||||
|
----
|
||||||
|
|
||||||
|
. *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.
|
||||||
|
|
||||||
|
== 📋 Prérequis
|
||||||
|
|
||||||
|
- Un serveur *Debian 12* ou *Ubuntu 24.04* fraîchement installé, avec accès `root` (ou `sudo`)
|
||||||
|
- Un nom de domaine dont le *DNS pointe vers le serveur* (enregistrement A/AAAA)
|
||||||
|
- Le dépôt Gitea : `git@labola.o-k-i.net:cedric/annu-kute-ced.git`
|
||||||
|
- Un *runner Gitea Actions* opérationnel sur l'instance LaBola (déjà le cas pour pawol.nu)
|
||||||
|
|
||||||
|
NOTE: Sur un hébergement mutualisé (pas d'accès root), adaptez : les fichiers sont déployés dans le docroot fourni par l'hébergeur, et la clé SSH s'ajoute via le panneau de contrôle (o2switch : *Clés SSH* dans cPanel).
|
||||||
|
|
||||||
|
== 1️⃣ Installation des paquets
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# Nginx + PHP-FPM (choix recommandé ; Apache possible, voir §5 option B)
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
apt install -y nginx php-fpm \
|
||||||
|
php-curl php-intl php-mbstring php-xml \
|
||||||
|
git curl unzip
|
||||||
|
|
||||||
|
# Vérifier les extensions requises
|
||||||
|
php -m | grep -E 'curl|intl|mbstring|SimpleXML|json'
|
||||||
|
----
|
||||||
|
|
||||||
|
== 2️⃣ Utilisateur de déploiement
|
||||||
|
|
||||||
|
Le runner CI se connectera en SSH avec cet utilisateur. Ne *pas* utiliser root.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
adduser --disabled-password --gecos "Deploy annu-kute-ced" deploy
|
||||||
|
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
|
||||||
|
|
||||||
|
Le serveur de production tient un clone du dépôt. Le runner y exécutera `git pull` à chaque déploiement.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# Clé SSH du serveur pour lire le dépôt (deploy key Gitea, lecture seule)
|
||||||
|
sudo -u deploy ssh-keygen -t ed25519 -f /home/deploy/.ssh/id_ed25519 -N ""
|
||||||
|
cat /home/deploy/.ssh/id_ed25519.pub
|
||||||
|
----
|
||||||
|
|
||||||
|
. Sur LaBola : *Settings du dépôt → Deploy Keys → Add deploy key* (coller la clé publique, lecture seule suffit).
|
||||||
|
. Puis :
|
||||||
|
+
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
sudo -u deploy git clone git@labola.o-k-i.net:cedric/annu-kute-ced.git /var/www/annu-kute-ced
|
||||||
|
cd /var/www/annu-kute-ced
|
||||||
|
sudo -u deploy git config pull.ff only # sécurité : refuser tout pull non fast-forward
|
||||||
|
----
|
||||||
|
|
||||||
|
== 4️⃣ Fichiers d'instance
|
||||||
|
|
||||||
|
Ces fichiers sont ignorés par Git : ils vivent *uniquement sur le serveur* et ne seront jamais écrasés par les déploiements.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
cd /var/www/annu-kute-ced
|
||||||
|
sudo -u deploy cp includes/config.local.php.sample includes/config.local.php
|
||||||
|
sudo -u deploy cp site.webmanifest.sample site.webmanifest
|
||||||
|
sudo -u deploy cp robots.txt.sample robots.txt
|
||||||
|
sudo -u deploy cp sitemap.xml.sample sitemap.xml
|
||||||
|
sudo -u deploy cp mentions-legales.php.sample mentions-legales.php
|
||||||
|
# Uniquement si vous utilisez Apache (option B de l'étape 5) :
|
||||||
|
# sudo -u deploy cp conf/.htaccess.sample .htaccess
|
||||||
|
# Facultatif (page de dons) :
|
||||||
|
# sudo -u deploy cp dons.php.sample dons.php
|
||||||
|
----
|
||||||
|
|
||||||
|
Éditer ensuite les fichiers copiés :
|
||||||
|
|
||||||
|
- `includes/config.local.php` : `APP_HOST_NAME`, sources (PeerTube/Castopod/Mastodon), `CACHE_ENABLED=true` (recommandé pour Castopod), etc. — voir la link:README.adoc#fr-configuration[référence de configuration]
|
||||||
|
- `sitemap.xml`, `robots.txt`, `site.webmanifest` : remplacer `example.com` par le domaine réel
|
||||||
|
- `mentions-legales.php` : remplacer le placeholder `VOTRE-DATE-MAJ`
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# Le cache de l'API doit être accessible en écriture par PHP
|
||||||
|
mkdir -p /var/www/annu-kute-ced/cache
|
||||||
|
chown -R www-data:www-data /var/www/annu-kute-ced/cache
|
||||||
|
----
|
||||||
|
|
||||||
|
== 5️⃣ VirtualHost
|
||||||
|
|
||||||
|
=== Option A — Nginx + PHP-FPM (recommandée, configuration fournie)
|
||||||
|
|
||||||
|
Le fichier `conf/nginx.conf.sample` est l'équivalent Nginx complet du `.htaccess` : mêmes protections (fichiers de configuration, répertoires sensibles, dotfiles, pas de listing), masquage de l'extension `.php`, `no-cache` pour `sw.js` et `site.webmanifest`, plus le cache des assets et gzip.
|
||||||
|
|
||||||
|
Il est conçu pour un déploiement *en deux temps* : le bloc `:80` sert immédiatement le site en HTTP (prérequis de la validation Certbot, étape 6), et `certbot --nginx` créera ensuite le bloc `443` avec la redirection HTTPS. Aucun certificat n'est donc requis à cette étape — `nginx -t` doit passer tel quel.
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# Adapter conf/nginx.conf.sample :
|
||||||
|
# - server_name : votre domaine
|
||||||
|
# - root : /var/www/annu-kute-ced
|
||||||
|
# - fastcgi_pass : socket de votre version PHP (ex. /var/run/php/php8.3-fpm.sock)
|
||||||
|
cp conf/nginx.conf.sample /etc/nginx/sites-available/annu-kute-ced
|
||||||
|
ln -s /etc/nginx/sites-available/annu-kute-ced /etc/nginx/sites-enabled/
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
----
|
||||||
|
|
||||||
|
=== Option B — Apache
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
apt install -y apache2 php libapache2-mod-php
|
||||||
|
a2enmod rewrite headers
|
||||||
|
cat > /etc/apache2/sites-available/annu-kute-ced.conf <<'EOF'
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName example.com
|
||||||
|
ServerAlias www.example.com
|
||||||
|
DocumentRoot /var/www/annu-kute-ced
|
||||||
|
|
||||||
|
<Directory /var/www/annu-kute-ced>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
EOF
|
||||||
|
a2ensite annu-kute-ced
|
||||||
|
systemctl reload apache2
|
||||||
|
----
|
||||||
|
|
||||||
|
Le `.htaccess` copié à l'étape 4 applique les règles de sécurité, le HTTPS forcé (actif après l'étape 6) et le `no-cache` de `sw.js`.
|
||||||
|
|
||||||
|
== 6️⃣ HTTPS (obligatoire pour la PWA)
|
||||||
|
|
||||||
|
Méthode recommandée par l'EFF : *Certbot via snap* (https://certbot.eff.org/instructions?ws=nginx&os=snap[instructions officielles^]). Prérequis : le domaine pointe vers le serveur et le site répond déjà en HTTP sur le port 80 (étape 5 terminée).
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
# 1. Installer snapd (Ubuntu : déjà présent. Debian : apt + support classic)
|
||||||
|
apt install -y snapd
|
||||||
|
snap install core && snap refresh core
|
||||||
|
|
||||||
|
# 2. Retirer tout certbot installé via apt (évite les conflits de commande)
|
||||||
|
apt-get remove -y certbot || true
|
||||||
|
|
||||||
|
# 3. Installer Certbot et préparer la commande
|
||||||
|
snap install --classic certbot
|
||||||
|
ln -s /snap/bin/certbot /usr/local/bin/certbot
|
||||||
|
|
||||||
|
# 4. Obtenir le certificat ET laisser Certbot configurer Nginx automatiquement
|
||||||
|
certbot --nginx -d example.com -d www.example.com
|
||||||
|
# Variante prudente (ne fait qu'émettre le certificat, vhost édité à la main) :
|
||||||
|
# certbot certonly --nginx -d example.com -d www.example.com
|
||||||
|
|
||||||
|
# 5. Vérifier le renouvellement automatique (timer systemd/cron inclus avec le snap)
|
||||||
|
certbot renew --dry-run
|
||||||
|
----
|
||||||
|
|
||||||
|
NOTE: Pour Apache (option B), la méthode snap est identique, avec `certbot --apache` à l'étape 4.
|
||||||
|
|
||||||
|
Ouvrez ensuite `https://example.com` dans un navigateur : le cadenas doit apparaître dans la barre d'URL.
|
||||||
|
|
||||||
|
== 7️⃣ Vérification du site
|
||||||
|
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
curl -I https://example.com
|
||||||
|
# Attendu : HTTP/2 200, en-têtes de sécurité (CSP, X-Frame-Options…)
|
||||||
|
curl -I https://example.com/sw.js
|
||||||
|
# Attendu : Cache-Control: no-cache
|
||||||
|
----
|
||||||
|
|
||||||
|
Ouvrir le site dans un navigateur : vidéos, podcasts, timeline et le bouton d'installation PWA doivent fonctionner.
|
||||||
|
|
||||||
|
== 8️⃣ Clé SSH du CI
|
||||||
|
|
||||||
|
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]
|
||||||
|
----
|
||||||
|
# Sur votre poste local
|
||||||
|
ssh-keygen -t ed25519 -f annu-kute-ced-deploy -C "ci-gitea-annu-kute-ced"
|
||||||
|
|
||||||
|
# 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.*
|
||||||
|
|
||||||
|
== 9️⃣ Secrets Gitea
|
||||||
|
|
||||||
|
Dans LaBola : *Settings du dépôt → Actions → Secrets*, créer :
|
||||||
|
|
||||||
|
[cols="1,3",options="header"]
|
||||||
|
|===
|
||||||
|
| Secret | Valeur
|
||||||
|
|
||||||
|
| `SSH_HOST`
|
||||||
|
| Adresse du serveur (IP ou FQDN), port 22 par défaut (sinon `host:port`)
|
||||||
|
|
||||||
|
| `SSH_USER`
|
||||||
|
| `deploy`
|
||||||
|
|
||||||
|
| `SSH_KEY`
|
||||||
|
| Contenu *intégral* de la clé privée générée à l'étape 8
|
||||||
|
|
||||||
|
| `PROD_DEPLOY_PATH`
|
||||||
|
| `/var/www/annu-kute-ced`
|
||||||
|
|===
|
||||||
|
|
||||||
|
WARNING: `SSH_KEY` donne un accès shell au serveur avec les droits de `deploy`. Ne jamais la coller ailleurs que dans les secrets Gitea, et révoquer la clé publique (`authorized_keys`) en cas de doute.
|
||||||
|
|
||||||
|
== 🔟 Test du pipeline
|
||||||
|
|
||||||
|
. Pousser un commit sur `main` (par exemple une correction de coquille dans le README).
|
||||||
|
. Dans LaBola, onglet *Actions* : le workflow *Déploiement PROD* doit exécuter `check` puis `deploy` au vert.
|
||||||
|
. Côté serveur :
|
||||||
|
+
|
||||||
|
[source,bash]
|
||||||
|
----
|
||||||
|
cd /var/www/annu-kute-ced
|
||||||
|
git log -1 --oneline # doit correspondre au commit poussé
|
||||||
|
grep STATIC_CACHE_NAME sw.js # le suffixe de version a été bumpé à l'heure du déploiement
|
||||||
|
----
|
||||||
|
. Côté visiteur : au prochain chargement de page, le modal « Nouvelle version disponible » apparaît (Service Worker déjà installé lors d'une visite précédente).
|
||||||
|
|
||||||
|
== 🔄 Fonctionnement courant
|
||||||
|
|
||||||
|
[cols="1,3",options="header"]
|
||||||
|
|===
|
||||||
|
| Événement | Résultat
|
||||||
|
|
||||||
|
| Pull request vers `main`
|
||||||
|
| Workflow *Vérification PR* : lint PHP/JS et tests unitaires PHP/JS bloquants en cas d'erreur
|
||||||
|
|
||||||
|
| Push sur `main`
|
||||||
|
| 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 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"]
|
||||||
|
|===
|
||||||
|
| Symptôme | Piste
|
||||||
|
|
||||||
|
| `git pull --ff-only` échoue sur le serveur
|
||||||
|
| Une modification locale existe : `git status` dans le docroot, puis `git checkout -- <fichier>` (le workflow le fait déjà pour `sw.js`)
|
||||||
|
|
||||||
|
| `Permission denied` sur `.git/FETCH_HEAD` (ou un autre fichier `.git`)
|
||||||
|
| Des fichiers du clone appartiennent à `root` (une commande `git` a été lancée en root, sans `sudo -u deploy`) : `chown -R deploy:www-data /var/www/annu-kute-ced`
|
||||||
|
|
||||||
|
| L'action ne se déclenche pas
|
||||||
|
| Vérifier que le runner act est en ligne (LaBola → *Site Administration → Actions → Runners*) et que Actions est activé pour le dépôt (*Settings → Units*)
|
||||||
|
|
||||||
|
| `Permission denied (publickey)` dans le job deploy
|
||||||
|
| Clé publique absente de `/home/deploy/.ssh/authorized_keys`, ou mauvais `SSH_USER`/`SSH_HOST`
|
||||||
|
|
||||||
|
| Les visiteurs ne reçoivent pas la mise à jour
|
||||||
|
| Vérifier `curl -I https://example.com/sw.js` → `Cache-Control: no-cache` requis (règles fournies dans `conf/`)
|
||||||
|
|
||||||
|
| Erreurs 500 sur les vidéos/podcasts
|
||||||
|
| `cache/` non accessible en écriture : `chown -R www-data:www-data cache/`
|
||||||
|
|
||||||
|
| `502 Bad Gateway` (Nginx)
|
||||||
|
| PHP-FPM injoignable : vérifier le socket réel avec `ls /var/run/php/` et adapter `fastcgi_pass` dans le vhost (ex. `php8.1-fpm.sock` sous Ubuntu 22.04, `php8.3-fpm.sock` sous 24.04) ; vérifier que le service tourne : `systemctl status php*-fpm`
|
||||||
|
|
||||||
|
| `php-intl` manquant
|
||||||
|
| `apt install php-intl` puis `systemctl restart php*-fpm` (Nginx) ou `systemctl restart apache2` (Apache)
|
||||||
|
|===
|
||||||
|
|
||||||
|
== 🔒 Notes de sécurité
|
||||||
|
|
||||||
|
- La clé privée du CI est *dédiée* à ce dépôt : une clé compromise ne donne accès qu'au compte `deploy`, sans sudo.
|
||||||
|
- La deploy key Gitea (étape 3) est en *lecture seule*.
|
||||||
|
- Pour restreindre davantage la clé du CI, on peut limiter les commandes dans `authorized_keys` (`command="..."`, `no-pty`) — facultatif, hors scope de ce guide.
|
||||||
|
- Les fichiers sensibles (`includes/`, `.htaccess`, `config.local.php`) sont bloqués en accès web par les configurations fournies dans `conf/`.
|
||||||
|
|
||||||
|
== 📞 Support
|
||||||
|
|
||||||
|
En cas de blocage : mailto:kontak@o-k-i.net[kontak@o-k-i.net]
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
// Inclure la configuration
|
// Inclure la configuration
|
||||||
require_once '../includes/config.php';
|
require_once '../includes/config.php';
|
||||||
|
|
||||||
|
// Limitation de débit : 30 requêtes / minute par IP (l'endpoint proxifie PeerTube)
|
||||||
|
if (!checkRateLimit($_SERVER['REMOTE_ADDR'] ?? 'unknown', 30, 60)) {
|
||||||
|
http_response_code(429); // Trop de requêtes
|
||||||
|
echo json_encode(['error' => 'Trop de requêtes, réessayez plus tard']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier que la requête est faite par AJAX
|
// Vérifier que la requête est faite par AJAX
|
||||||
if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') {
|
if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') {
|
||||||
http_response_code(403); // Accès non autorisé
|
http_response_code(403); // Accès non autorisé
|
||||||
@@ -25,11 +32,13 @@ if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
|
|||||||
|
|
||||||
// Récupérer les paramètres
|
// Récupérer les paramètres
|
||||||
$type = isset($_GET['type']) ? $_GET['type'] : '';
|
$type = isset($_GET['type']) ? $_GET['type'] : '';
|
||||||
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
|
// Borner la page à [1, 1000] : au-delà il n'y a plus de contenu réel, et des
|
||||||
|
// pages arbitrairement grandes contourneraient le cache de l'API PeerTube.
|
||||||
|
$page = min(1000, validatePageNumber($_GET['page'] ?? 1));
|
||||||
$categoryId = isset($_GET['category']) ? intval($_GET['category']) : 0;
|
$categoryId = isset($_GET['category']) ? intval($_GET['category']) : 0;
|
||||||
|
|
||||||
// Vérifier que le type est valide
|
// Vérifier que le type est valide
|
||||||
if (!in_array($type, ['recent', 'trending', 'independence', 'category'])) {
|
if (!in_array($type, ['recent', 'trending', 'category'])) {
|
||||||
http_response_code(400); // Requête incorrecte
|
http_response_code(400); // Requête incorrecte
|
||||||
echo json_encode(['error' => 'Type de vidéos non valide']);
|
echo json_encode(['error' => 'Type de vidéos non valide']);
|
||||||
exit;
|
exit;
|
||||||
@@ -69,17 +78,6 @@ switch ($type) {
|
|||||||
$videos = formatVideosData($data['data'] ?? []);
|
$videos = formatVideosData($data['data'] ?? []);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'independence':
|
|
||||||
// Récupérer les vidéos sur l'indépendance
|
|
||||||
$data = callPeerTubeApi('videos', [
|
|
||||||
'tagsOneOf' => TAG_INDEPENDENCE,
|
|
||||||
'count' => LOAD_MORE_COUNT,
|
|
||||||
'start' => $offset,
|
|
||||||
'isLocal' => true
|
|
||||||
]);
|
|
||||||
$videos = formatVideosData($data['data'] ?? []);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'category':
|
case 'category':
|
||||||
// Récupérer les vidéos de la catégorie
|
// Récupérer les vidéos de la catégorie
|
||||||
$data = callPeerTubeApi('videos', [
|
$data = callPeerTubeApi('videos', [
|
||||||
@@ -97,25 +95,7 @@ switch ($type) {
|
|||||||
$html = '';
|
$html = '';
|
||||||
|
|
||||||
foreach ($videos as $video) {
|
foreach ($videos as $video) {
|
||||||
$html .= '<div class="video-card" data-video-id="' . htmlspecialchars($video['id']) . '">';
|
$html .= renderVideoCard($video);
|
||||||
$html .= ' <div class="video-thumbnail">';
|
|
||||||
$html .= ' <img src="' . htmlspecialchars($video['thumbnail']) . '" alt="' . htmlspecialchars($video['title']) . '">';
|
|
||||||
$html .= ' <div class="video-play-icon">';
|
|
||||||
$html .= ' <i class="fas fa-play-circle"></i>';
|
|
||||||
$html .= ' </div>';
|
|
||||||
$html .= ' <div class="video-duration">' . formatDuration($video['duration']) . '</div>';
|
|
||||||
$html .= ' </div>';
|
|
||||||
$html .= ' <div class="video-info">';
|
|
||||||
$html .= ' <h3 class="video-title">' . htmlspecialchars($video['title']) . '</h3>';
|
|
||||||
$html .= ' <div class="video-channel">' . htmlspecialchars($video['channel']) . '</div>';
|
|
||||||
$html .= ' <div class="video-metadata">';
|
|
||||||
if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS) {
|
|
||||||
$html .= ' <span class="video-views"><i class="fas fa-eye"></i> ' . formatViewCount($video['views']) . ' vues</span>';
|
|
||||||
}
|
|
||||||
$html .= ' <span class="video-date"><i class="far fa-calendar-alt"></i> ' . formatDate($video['date']) . '</span>';
|
|
||||||
$html .= ' </div>';
|
|
||||||
$html .= ' </div>';
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retourner la réponse
|
// Retourner la réponse
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ setSecurityHeaders();
|
|||||||
$categoryId = isset($_GET['id']) ? $_GET['id'] : null;
|
$categoryId = isset($_GET['id']) ? $_GET['id'] : null;
|
||||||
$categoryId = $categoryId ? validateCategoryId($categoryId) : null;
|
$categoryId = $categoryId ? validateCategoryId($categoryId) : null;
|
||||||
|
|
||||||
// Récupérer les catégories disponibles
|
// Récupérer les catégories disponibles (chargement paresseux, ARC-2)
|
||||||
$allCategories = PEERTUBE_CATEGORIES;
|
$allCategories = getPeertubeCategories();
|
||||||
|
|
||||||
// Récupérer les vidéos de la catégorie si un ID est fourni
|
// Récupérer les vidéos de la catégorie si un ID est fourni
|
||||||
if ($categoryId && isset($allCategories[$categoryId])) {
|
if ($categoryId && isset($allCategories[$categoryId])) {
|
||||||
@@ -43,8 +43,10 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||||
<title><?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?></title>
|
<title><?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?></title>
|
||||||
|
<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="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -52,13 +54,13 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
||||||
<link rel="manifest" href="site.webmanifest">
|
<link rel="manifest" href="site.webmanifest">
|
||||||
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#FF0000">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
|
<meta property="og:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
|
<meta property="og:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
|
||||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:locale" content="fr_FR">
|
<meta property="og:locale" content="fr_FR">
|
||||||
@@ -67,7 +69,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
|||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
|
<meta name="twitter:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
|
||||||
<meta name="twitter:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
|
<meta name="twitter:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
|
||||||
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
|
|
||||||
<!-- Données structurées JSON-LD pour la page de catégorie -->
|
<!-- Données structurées JSON-LD pour la page de catégorie -->
|
||||||
<?php
|
<?php
|
||||||
@@ -112,7 +114,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
|||||||
<div class="section-logo">
|
<div class="section-logo">
|
||||||
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
|
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
|
||||||
</div>
|
</div>
|
||||||
<h2 class="section-title">Catégorie : <?php echo htmlspecialchars($categoryName); ?></h2>
|
<h1 class="section-title">Catégorie : <?php echo htmlspecialchars($categoryName); ?></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($videos)): ?>
|
<?php if (empty($videos)): ?>
|
||||||
@@ -125,34 +127,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="video-grid category-videos">
|
<div class="video-grid category-videos">
|
||||||
<?php foreach ($videos as $video): ?>
|
<?php foreach ($videos as $video): ?>
|
||||||
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
|
<?php echo renderVideoCard($video); ?>
|
||||||
<div class="video-thumbnail">
|
|
||||||
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
|
|
||||||
<div class="video-play-icon">
|
|
||||||
<i class="fas fa-play-circle"></i>
|
|
||||||
</div>
|
|
||||||
<span class="video-duration"><?php echo formatDuration($video['duration']); ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-info">
|
|
||||||
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
|
|
||||||
<div class="video-channel">
|
|
||||||
<?php if (strpos($video['channelAvatar'], 'default-avatar') !== false || empty($video['channelAvatar'])): ?>
|
|
||||||
<div class="channel-avatar-placeholder">
|
|
||||||
<i class="fas fa-user-circle"></i>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo htmlspecialchars($video['channel']); ?>" class="channel-avatar">
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-metadata">
|
|
||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
|
||||||
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,6 +142,6 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
|||||||
<?php include 'includes/mobile-menu.php'; ?>
|
<?php include 'includes/mobile-menu.php'; ?>
|
||||||
|
|
||||||
<script src="js/main.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ RewriteRule ^(includes|cache|docs|conf)/ - [F,L]
|
|||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Bloquer les artefacts de documentation à la racine web
|
||||||
|
# (DEPLOY.adoc/html/pdf, README.adoc…) : ils divulguent le plan
|
||||||
|
# d'infrastructure et ne doivent jamais être servis s'ils sont copiés
|
||||||
|
# sur l'hébergement. [^/]+ = fichiers à la racine uniquement, les
|
||||||
|
# éventuels .html légitimes en sous-répertoire restent servis.
|
||||||
|
RewriteRule ^[^/]+\.(adoc|html|pdf)$ - [F,L,NC]
|
||||||
|
|
||||||
# Empêcher l'exploration des répertoires
|
# Empêcher l'exploration des répertoires
|
||||||
Options -Indexes
|
Options -Indexes
|
||||||
|
|
||||||
@@ -40,7 +47,9 @@ RewriteCond %{REQUEST_FILENAME} !-f
|
|||||||
RewriteRule ^([^\.]+)$ $1.php [NC,L]
|
RewriteRule ^([^\.]+)$ $1.php [NC,L]
|
||||||
|
|
||||||
# Rediriger les URLs avec .php vers les URLs sans extension
|
# 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 %{THE_REQUEST} /([^.]+)\.php [NC]
|
||||||
|
RewriteCond %{REQUEST_URI} !^/ajax/ [NC]
|
||||||
RewriteRule ^ /%1 [NC,L,R=301]
|
RewriteRule ^ /%1 [NC,L,R=301]
|
||||||
|
|
||||||
# Pour accéder à page.php via /page
|
# Pour accéder à page.php via /page
|
||||||
@@ -55,3 +64,15 @@ RewriteRule ^([^/]+)$ $1.php [L]
|
|||||||
RewriteCond %{HTTP:X-Forwarded-Proto} !https
|
RewriteCond %{HTTP:X-Forwarded-Proto} !https
|
||||||
RewriteCond %{HTTPS} !on
|
RewriteCond %{HTTPS} !on
|
||||||
RewriteRule ^(.*) https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
RewriteRule ^(.*) https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# CACHE
|
||||||
|
# ======================
|
||||||
|
|
||||||
|
# Le Service Worker et le manifest ne doivent pas être cachés longtemps :
|
||||||
|
# le navigateur doit pouvoir détecter les nouvelles versions du site.
|
||||||
|
<FilesMatch "^(sw\.js|site\.webmanifest)$">
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set Cache-Control "no-cache, must-revalidate"
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
|
# ======================
|
||||||
|
# ANNU KUTE CED — Configuration Nginx
|
||||||
|
# ======================
|
||||||
|
# Ce fichier est prévu pour un déploiement en deux temps :
|
||||||
|
# 1. Avant le certificat SSL : le bloc :80 ci-dessous sert le site en HTTP.
|
||||||
|
# C'est aussi le prérequis de `certbot --nginx` (validation sur le port 80).
|
||||||
|
# À ce stade, `nginx -t` doit passer sans aucun certificat.
|
||||||
|
# 2. Après `certbot --nginx` : Certbot crée le bloc 443 (SSL) et la
|
||||||
|
# redirection HTTP → HTTPS automatiquement. Un bloc 443 commenté est
|
||||||
|
# fourni en bas de fichier si vous préférez configurer SSL à la main.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name votre-domaine.com;
|
server_name votre-domaine.com;
|
||||||
return 301 https://$server_name$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name votre-domaine.com;
|
|
||||||
root /path/to/your/site;
|
root /path/to/your/site;
|
||||||
index index.php index.html;
|
index index.php index.html;
|
||||||
|
|
||||||
# SSL Configuration (adaptez selon votre certificat)
|
|
||||||
ssl_certificate /path/to/your/certificate.crt;
|
|
||||||
ssl_certificate_key /path/to/your/private.key;
|
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# SÉCURITÉ
|
# SÉCURITÉ
|
||||||
# ======================
|
# ======================
|
||||||
|
|
||||||
# Bloquer l'accès aux fichiers de configuration
|
# Bloquer l'accès aux fichiers de configuration
|
||||||
location ~* \.(php|inc|conf|config|local)$ {
|
# (les .php de includes/ sont déjà bloqués par le bloc "répertoires
|
||||||
|
# sensibles" ci-dessous ; ne PAS ajouter php ici, sinon toute
|
||||||
|
# l'application serait inaccessible)
|
||||||
|
location ~* \.(inc|conf|config|local)$ {
|
||||||
deny all;
|
deny all;
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,16 @@ server {
|
|||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Bloquer les artefacts de documentation à la racine web
|
||||||
|
# (DEPLOY.adoc/html/pdf, README.adoc…) : ils divulguent le plan
|
||||||
|
# d'infrastructure et ne doivent jamais être servis s'ils sont copiés
|
||||||
|
# sur l'hébergement. [^/]+ = fichiers à la racine uniquement, les
|
||||||
|
# éventuels .html légitimes en sous-répertoire restent servis.
|
||||||
|
location ~* ^/[^/]+\.(adoc|html|pdf)$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
# Bloquer l'accès aux fichiers cachés
|
# Bloquer l'accès aux fichiers cachés
|
||||||
location ~ /\. {
|
location ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
@@ -58,15 +72,23 @@ server {
|
|||||||
rewrite ^/([^.]+)$ /$1.php last;
|
rewrite ^/([^.]+)$ /$1.php last;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Rediriger les URLs avec .php vers les URLs sans extension
|
|
||||||
location ~ ^/(.+)\.php$ {
|
|
||||||
return 301 /$1;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Traitement des fichiers PHP
|
# Traitement des fichiers PHP
|
||||||
|
# $request_uri est la requête ORIGINALE du client (elle ne change pas
|
||||||
|
# lors des réécritures internes) : si elle contient .php, on redirige
|
||||||
|
# proprement vers l'URL sans extension ; sinon (réécriture interne
|
||||||
|
# @rewrite ou directive index), on sert le PHP. Cela évite la boucle
|
||||||
|
# infinie /index → /index.php → /index → …
|
||||||
location ~ \.php$ {
|
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;
|
||||||
|
}
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; # Adaptez selon votre version PHP
|
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; # Adaptez selon votre version PHP
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||||
}
|
}
|
||||||
@@ -75,6 +97,18 @@ server {
|
|||||||
# OPTIMISATIONS
|
# OPTIMISATIONS
|
||||||
# ======================
|
# ======================
|
||||||
|
|
||||||
|
# Le Service Worker et le manifest ne doivent pas être cachés longtemps :
|
||||||
|
# le navigateur doit pouvoir détecter les nouvelles versions du site.
|
||||||
|
# (location exacte = prioritaire sur les regex ci-dessous ; "expires -1"
|
||||||
|
# émet Cache-Control: no-cache sans annuler les add_header de sécurité)
|
||||||
|
location = /sw.js {
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /site.webmanifest {
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
# Cache des fichiers statiques
|
# Cache des fichiers statiques
|
||||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -94,3 +128,31 @@ server {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# HTTPS (port 443) — après obtention du certificat
|
||||||
|
# ======================
|
||||||
|
# `certbot --nginx` génère automatiquement ce bloc à partir du bloc :80,
|
||||||
|
# avec la redirection HTTP → HTTPS. Pour une configuration SSL manuelle,
|
||||||
|
# décommentez et adaptez les chemins des certificats, en reprenant les
|
||||||
|
# mêmes sections SÉCURITÉ, RÉÉCRITURE D'URL, PHP et OPTIMISATIONS que
|
||||||
|
# le bloc :80 ci-dessus :
|
||||||
|
#
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name votre-domaine.com;
|
||||||
|
# root /path/to/your/site;
|
||||||
|
# index index.php index.html;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/votre-domaine.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/votre-domaine.com/privkey.pem;
|
||||||
|
#
|
||||||
|
# # ... reprendre ici les mêmes location que dans le bloc :80 ...
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # Et remplacez alors le contenu du bloc :80 par une simple redirection :
|
||||||
|
# # server {
|
||||||
|
# # listen 80;
|
||||||
|
# # server_name votre-domaine.com;
|
||||||
|
# # return 301 https://$server_name$request_uri;
|
||||||
|
# # }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
require_once 'includes/config.php';
|
require_once 'includes/config.php';
|
||||||
// Inclure les fonctions de sécurité
|
// Inclure les fonctions de sécurité
|
||||||
require_once 'includes/security.php';
|
require_once 'includes/security.php';
|
||||||
|
// Inclure les fonctions de données structurées (getBaseUrl, getCurrentUrl)
|
||||||
|
require_once 'includes/structured-data.php';
|
||||||
// Appliquer les en-têtes de sécurité
|
// Appliquer les en-têtes de sécurité
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
?>
|
?>
|
||||||
@@ -13,10 +15,12 @@ setSecurityHeaders();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||||
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
|
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
|
||||||
|
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?> Ouverture prochaine.">
|
||||||
|
<link rel="canonical" href="<?php echo getBaseUrl() . '/countdown.php'; ?>">
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>">
|
<link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -36,12 +40,10 @@ setSecurityHeaders();
|
|||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
|
<meta property="og:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
|
||||||
<meta property="og:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
|
<meta property="og:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
|
||||||
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
|
echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE));
|
||||||
setlocale(LC_TIME, 'fr_FR.UTF-8');
|
|
||||||
echo strftime('%e %B %Y', $targetDate->getTimestamp());
|
|
||||||
?>. Restez connectés !">
|
?>. Restez connectés !">
|
||||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:locale" content="fr_FR">
|
<meta property="og:locale" content="fr_FR">
|
||||||
@@ -50,15 +52,19 @@ setSecurityHeaders();
|
|||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
|
<meta name="twitter:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
|
||||||
<meta name="twitter:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
|
<meta name="twitter:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
|
||||||
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
|
echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE));
|
||||||
setlocale(LC_TIME, 'fr_FR.UTF-8');
|
|
||||||
echo strftime('%e %B %Y', $targetDate->getTimestamp());
|
|
||||||
?>. Restez connectés !">
|
?>. Restez connectés !">
|
||||||
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
|
|
||||||
<!-- Configuration JavaScript -->
|
<!-- Configuration JavaScript : date cible en ISO 8601 avec fuseau horaire -->
|
||||||
<script nonce="<?php echo getCspNonce(); ?>">
|
<script nonce="<?php echo getCspNonce(); ?>">
|
||||||
const COUNTDOWN_TARGET_DATE = '<?php echo defined('COUNTDOWN_TARGET_DATE') ? COUNTDOWN_TARGET_DATE : '2025-10-11 00:00:00'; ?>';
|
const COUNTDOWN_TARGET_DATE = '<?php
|
||||||
|
$countdownTargetDate = new DateTime(
|
||||||
|
defined('COUNTDOWN_TARGET_DATE') ? COUNTDOWN_TARGET_DATE : '2025-10-11 00:00:00',
|
||||||
|
new DateTimeZone(DEFAULT_TIMEZONE)
|
||||||
|
);
|
||||||
|
echo $countdownTargetDate->format(DateTime::ATOM);
|
||||||
|
?>';
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -96,9 +102,7 @@ setSecurityHeaders();
|
|||||||
<!-- Message -->
|
<!-- Message -->
|
||||||
<div class="countdown-message">
|
<div class="countdown-message">
|
||||||
<p>La plateforme ouvrira ses portes le <strong><?php
|
<p>La plateforme ouvrira ses portes le <strong><?php
|
||||||
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
|
echo formatDateFr(new DateTime(COUNTDOWN_TARGET_DATE), true);
|
||||||
setlocale(LC_TIME, 'fr_FR.UTF-8');
|
|
||||||
echo strftime('%e %B %Y à %H:%M', $targetDate->getTimestamp());
|
|
||||||
?></strong> (heure de La Réunion).</p>
|
?></strong> (heure de La Réunion).</p>
|
||||||
|
|
||||||
<!-- Heures des autres territoires -->
|
<!-- Heures des autres territoires -->
|
||||||
@@ -183,5 +187,6 @@ setSecurityHeaders();
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="js/countdown.js"></script>
|
<script src="js/countdown.js"></script>
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -82,6 +82,45 @@
|
|||||||
text-align: center;
|
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 */
|
/* Interface de don */
|
||||||
.donation-interface {
|
.donation-interface {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
.search-results-count {
|
.search-results-count {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Masquage d'éléments (remplace les attributs style="display: none;" bloqués par la CSP) */
|
||||||
|
.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-red: #FF0000;
|
--primary-red: #FF0000;
|
||||||
--primary-green: #008000;
|
--primary-green: #008000;
|
||||||
@@ -3167,7 +3172,8 @@ i.icon-mastodon,
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #f9f9f9;
|
background-color: var(--tag-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3412,3 +3418,201 @@ i.icon-mastodon,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Modal de mise à jour PWA (js/pwa-update.js)
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
.pwa-update-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-overlay.pwa-update-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-modal {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
text-align: center;
|
||||||
|
transform: translateY(12px) scale(0.97);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-visible .pwa-update-modal {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-text {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn-primary {
|
||||||
|
background-color: var(--primary-red);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn-primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn-secondary {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn-secondary:hover:not(:disabled) {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-red);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.pwa-update-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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.35rem;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pages d'erreur (404.php / 500.php) */
|
||||||
|
.error-page {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page .error-code {
|
||||||
|
font-size: 4.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--primary-red);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page h1 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page > p:not(.error-code):not(.error-actions) {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page .error-home-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 25px;
|
||||||
|
background-color: var(--primary-red);
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page .error-home-link:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inclure le convertisseur Markdown
|
||||||
|
require_once 'includes/lib/markdown.php';
|
||||||
// Inclure les fonctions de données structurées
|
// Inclure les fonctions de données structurées
|
||||||
require_once 'includes/structured-data.php';
|
require_once 'includes/structured-data.php';
|
||||||
// Appliquer les en-têtes de sécurité
|
// Appliquer les en-têtes de sécurité
|
||||||
@@ -23,8 +25,10 @@ $liveStream = getLiveStream();
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Direct - <?php echo SITE_NAME; ?></title>
|
<title>Direct - <?php echo SITE_NAME; ?></title>
|
||||||
|
<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="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -32,7 +36,7 @@ $liveStream = getLiveStream();
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
||||||
<link rel="manifest" href="site.webmanifest">
|
<link rel="manifest" href="site.webmanifest">
|
||||||
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#FF0000">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="Direct - <?php echo SITE_NAME; ?>">
|
<meta property="og:title" content="Direct - <?php echo SITE_NAME; ?>">
|
||||||
@@ -125,7 +129,7 @@ $liveStream = getLiveStream();
|
|||||||
</div>
|
</div>
|
||||||
<div class="live-player">
|
<div class="live-player">
|
||||||
<iframe
|
<iframe
|
||||||
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1"
|
src="<?php echo e(PEERTUBE_URL . '/videos/embed/' . $liveStream['id'] . '?autoplay=1'); ?>"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen="allowfullscreen"
|
allowfullscreen="allowfullscreen"
|
||||||
allow="autoplay; fullscreen"
|
allow="autoplay; fullscreen"
|
||||||
@@ -140,9 +144,9 @@ $liveStream = getLiveStream();
|
|||||||
<i class="fas fa-user-circle"></i>
|
<i class="fas fa-user-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="<?php echo $liveStream['channel']; ?>" class="channel-avatar">
|
<img src="<?php echo e($liveStream['channelAvatar']); ?>" alt="<?php echo e($liveStream['channel']); ?>" class="channel-avatar">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="channel-name"><?php echo $liveStream['channel']; ?></span>
|
<span class="channel-name"><?php echo e($liveStream['channel']); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($liveStream['description'])): ?>
|
<?php if (!empty($liveStream['description'])): ?>
|
||||||
<div class="live-description">
|
<div class="live-description">
|
||||||
@@ -156,111 +160,14 @@ $liveStream = getLiveStream();
|
|||||||
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
|
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
|
||||||
|
|
||||||
if ($showNextLiveAnnouncement) {
|
if ($showNextLiveAnnouncement) {
|
||||||
// Afficher l'annonce du prochain live
|
// Afficher l'annonce du prochain live (partial partagé avec hero-section.php)
|
||||||
// Définir l'image de fond si disponible
|
$nextLiveVariant = 'page';
|
||||||
$bgImageStyle = '';
|
include 'includes/partials/next-live.php';
|
||||||
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
|
|
||||||
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<div class="next-live-announcement" style="<?php echo $bgImageStyle; ?>" nonce="<?php echo getCspNonce(); ?>">
|
|
||||||
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
|
|
||||||
<div class="next-live-image-container">
|
|
||||||
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
|
|
||||||
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
|
|
||||||
class="next-live-image">
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div class="next-live-content">
|
|
||||||
<i class="fas fa-calendar-alt"></i>
|
|
||||||
<?php
|
|
||||||
if (!empty(NEXT_LIVE_DATE)) {
|
|
||||||
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
|
|
||||||
$dayFormatter = new IntlDateFormatter(
|
|
||||||
'fr_FR',
|
|
||||||
IntlDateFormatter::FULL,
|
|
||||||
IntlDateFormatter::NONE,
|
|
||||||
DEFAULT_TIMEZONE,
|
|
||||||
IntlDateFormatter::GREGORIAN,
|
|
||||||
'EEEE d MMMM'
|
|
||||||
);
|
|
||||||
$formattedDay = $dayFormatter->format($liveDate);
|
|
||||||
$formattedDay = ucfirst($formattedDay);
|
|
||||||
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
|
|
||||||
} else {
|
|
||||||
$dynamicTitle = NEXT_LIVE_TITLE;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<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="next-live-datetime">
|
|
||||||
<p class="next-live-date">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
<?php
|
|
||||||
$formatter = new IntlDateFormatter(
|
|
||||||
'fr_FR',
|
|
||||||
IntlDateFormatter::FULL,
|
|
||||||
IntlDateFormatter::SHORT,
|
|
||||||
DEFAULT_TIMEZONE
|
|
||||||
);
|
|
||||||
echo $formatter->format($liveDate);
|
|
||||||
|
|
||||||
$offset = $liveDate->format('P');
|
|
||||||
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Autres fuseaux horaires -->
|
|
||||||
<div class="next-live-timezones">
|
|
||||||
<?php
|
|
||||||
// Ordre croissant : du plus en retard au plus en avance
|
|
||||||
$timezones = [
|
|
||||||
'Ma\'ohi Nui' => 'Pacific/Tahiti',
|
|
||||||
'Martinique / Guadeloupe' => 'America/Martinique',
|
|
||||||
'Guyane' => 'America/Cayenne',
|
|
||||||
'France' => 'Europe/Paris',
|
|
||||||
'Kanaky' => 'Pacific/Noumea'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach($timezones as $name => $timezone):
|
|
||||||
$liveDateLocal = clone $liveDate;
|
|
||||||
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
|
|
||||||
|
|
||||||
// Vérifier si c'est un jour différent
|
|
||||||
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
|
|
||||||
|
|
||||||
$dayIndicator = '';
|
|
||||||
if ($dayDiff > 0) {
|
|
||||||
$dayIndicator = ' <span class="day-shift">+1j</span>';
|
|
||||||
} elseif ($dayDiff < 0) {
|
|
||||||
$dayIndicator = ' <span class="day-shift">-1j</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<span class="timezone-item">
|
|
||||||
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
} else {
|
} else {
|
||||||
?>
|
?>
|
||||||
<div class="no-live-message">
|
<div class="no-live-message">
|
||||||
<i class="fas fa-tv"></i>
|
<i class="fas fa-tv"></i>
|
||||||
<h2>Aucun direct en cours</h2>
|
<h1>Aucun direct en cours</h1>
|
||||||
<p>Revenez plus tard pour découvrir nos prochaines diffusions en direct.</p>
|
<p>Revenez plus tard pour découvrir nos prochaines diffusions en direct.</p>
|
||||||
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
|
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,5 +181,6 @@ $liveStream = getLiveStream();
|
|||||||
<?php include 'includes/footer.php'; ?>
|
<?php include 'includes/footer.php'; ?>
|
||||||
<?php include 'includes/mobile-menu.php'; ?>
|
<?php include 'includes/mobile-menu.php'; ?>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!-- Injected by Asciidoctor when generating the print/PDF HTML variant.
|
||||||
|
Usage: asciidoctor -a docinfodir=docs -a docinfo=shared README.adoc -o README-print.html
|
||||||
|
Fixes color-emoji rendering in headless Chromium and adapts the layout for print. -->
|
||||||
|
<style>
|
||||||
|
/* Font stacks with an explicit emoji fallback. "Noto Color Emoji" must be
|
||||||
|
installed system-wide (package: fonts-noto-color-emoji). */
|
||||||
|
* {
|
||||||
|
font-family: "Noto Sans", "Noto Color Emoji", sans-serif !important;
|
||||||
|
}
|
||||||
|
pre, code, kbd, samp {
|
||||||
|
font-family: "Noto Sans Mono", "Noto Color Emoji", monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* The left-floating TOC is screen-only; bring it back into the flow */
|
||||||
|
#header, #toc {
|
||||||
|
position: static !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
float: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
#content {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
/* Keep headings with their content, break tables between rows only */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
tr, td, th, .admonitionblock, .imageblock {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
/* Long code listings may overflow the page width: allow wrapping */
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
/* Links are not clickable in a PDF: keep them readable, not blue */
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a[href^="http"]::after {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate the HTML/PDF export(s) from any .adoc document (README.adoc by default).
|
||||||
|
#
|
||||||
|
# docs/generate-readme-pdf.sh # README.html + README.pdf
|
||||||
|
# docs/generate-readme-pdf.sh --html # README.html only
|
||||||
|
# docs/generate-readme-pdf.sh --pdf # README.pdf only
|
||||||
|
# docs/generate-readme-pdf.sh DEPLOY.adoc # DEPLOY.html + DEPLOY.pdf
|
||||||
|
# docs/generate-readme-pdf.sh DEPLOY.adoc --pdf # DEPLOY.pdf only
|
||||||
|
#
|
||||||
|
# Outputs land in the project root (named after the source) and are Git-ignored.
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - asciidoctor (e.g. gem install asciidoctor)
|
||||||
|
# - chromium (headless PDF rendering)
|
||||||
|
# - fonts-noto-color-emoji (color emoji in the PDF)
|
||||||
|
#
|
||||||
|
# Why Chromium instead of asciidoctor-pdf (used by the VSCodium extension)?
|
||||||
|
# Its Prawn engine cannot embed color-emoji fonts (CBDT/COLR), so emoji
|
||||||
|
# disappear. Chromium handles them fine as long as "Noto Color Emoji"
|
||||||
|
# is installed. The docs/docinfo.html stylesheet (emoji fallback + print
|
||||||
|
# rules) is injected only for this build, via Asciidoctor's docinfo
|
||||||
|
# mechanism, and adapts the layout for print (TOC in flow, table/page
|
||||||
|
# breaks, wrapped code listings).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|
||||||
|
SRC="README.adoc"
|
||||||
|
MODE="--all"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--html|--pdf|--all)
|
||||||
|
MODE="$arg"
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,12p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*.adoc)
|
||||||
|
SRC="$arg"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [fichier.adoc] [--html|--pdf|--all]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC" ]]; then
|
||||||
|
echo "Erreur : fichier introuvable : $SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE="$(basename "$SRC" .adoc)"
|
||||||
|
HTML_OUT="$BASE.html"
|
||||||
|
PDF_OUT="$BASE.pdf"
|
||||||
|
|
||||||
|
build_html() {
|
||||||
|
asciidoctor -b html5 -a docinfodir=docs -a docinfo=shared "$SRC" -o "$1"
|
||||||
|
echo "HTML generated: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_pdf() {
|
||||||
|
# Resolve to an absolute path for the file:// URL
|
||||||
|
local html_path="$1"
|
||||||
|
[[ "$html_path" != /* ]] && html_path="$PWD/$html_path"
|
||||||
|
chromium --headless --disable-gpu --no-sandbox \
|
||||||
|
--no-pdf-header-footer \
|
||||||
|
--print-to-pdf="$PDF_OUT" \
|
||||||
|
"file://$html_path"
|
||||||
|
echo "PDF generated: $PDF_OUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
--html)
|
||||||
|
build_html "$HTML_OUT"
|
||||||
|
;;
|
||||||
|
--pdf)
|
||||||
|
TMP_HTML="$(mktemp --suffix=.html)"
|
||||||
|
trap 'rm -f "$TMP_HTML"' EXIT
|
||||||
|
build_html "$TMP_HTML" >/dev/null
|
||||||
|
build_pdf "$TMP_HTML"
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
build_html "$HTML_OUT"
|
||||||
|
build_pdf "$HTML_OUT"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Page de dons - Template d'exemple
|
* Page de dons - Template d'exemple pour ANNU KUTE CED
|
||||||
* Support via LiberaPay, Ko-fi et Stripe
|
* Support via LiberaPay, Ko-fi et Stripe
|
||||||
*
|
*
|
||||||
* Pour personnaliser cette page :
|
* Pour personnaliser cette page :
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
require_once 'includes/config.php';
|
require_once 'includes/config.php';
|
||||||
require_once 'includes/security.php';
|
require_once 'includes/security.php';
|
||||||
|
require_once 'includes/structured-data.php';
|
||||||
|
|
||||||
// Vérifier si les dons sont activés
|
// Vérifier si les dons sont activés
|
||||||
if (!defined('DONATIONS_ENABLED') || !DONATIONS_ENABLED) {
|
if (!defined('DONATIONS_ENABLED') || !DONATIONS_ENABLED) {
|
||||||
@@ -48,6 +49,24 @@ $currencySymbol = $currency === 'EUR' ? '€' : '$';
|
|||||||
$stripeOneTimeLinks = defined('STRIPE_ONE_TIME_LINKS') ? STRIPE_ONE_TIME_LINKS : [];
|
$stripeOneTimeLinks = defined('STRIPE_ONE_TIME_LINKS') ? STRIPE_ONE_TIME_LINKS : [];
|
||||||
$stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
@@ -58,12 +77,13 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
|||||||
|
|
||||||
<!-- PERSONNALISEZ: Titre et description de votre page de dons -->
|
<!-- PERSONNALISEZ: Titre et description de votre page de dons -->
|
||||||
<title>Soutenir <?php echo ORGANIZATION_NAME; ?> - Dons</title>
|
<title>Soutenir <?php echo ORGANIZATION_NAME; ?> - Dons</title>
|
||||||
<meta name="description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don. Chaque contribution compte pour maintenir notre plateforme indépendante.">
|
<meta name="description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don. Chaque contribution compte pour maintenir le hub multimédia du podcast.">
|
||||||
|
<link rel="canonical" href="<?php echo getBaseUrl() . '/dons.php'; ?>">
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="css/donations.css?v=<?php echo filemtime('css/donations.css'); ?>">
|
<link rel="stylesheet" href="css/donations.css?v=<?php echo filemtime('css/donations.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -75,28 +95,35 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
|||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="Soutenir <?php echo ORGANIZATION_NAME; ?>">
|
<meta property="og:title" content="Soutenir <?php echo ORGANIZATION_NAME; ?>">
|
||||||
<meta property="og:description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don et aidez-nous à maintenir notre plateforme indépendante.">
|
<meta property="og:description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don et aidez-nous à maintenir le hub multimédia du podcast.">
|
||||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/dons.php'; ?>">
|
<meta property="og:url" content="<?php echo getBaseUrl() . '/dons.php'; ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
|
<meta property="og:locale" content="fr_FR">
|
||||||
|
|
||||||
|
<!-- Twitter Card Meta Tags -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Soutenir <?php echo ORGANIZATION_NAME; ?>">
|
||||||
|
<meta name="twitter:description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don et aidez-nous à maintenir le hub multimédia du podcast.">
|
||||||
|
<meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
|
|
||||||
<!-- Schema.org pour les dons -->
|
<!-- Schema.org pour les dons -->
|
||||||
<script type="application/ld+json">
|
<?php
|
||||||
{
|
outputJsonLd(json_encode([
|
||||||
"@context": "https://schema.org",
|
"@context" => "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type" => "Organization",
|
||||||
"name": "<?php echo SITE_NAME; ?>",
|
"name" => SITE_NAME,
|
||||||
"description": "<?php echo SITE_DESCRIPTION; ?>",
|
"description" => SITE_DESCRIPTION,
|
||||||
"url": "<?php echo 'https://' . $_SERVER['HTTP_HOST']; ?>",
|
"url" => getBaseUrl(),
|
||||||
"potentialAction": {
|
"potentialAction" => [
|
||||||
"@type": "DonateAction"
|
"@type" => "DonateAction"
|
||||||
}
|
]
|
||||||
}
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
</script>
|
?>
|
||||||
|
|
||||||
<!-- Script pour éviter le flash en mode sombre -->
|
<!-- Script pour éviter le flash en mode sombre -->
|
||||||
<script>
|
<script nonce="<?php echo getCspNonce(); ?>">
|
||||||
(function() {
|
(function() {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
@@ -118,24 +145,31 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
|||||||
<section class="donation-hero">
|
<section class="donation-hero">
|
||||||
<div class="donation-hero-content">
|
<div class="donation-hero-content">
|
||||||
<h1><i class="fas fa-heart"></i> Soutenir <?php echo ORGANIZATION_NAME; ?></h1>
|
<h1><i class="fas fa-heart"></i> Soutenir <?php echo ORGANIZATION_NAME; ?></h1>
|
||||||
<p class="hero-subtitle">Votre soutien est essentiel pour maintenir notre plateforme indépendante</p>
|
<p class="hero-subtitle">Votre soutien est essentiel pour maintenir le hub multimédia du podcast</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Section principale de don -->
|
<!-- Section principale de don -->
|
||||||
<section class="donation-main">
|
<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-content">
|
||||||
<div class="donation-message">
|
<div class="donation-message">
|
||||||
<!-- PERSONNALISEZ: Votre message de don -->
|
<!-- PERSONNALISEZ: Votre message de don -->
|
||||||
<h2>Pourquoi nous soutenir ?</h2>
|
<h2>Pourquoi nous soutenir ?</h2>
|
||||||
<p><?php echo SITE_NAME; ?> est une plateforme multimédia indépendante.
|
<p><?php echo SITE_NAME; ?> est le hub multimédia du podcast ANNU KUTE CED.
|
||||||
Vos dons nous permettent de :</p>
|
Vos dons nous permettent de :</p>
|
||||||
<ul>
|
<ul>
|
||||||
<!-- PERSONNALISEZ: Vos objectifs -->
|
<!-- PERSONNALISEZ: Vos objectifs -->
|
||||||
<li><i class="fas fa-server"></i> Maintenir nos serveurs et notre infrastructure</li>
|
<li><i class="fas fa-server"></i> Maintenir nos serveurs et notre infrastructure</li>
|
||||||
<li><i class="fas fa-shield-alt"></i> Préserver notre indépendance et notre souveraineté numérique</li>
|
<li><i class="fas fa-microphone-alt"></i> Poursuivre la production et la diffusion du podcast</li>
|
||||||
<li><i class="fas fa-tools"></i> Développer de nouvelles fonctionnalités</li>
|
<li><i class="fas fa-tools"></i> Développer de nouvelles fonctionnalités pour le hub</li>
|
||||||
<li><i class="fas fa-users"></i> Soutenir la création de contenu libre et accessible</li>
|
<li><i class="fas fa-users"></i> Soutenir la création de contenu libre et accessible sur le Fédivers</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Chaque don, même petit, fait la différence !</strong></p>
|
<p><strong>Chaque don, même petit, fait la différence !</strong></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,10 +223,10 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
|||||||
|
|
||||||
<!-- Onglets don ponctuel / mensuel -->
|
<!-- Onglets don ponctuel / mensuel -->
|
||||||
<div class="donation-tabs">
|
<div class="donation-tabs">
|
||||||
<button class="tab-btn active" onclick="switchTab('onetime')">
|
<button class="tab-btn active" data-tab="onetime">
|
||||||
<i class="fas fa-hand-holding-heart"></i> Don ponctuel
|
<i class="fas fa-hand-holding-heart"></i> Don ponctuel
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" onclick="switchTab('monthly')">
|
<button class="tab-btn" data-tab="monthly">
|
||||||
<i class="fas fa-sync-alt"></i> Don mensuel
|
<i class="fas fa-sync-alt"></i> Don mensuel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,14 +290,14 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
|||||||
<p>Maintenir nos serveurs, notre plateforme et nos outils numériques représente des coûts mensuels importants.</p>
|
<p>Maintenir nos serveurs, notre plateforme et nos outils numériques représente des coûts mensuels importants.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<i class="fas fa-tools"></i>
|
<i class="fas fa-microphone-alt"></i>
|
||||||
<h4>Maintenance & Développement</h4>
|
<h4>Production du podcast</h4>
|
||||||
<p>Assurer la sécurité, les mises à jour et l'évolution de nos outils technologiques demande un investissement constant.</p>
|
<p>Vos contributions nous aident à assurer la régularité et la qualité des épisodes d'ANNU KUTE CED.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<i class="fas fa-shield-alt"></i>
|
<i class="fas fa-shield-alt"></i>
|
||||||
<h4>Indépendance Numérique</h4>
|
<h4>Indépendance Numérique</h4>
|
||||||
<p>Vos dons nous permettent de rester indépendants des plateformes commerciales et de préserver notre souveraineté numérique.</p>
|
<p>Vos dons nous permettent de rester indépendants des plateformes commerciales et de préserver notre souveraineté numérique sur le Fédivers.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -274,22 +308,25 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
|||||||
<?php include 'includes/mobile-menu.php'; ?>
|
<?php include 'includes/mobile-menu.php'; ?>
|
||||||
|
|
||||||
<!-- Script pour les onglets -->
|
<!-- Script pour les onglets -->
|
||||||
<script>
|
<script nonce="<?php echo getCspNonce(); ?>">
|
||||||
function switchTab(tab) {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
// Cacher tous les contenus et désactiver tous les boutons
|
btn.addEventListener('click', function() {
|
||||||
document.querySelectorAll('.tab-content').forEach(content => {
|
// Cacher tous les contenus et désactiver tous les boutons
|
||||||
content.classList.remove('active');
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
});
|
content.classList.remove('active');
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
});
|
||||||
btn.classList.remove('active');
|
document.querySelectorAll('.tab-btn').forEach(otherBtn => {
|
||||||
});
|
otherBtn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
// Afficher le contenu sélectionné et activer le bouton
|
// Afficher le contenu sélectionné et activer le bouton
|
||||||
document.getElementById(tab + '-tab').classList.add('active');
|
document.getElementById(this.dataset.tab + '-tab').classList.add('active');
|
||||||
event.target.classList.add('active');
|
this.classList.add('active');
|
||||||
}
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 767 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 377 KiB |
@@ -1,22 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration par défaut de FEDIVERSE OKI
|
* Configuration par défaut d'ANNU KUTE CED
|
||||||
*
|
*
|
||||||
* Ce fichier contient les paramètres de configuration par défaut.
|
* Ce fichier contient les paramètres de configuration par défaut.
|
||||||
* Il est utilisé pour initialiser les variables non définies dans config.local.php.
|
* Il est utilisé pour initialiser les variables non définies dans config.local.php.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (!defined('APP_HOST_NAME')) define('APP_HOST_NAME', 'fediverse.o-k-i.net');
|
if (!defined('APP_HOST_NAME')) define('APP_HOST_NAME', 'example.com');
|
||||||
|
|
||||||
if (!defined('ORGANIZATION_SHORT_NAME')) define('ORGANIZATION_SHORT_NAME', 'OKI');
|
if (!defined('ORGANIZATION_SHORT_NAME')) define('ORGANIZATION_SHORT_NAME', 'ANNU KUTE CED');
|
||||||
if (!defined('ORGANIZATION_NAME')) define('ORGANIZATION_NAME', 'ORGANISATION KA INTERNATIONALE');
|
if (!defined('ORGANIZATION_NAME')) define('ORGANIZATION_NAME', 'ANNU KUTE CED');
|
||||||
|
|
||||||
// Configuration de base - ces valeurs seront utilisées si elles ne sont pas définies dans config.local.php
|
// Configuration de base - ces valeurs seront utilisées si elles ne sont pas définies dans config.local.php
|
||||||
if (!defined('PEERTUBE_URL')) define('PEERTUBE_URL', 'https://gade.o-k-i.net');
|
if (!defined('PEERTUBE_URL')) define('PEERTUBE_URL', 'https://gade.o-k-i.net');
|
||||||
if (!defined('PEERTUBE_DISPLAY_NAME')) define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');
|
if (!defined('PEERTUBE_DISPLAY_NAME')) define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');
|
||||||
if (!defined('API_KEY')) define('API_KEY', '');
|
if (!defined('API_KEY')) define('API_KEY', '');
|
||||||
if (!defined('TAG_INDEPENDENCE')) define('TAG_INDEPENDENCE', 'indépendance');
|
|
||||||
if (!defined('SHORTS_MAX_DURATION')) define('SHORTS_MAX_DURATION', 180); // 3 minutes max pour les shorts
|
if (!defined('SHORTS_MAX_DURATION')) define('SHORTS_MAX_DURATION', 180); // 3 minutes max pour les shorts
|
||||||
|
|
||||||
// Pagination et affichage
|
// Pagination et affichage
|
||||||
@@ -27,7 +26,6 @@ if (!defined('RECENT_VIDEOS_COUNT')) define('RECENT_VIDEOS_COUNT', 6);
|
|||||||
if (!defined('SHORTS_COUNT')) define('SHORTS_COUNT', 6);
|
if (!defined('SHORTS_COUNT')) define('SHORTS_COUNT', 6);
|
||||||
if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100);
|
if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100);
|
||||||
if (!defined('TRENDING_VIDEOS_COUNT')) define('TRENDING_VIDEOS_COUNT', 6);
|
if (!defined('TRENDING_VIDEOS_COUNT')) define('TRENDING_VIDEOS_COUNT', 6);
|
||||||
if (!defined('INDEPENDENCE_VIDEOS_COUNT')) define('INDEPENDENCE_VIDEOS_COUNT', 6);
|
|
||||||
if (!defined('CATEGORY_VIDEOS_COUNT')) define('CATEGORY_VIDEOS_COUNT', 6);
|
if (!defined('CATEGORY_VIDEOS_COUNT')) define('CATEGORY_VIDEOS_COUNT', 6);
|
||||||
if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6);
|
if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6);
|
||||||
|
|
||||||
@@ -38,11 +36,8 @@ if (!defined('SHOW_VIDEO_VIEWS')) define('SHOW_VIDEO_VIEWS', false); // Masquer
|
|||||||
// format: [ID catégorie => Nom personnalisé]
|
// format: [ID catégorie => Nom personnalisé]
|
||||||
if (!defined('PRIORITY_CATEGORIES')) {
|
if (!defined('PRIORITY_CATEGORIES')) {
|
||||||
define('PRIORITY_CATEGORIES', [
|
define('PRIORITY_CATEGORIES', [
|
||||||
11 => 'Actualités & Politique',
|
10 => 'Divertissement',
|
||||||
15 => 'Science et Technologie',
|
15 => 'Science et Technologie'
|
||||||
4 => 'Art',
|
|
||||||
9 => 'Humour',
|
|
||||||
10 => 'Divertissement'
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +56,8 @@ if (!defined('MASTODON_MAX_POST_SHOW')) define('MASTODON_MAX_POST_SHOW', '10');
|
|||||||
// if (!defined('MASTODON_S3_MEDIA_URL')) define('MASTODON_S3_MEDIA_URL', 'https://s3.eu-central-003.backblazeb2.com');
|
// if (!defined('MASTODON_S3_MEDIA_URL')) define('MASTODON_S3_MEDIA_URL', 'https://s3.eu-central-003.backblazeb2.com');
|
||||||
|
|
||||||
// Informations du site
|
// Informations du site
|
||||||
if (!defined('SITE_NAME')) define('SITE_NAME', 'FEDIVERSE OKI');
|
if (!defined('SITE_NAME')) define('SITE_NAME', 'ANNU KUTE CED');
|
||||||
if (!defined('SITE_DESCRIPTION')) define('SITE_DESCRIPTION', 'Plateforme multimedia indépendante');
|
if (!defined('SITE_DESCRIPTION')) define('SITE_DESCRIPTION', 'Hub multimédia du podcast ANNU KUTE CED');
|
||||||
if (!defined('SITE_LOGO')) define('SITE_LOGO', 'img/logo.png');
|
if (!defined('SITE_LOGO')) define('SITE_LOGO', 'img/logo.png');
|
||||||
if (!defined('SITE_FAVICON')) define('SITE_FAVICON', 'img/favicon.png');
|
if (!defined('SITE_FAVICON')) define('SITE_FAVICON', 'img/favicon.png');
|
||||||
|
|
||||||
@@ -76,13 +71,13 @@ if (!defined('X_URL')) define('X_URL', '#');
|
|||||||
if (!defined('INSTAGRAM_URL')) define('INSTAGRAM_URL', '#');
|
if (!defined('INSTAGRAM_URL')) define('INSTAGRAM_URL', '#');
|
||||||
if (!defined('YOUTUBE_URL')) define('YOUTUBE_URL', '#');
|
if (!defined('YOUTUBE_URL')) define('YOUTUBE_URL', '#');
|
||||||
if (!defined('TIKTOK_URL')) define('TIKTOK_URL', '#');
|
if (!defined('TIKTOK_URL')) define('TIKTOK_URL', '#');
|
||||||
if (!defined('MASTODON_URL')) define('MASTODON_URL', 'https://bokante.o-k-i.net/@admin');
|
if (!defined('MASTODON_URL')) define('MASTODON_URL', 'https://bokante.o-k-i.net/@cedric');
|
||||||
|
|
||||||
// Contacts
|
// Contacts
|
||||||
if (!defined('CONTACT_EMAIL')) define('CONTACT_EMAIL', 'kontak.o-k-i.net');
|
if (!defined('CONTACT_EMAIL')) define('CONTACT_EMAIL', 'kontak@o-k-i.net');
|
||||||
|
|
||||||
// Mentions légales
|
// Mentions légales
|
||||||
if (!defined('LEGAL_COPYRIGHT')) define('LEGAL_COPYRIGHT', 'OKI');
|
if (!defined('LEGAL_COPYRIGHT')) define('LEGAL_COPYRIGHT', 'ANNU KUTE CED');
|
||||||
if (!defined('LEGAL_WEBMASTER_NAME')) define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
|
if (!defined('LEGAL_WEBMASTER_NAME')) define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
|
||||||
if (!defined('LEGAL_WEBMASTER_EMAIL')) define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
|
if (!defined('LEGAL_WEBMASTER_EMAIL')) define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
|
||||||
if (!defined('LEGAL_HOST_NAME')) define('LEGAL_HOST_NAME', 'o2Switch');
|
if (!defined('LEGAL_HOST_NAME')) define('LEGAL_HOST_NAME', 'o2Switch');
|
||||||
@@ -92,30 +87,42 @@ if (!defined('LEGAL_HOST_ADDRESS')) define('LEGAL_HOST_ADDRESS', '222 boulevard
|
|||||||
if (!defined('LEGAL_CONTACT_EMAIL')) define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
|
if (!defined('LEGAL_CONTACT_EMAIL')) define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
|
||||||
if (!defined('LEGAL_LICENSE')) define('LEGAL_LICENSE', 'GNU Affero General Public License version 3 (AGPL-V3)');
|
if (!defined('LEGAL_LICENSE')) define('LEGAL_LICENSE', 'GNU Affero General Public License version 3 (AGPL-V3)');
|
||||||
if (!defined('LEGAL_LICENSE_URL')) define('LEGAL_LICENSE_URL', 'https://www.gnu.org/licenses/agpl-3.0.html');
|
if (!defined('LEGAL_LICENSE_URL')) define('LEGAL_LICENSE_URL', 'https://www.gnu.org/licenses/agpl-3.0.html');
|
||||||
if (!defined('LEGAL_SOURCE_CODE_URL')) define('LEGAL_SOURCE_CODE_URL', 'https://codeberg.org/OKI/fediverse.o-k-i.net');
|
if (!defined('LEGAL_SOURCE_CODE_URL')) define('LEGAL_SOURCE_CODE_URL', 'https://labola.o-k-i.net/cedric/annu-kute-ced');
|
||||||
if (!defined('LEGAL_SERVICE_DESCRIPTION')) define('LEGAL_SERVICE_DESCRIPTION', 'est une plateforme multimédia indépendante.');
|
if (!defined('LEGAL_SERVICE_DESCRIPTION')) define('LEGAL_SERVICE_DESCRIPTION', 'est le hub multimédia du podcast ANNU KUTE CED.');
|
||||||
|
|
||||||
// Fonctionnalités
|
// Fonctionnalités
|
||||||
define('ENABLE_SEARCH', true);
|
define('ENABLE_SEARCH', true);
|
||||||
if (!defined('ENABLE_USER_ACCOUNTS')) define('ENABLE_USER_ACCOUNTS', false);
|
if (!defined('ENABLE_USER_ACCOUNTS')) define('ENABLE_USER_ACCOUNTS', false);
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
if (!defined('CACHE_ENABLED')) define('CACHE_ENABLED', false);
|
// Activé par défaut : indispensable pour Castopod (rate limit strict sur les
|
||||||
|
// flux RSS) et Funkwhale. Sans cache, une requête part à chaque chargement.
|
||||||
|
if (!defined('CACHE_ENABLED')) define('CACHE_ENABLED', true);
|
||||||
if (!defined('CACHE_DURATION')) define('CACHE_DURATION', 3600); // En secondes (1 heure)
|
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)
|
// Configuration de la section Hero (bannière d'accueil)
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Type de contenu à afficher dans la section hero
|
// Type de contenu à afficher dans la section hero
|
||||||
// Options: 'live' (direct PeerTube), 'playlist' (playlist audio/vidéo), 'video' (vidéo unique), 'none' (masquer)
|
// Options: 'live' (direct PeerTube), 'playlist' (playlist audio/vidéo), 'video' (vidéo unique), 'none' (masquer)
|
||||||
if (!defined('HERO_TYPE')) define('HERO_TYPE', 'live');
|
if (!defined('HERO_TYPE')) define('HERO_TYPE', 'video');
|
||||||
|
|
||||||
// Configuration pour le direct (HERO_TYPE = 'live')
|
// Configuration pour le direct (HERO_TYPE = 'live')
|
||||||
if (!defined('LIVE_ACCOUNT_NAME')) define('LIVE_ACCOUNT_NAME', 'admin');
|
if (!defined('LIVE_ACCOUNT_NAME')) define('LIVE_ACCOUNT_NAME', 'annu_kute_ced');
|
||||||
|
|
||||||
// Configuration pour une vidéo unique (HERO_TYPE = 'video')
|
// Configuration pour une vidéo unique (HERO_TYPE = 'video')
|
||||||
if (!defined('HERO_VIDEO_ID')) define('HERO_VIDEO_ID', '1aJ2u9euwF9fWKQhFxwFio');
|
if (!defined('HERO_VIDEO_ID')) define('HERO_VIDEO_ID', 'fDE6tvQMXRuBpE4eDDS7kj');
|
||||||
if (!defined('HERO_VIDEO_TITLE')) define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
|
if (!defined('HERO_VIDEO_TITLE')) define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
|
||||||
|
|
||||||
// Configuration pour les playlists (HERO_TYPE = 'playlist')
|
// Configuration pour les playlists (HERO_TYPE = 'playlist')
|
||||||
@@ -148,7 +155,7 @@ if (!defined('CASTOPOD_URL')) define('CASTOPOD_URL', 'https://kute.o-k-i.net');
|
|||||||
// Format: ['slug1', 'slug2', 'slug3']
|
// Format: ['slug1', 'slug2', 'slug3']
|
||||||
// Les épisodes de tous les podcasts seront mélangés et triés par date
|
// Les épisodes de tous les podcasts seront mélangés et triés par date
|
||||||
if (!defined('CASTOPOD_PODCAST_SLUGS')) {
|
if (!defined('CASTOPOD_PODCAST_SLUGS')) {
|
||||||
define('CASTOPOD_PODCAST_SLUGS', ['joukawouve']);
|
define('CASTOPOD_PODCAST_SLUGS', ['annu_kute_cedric']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
|
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
|
||||||
@@ -173,21 +180,14 @@ if (!defined('TAG_SHORT')) define('TAG_SHORT', 'short');
|
|||||||
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
|
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
|
||||||
if (!defined('IMPORTANT_TAGS')) {
|
if (!defined('IMPORTANT_TAGS')) {
|
||||||
define('IMPORTANT_TAGS', [
|
define('IMPORTANT_TAGS', [
|
||||||
'Chlordécone',
|
'ANNUKUTECED'
|
||||||
'RCI',
|
|
||||||
'Mé67',
|
|
||||||
'Traduction',
|
|
||||||
'fediverse'
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hashtags populaires à afficher sur la page d'accueil
|
// Hashtags populaires à afficher sur la page d'accueil
|
||||||
if (!defined('POPULAR_TAGS')) {
|
if (!defined('POPULAR_TAGS')) {
|
||||||
define('POPULAR_TAGS', [
|
define('POPULAR_TAGS', [
|
||||||
'fediverse',
|
'ANNUKUTECED'
|
||||||
'guadeloupe',
|
|
||||||
'Gaza',
|
|
||||||
'Arte'
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,32 +230,43 @@ if (!defined('WORDPRESS_ENABLED')) define('WORDPRESS_ENABLED', false);
|
|||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Activation du système de dons par défaut
|
// 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
|
// URLs des plateformes de don
|
||||||
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', ''); // Ex: https://liberapay.com/votre-compte/donate
|
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', 'https://liberapay.com/OKI/donate');
|
||||||
if (!defined('KOFI_URL')) define('KOFI_URL', ''); // Ex: https://ko-fi.com/votre-compte
|
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);
|
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
|
// Configuration Stripe
|
||||||
if (!defined('STRIPE_ONE_TIME_LINKS')) {
|
if (!defined('STRIPE_ONE_TIME_LINKS')) {
|
||||||
define('STRIPE_ONE_TIME_LINKS', [
|
define('STRIPE_ONE_TIME_LINKS', [
|
||||||
1 => '', // Lien Stripe pour don de 1€
|
1 => 'https://don.o-k-i.net/b/aEUdTw5SD3SH7m0bII', // Lien pour don de 1€
|
||||||
5 => '', // Lien Stripe pour don de 5€
|
5 => 'https://don.o-k-i.net/b/4gw6r480L1Kz5dScMO', // Lien pour don de 5€
|
||||||
10 => '', // Lien Stripe pour don de 10€
|
10 => 'https://don.o-k-i.net/b/5kA02G1Cn4WLbCgeUV', // Lien pour don de 10€
|
||||||
20 => '', // Lien Stripe pour don de 20€
|
20 => 'https://don.o-k-i.net/b/fZebLo94PfBpayc5kn', // Lien pour don de 20€
|
||||||
50 => '', // Lien Stripe pour don de 50€
|
50 => 'https://don.o-k-i.net/b/6oE5n05SDdth49O004', // Lien pour don de 50€
|
||||||
'custom' => '' // Lien Stripe pour montant personnalisé
|
'custom' => 'https://don.o-k-i.net/b/6oE2aO94P88X9u8eV4' // Lien pour montant personnalisé
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!defined('STRIPE_MONTHLY_LINKS')) {
|
if (!defined('STRIPE_MONTHLY_LINKS')) {
|
||||||
define('STRIPE_MONTHLY_LINKS', [
|
define('STRIPE_MONTHLY_LINKS', [
|
||||||
1 => '', // Lien Stripe pour don mensuel de 1€
|
1 => 'https://don.o-k-i.net/b/7sI9Dgch14WL7m0fZ3', // Lien pour don mensuel de 1€
|
||||||
5 => '', // Lien Stripe pour don mensuel de 5€
|
5 => 'https://don.o-k-i.net/b/8wM4iW4Ozbl95dS6ow', // Lien pour don mensuel de 5€
|
||||||
10 => '', // Lien Stripe pour don mensuel de 10€
|
10 => 'https://don.o-k-i.net/b/8wM2aO80L74TcGk3ci', // Lien pour don mensuel de 10€
|
||||||
20 => '', // Lien Stripe pour don mensuel de 20€
|
20 => 'https://don.o-k-i.net/b/00g7v894P88XgWAfZ7', // Lien pour don mensuel de 20€
|
||||||
50 => '', // Lien Stripe pour don mensuel de 50€
|
50 => 'https://don.o-k-i.net/b/4gw8zc6WHgFtcGkbIP', // Lien pour don mensuel de 50€
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,4 +295,3 @@ if (!defined('NEXT_LIVE_DATE')) define('NEXT_LIVE_DATE', '');
|
|||||||
// Chemin vers l'image d'annonce du prochain live (relatif à la racine du site)
|
// Chemin vers l'image d'annonce du prochain live (relatif à la racine du site)
|
||||||
// Exemple: 'uploads/next-live.jpg'
|
// Exemple: 'uploads/next-live.jpg'
|
||||||
if (!defined('NEXT_LIVE_IMAGE')) define('NEXT_LIVE_IMAGE', 'uploads/next-live.jpg');
|
if (!defined('NEXT_LIVE_IMAGE')) define('NEXT_LIVE_IMAGE', 'uploads/next-live.jpg');
|
||||||
?>
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Configuration locale pour l'instance de PeerTube
|
* Configuration locale pour l'instance ANNU KUTE CED
|
||||||
*
|
*
|
||||||
* Ce fichier est un exemple de configuration locale.
|
* Ce fichier est un exemple de configuration locale.
|
||||||
* Pour l'utiliser:
|
* Pour l'utiliser:
|
||||||
@@ -10,18 +10,20 @@
|
|||||||
* Note: config.local.php ne doit pas être versionné dans git
|
* Note: config.local.php ne doit pas être versionné dans git
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define('APP_HOST_NAME', 'fediverse.o-k-i.net');
|
define('APP_HOST_NAME', 'example.com');
|
||||||
|
|
||||||
// define('ORGANIZATION_SHORT_NAME', 'OKI');
|
// define('ORGANIZATION_SHORT_NAME', 'ANNU KUTE CED');
|
||||||
// define('ORGANIZATION_NAME', 'ORGANISATION KA INTERNATIONALE');
|
// define('ORGANIZATION_NAME', 'ANNU KUTE CED');
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// Configuration de l'API PeerTube
|
// Configuration de l'API PeerTube
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// URL de l'API PeerTube (obligatoire)
|
// URL de l'API PeerTube (obligatoire)
|
||||||
// define('PEERTUBE_URL', 'https://votre-instance.fr');
|
// Par défaut, le hub utilise la chaîne GADE du podcast :
|
||||||
// define('PEERTUBE_DISPLAY_NAME', 'votre-instance.fr');
|
// https://gade.o-k-i.net/c/annu_kute_ced/videos
|
||||||
|
// define('PEERTUBE_URL', 'https://gade.o-k-i.net');
|
||||||
|
// define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');
|
||||||
|
|
||||||
// Clé d'API PeerTube (optionnelle)
|
// Clé d'API PeerTube (optionnelle)
|
||||||
// define('API_KEY', 'votre_cle_api');
|
// define('API_KEY', 'votre_cle_api');
|
||||||
@@ -38,56 +40,48 @@ define('APP_HOST_NAME', 'fediverse.o-k-i.net');
|
|||||||
// define('HERO_TYPE', 'live');
|
// define('HERO_TYPE', 'live');
|
||||||
|
|
||||||
// Configuration pour le direct (HERO_TYPE = 'live')
|
// Configuration pour le direct (HERO_TYPE = 'live')
|
||||||
// define('LIVE_ACCOUNT_NAME', 'admin');
|
// Le compte PeerTube du podcast est 'annu_kute_ced'
|
||||||
|
// define('LIVE_ACCOUNT_NAME', 'annu_kute_ced');
|
||||||
|
|
||||||
// Configuration pour une vidéo unique (HERO_TYPE = 'video')
|
// Configuration pour une vidéo unique (HERO_TYPE = 'video')
|
||||||
// ID de la vidéo (extrait de l'URL: https://gade.o-k-i.net/w/VIDEO_ID)
|
// ID de la vidéo (extrait de l'URL: https://gade.o-k-i.net/w/VIDEO_ID)
|
||||||
// define('HERO_VIDEO_ID', '1aJ2u9euwF9fWKQhFxwFio');
|
// define('HERO_VIDEO_ID', 'fDE6tvQMXRuBpE4eDDS7kj');
|
||||||
|
|
||||||
// Titre de la vidéo (optionnel, pour l'accessibilité)
|
// Titre de la vidéo (optionnel, pour l'accessibilité)
|
||||||
// define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
|
// define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
|
||||||
|
|
||||||
// Configuration pour les playlists (HERO_TYPE = 'playlist')
|
// Configuration pour les playlists (HERO_TYPE = 'playlist')
|
||||||
// Type de plateforme: 'peertube', 'funkwhale', 'castopod'
|
// Type de plateforme: 'peertube', 'funkwhale', 'castopod'
|
||||||
// define('PLAYLIST_PLATFORM', 'peertube');
|
// define('PLAYLIST_PLATFORM', 'castopod');
|
||||||
|
|
||||||
// URL de base de la plateforme de playlist
|
// URL de base de la plateforme de playlist
|
||||||
// define('PLAYLIST_INSTANCE_URL', 'https://votre-instance.fr');
|
// define('PLAYLIST_INSTANCE_URL', 'https://kute.o-k-i.net');
|
||||||
|
|
||||||
// ID de la playlist à afficher
|
// ID de la playlist à afficher
|
||||||
// define('PLAYLIST_ID', 'votre-playlist-uuid');
|
// Pour Castopod, utilisez le slug du podcast précédé de @ :
|
||||||
|
// define('PLAYLIST_ID', '@annu_kute_cedric');
|
||||||
|
|
||||||
// Titre de la playlist (optionnel, sinon récupéré via API)
|
// Titre de la playlist (optionnel, sinon récupéré via API)
|
||||||
// define('PLAYLIST_TITLE', 'Ma Playlist');
|
// define('PLAYLIST_TITLE', 'ANNU KUTE CED');
|
||||||
|
|
||||||
// Description de la playlist (optionnel)
|
// Description de la playlist (optionnel)
|
||||||
// define('PLAYLIST_DESCRIPTION', 'Description de la playlist');
|
// define('PLAYLIST_DESCRIPTION', 'Tous les épisodes du podcast ANNU KUTE CED');
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// Filtres et tags
|
// Filtres et tags
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Tag pour les vidéos sur l'indépendance
|
|
||||||
// define('TAG_INDEPENDENCE', 'indépendance');
|
|
||||||
|
|
||||||
// Tag pour les shorts
|
// Tag pour les shorts
|
||||||
// define('TAG_SHORT', 'short');
|
// define('TAG_SHORT', 'short');
|
||||||
|
|
||||||
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
|
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
|
||||||
define('IMPORTANT_TAGS', [
|
define('IMPORTANT_TAGS', [
|
||||||
'Chlordécone',
|
'ANNUKUTECED'
|
||||||
'RCI',
|
|
||||||
'Mé67',
|
|
||||||
'Traduction',
|
|
||||||
'fediverse'
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Hashtags populaires à afficher sur la page d'accueil
|
// Hashtags populaires à afficher sur la page d'accueil
|
||||||
define('POPULAR_TAGS', [
|
define('POPULAR_TAGS', [
|
||||||
'fediverse',
|
'ANNUKUTECED'
|
||||||
'guadeloupe',
|
|
||||||
'Gaza',
|
|
||||||
'Arte'
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Durée maximale des shorts en secondes
|
// Durée maximale des shorts en secondes
|
||||||
@@ -116,9 +110,6 @@ define('SHORTS_MAX_DURATION', 180); // 3 minutes
|
|||||||
// Nombre de vidéos tendances
|
// Nombre de vidéos tendances
|
||||||
// define('TRENDING_VIDEOS_COUNT', 6);
|
// define('TRENDING_VIDEOS_COUNT', 6);
|
||||||
|
|
||||||
// Nombre de vidéos indépendance
|
|
||||||
// define('INDEPENDENCE_VIDEOS_COUNT', 6);
|
|
||||||
|
|
||||||
// Nombre de vidéos par catégorie
|
// Nombre de vidéos par catégorie
|
||||||
// define('CATEGORY_VIDEOS_COUNT', 6);
|
// define('CATEGORY_VIDEOS_COUNT', 6);
|
||||||
|
|
||||||
@@ -150,11 +141,8 @@ define('SHORTS_MAX_DURATION', 180); // 3 minutes
|
|||||||
// 17 : Kids
|
// 17 : Kids
|
||||||
// 18 : Food
|
// 18 : Food
|
||||||
define('PRIORITY_CATEGORIES', [
|
define('PRIORITY_CATEGORIES', [
|
||||||
11 => 'Actualités & Politique',
|
10 => 'Divertissement',
|
||||||
15 => 'Science et Technologie',
|
15 => 'Science et Technologie'
|
||||||
4 => 'Art',
|
|
||||||
9 => 'Humour',
|
|
||||||
10 => 'Divertissement'
|
|
||||||
// Ajoutez d'autres catégories selon vos besoins
|
// Ajoutez d'autres catégories selon vos besoins
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -163,10 +151,10 @@ define('PRIORITY_CATEGORIES', [
|
|||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Nom du site
|
// Nom du site
|
||||||
// define('SITE_NAME', 'FEDIVERSE OKI');
|
// define('SITE_NAME', 'ANNU KUTE CED');
|
||||||
|
|
||||||
// Description du site
|
// Description du site
|
||||||
// define('SITE_DESCRIPTION', 'Plateforme multimedia indépendante');
|
// define('SITE_DESCRIPTION', 'Hub multimédia du podcast ANNU KUTE CED');
|
||||||
|
|
||||||
// Logo du site
|
// Logo du site
|
||||||
// define('SITE_LOGO', 'img/logo.png');
|
// define('SITE_LOGO', 'img/logo.png');
|
||||||
@@ -198,14 +186,15 @@ define('PRIORITY_CATEGORIES', [
|
|||||||
// define('TIKTOK_URL', 'https://tiktok.com/@votrecompte');
|
// define('TIKTOK_URL', 'https://tiktok.com/@votrecompte');
|
||||||
|
|
||||||
// URL du compte Mastodon
|
// URL du compte Mastodon
|
||||||
// define('MASTODON_URL', 'https://bokante.o-k-i.net/@admin');
|
// Compte BOKANTE du podcast : https://bokante.o-k-i.net/@cedric
|
||||||
|
// define('MASTODON_URL', 'https://bokante.o-k-i.net/@cedric');
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// Contact
|
// Contact
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Email de contact
|
// Email de contact
|
||||||
// define('CONTACT_EMAIL', 'contact@votredomaine.com');
|
// define('CONTACT_EMAIL', 'kontak@o-k-i.net');
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// Fonctionnalités
|
// Fonctionnalités
|
||||||
@@ -235,7 +224,8 @@ define('PRIORITY_CATEGORIES', [
|
|||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// URL de l'instance Mastodon
|
// URL de l'instance Mastodon
|
||||||
// define('MASTODON_INSTANCE_URL', 'https://mastodon.social');
|
// Instance BOKANTE : https://bokante.o-k-i.net
|
||||||
|
// define('MASTODON_INSTANCE_URL', 'https://bokante.o-k-i.net');
|
||||||
|
|
||||||
// Format de date pour l'affichage des posts
|
// Format de date pour l'affichage des posts
|
||||||
// define('MASTODON_DATE_FORMAT', 'fr-FR');
|
// define('MASTODON_DATE_FORMAT', 'fr-FR');
|
||||||
@@ -261,7 +251,7 @@ define('PRIORITY_CATEGORIES', [
|
|||||||
// Mentions légales
|
// Mentions légales
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// define('LEGAL_COPYRIGHT', 'OKI');
|
// define('LEGAL_COPYRIGHT', 'ANNU KUTE CED');
|
||||||
// define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
|
// define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
|
||||||
// define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
|
// define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
|
||||||
// define('LEGAL_HOST_NAME', 'o2Switch');
|
// define('LEGAL_HOST_NAME', 'o2Switch');
|
||||||
@@ -271,8 +261,9 @@ define('PRIORITY_CATEGORIES', [
|
|||||||
// define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
|
// define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
|
||||||
// define('LEGAL_LICENSE', 'GNU Affero General Public License version 3 (AGPL-V3)');
|
// define('LEGAL_LICENSE', 'GNU Affero General Public License version 3 (AGPL-V3)');
|
||||||
// define('LEGAL_LICENSE_URL', 'https://www.gnu.org/licenses/agpl-3.0.html');
|
// define('LEGAL_LICENSE_URL', 'https://www.gnu.org/licenses/agpl-3.0.html');
|
||||||
// define('LEGAL_SOURCE_CODE_URL', 'https://codeberg.org/OKI/fediverse.o-k-i.net');
|
// Source du fork sur LaBola
|
||||||
// define('LEGAL_SERVICE_DESCRIPTION', ' est une plateforme multimédia indépendante.');
|
// define('LEGAL_SOURCE_CODE_URL', 'https://labola.o-k-i.net/cedric/annu-kute-ced');
|
||||||
|
// define('LEGAL_SERVICE_DESCRIPTION', ' est le hub multimédia du podcast ANNU KUTE CED.');
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// Système de compte à rebours / maintenance
|
// Système de compte à rebours / maintenance
|
||||||
@@ -314,14 +305,15 @@ define('WORDPRESS_ENABLED', false);
|
|||||||
// define('CASTOPOD_ENABLED', true);
|
// define('CASTOPOD_ENABLED', true);
|
||||||
|
|
||||||
// URL de l'instance Castopod
|
// URL de l'instance Castopod
|
||||||
|
// Instance KUTE : https://kute.o-k-i.net
|
||||||
// define('CASTOPOD_URL', 'https://kute.o-k-i.net');
|
// define('CASTOPOD_URL', 'https://kute.o-k-i.net');
|
||||||
|
|
||||||
// Liste des slugs de podcasts à afficher (tableau)
|
// Liste des slugs de podcasts à afficher (tableau)
|
||||||
|
// Le podcast ANNU KUTE CED est accessible via : https://kute.o-k-i.net/@annu_kute_cedric
|
||||||
|
// Le feed RSS est : https://kute.o-k-i.net/@annu_kute_cedric/feed
|
||||||
// Les épisodes de tous les podcasts seront mélangés et triés par date
|
// Les épisodes de tous les podcasts seront mélangés et triés par date
|
||||||
// define('CASTOPOD_PODCAST_SLUGS', [
|
// define('CASTOPOD_PODCAST_SLUGS', [
|
||||||
// 'joukawouve',
|
// 'annu_kute_cedric'
|
||||||
// 'cspcc',
|
|
||||||
// 'radyobokaz'
|
|
||||||
// ]);
|
// ]);
|
||||||
|
|
||||||
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
|
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
|
||||||
@@ -333,6 +325,11 @@ define('WORDPRESS_ENABLED', false);
|
|||||||
// define('CACHE_ENABLED', true);
|
// define('CACHE_ENABLED', true);
|
||||||
// define('CACHE_DURATION', 3600); // 1 heure recommandé
|
// 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)
|
// Intégration Funkwhale (Musique)
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -351,10 +348,14 @@ define('WORDPRESS_ENABLED', false);
|
|||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Activer/désactiver le système de dons
|
// 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);
|
// define('DONATIONS_ENABLED', true);
|
||||||
|
|
||||||
// URLs des plateformes de don
|
// 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');
|
// define('KOFI_URL', 'https://ko-fi.com/votre-compte');
|
||||||
|
|
||||||
// Activer/désactiver les dons via Stripe
|
// Activer/désactiver les dons via Stripe
|
||||||
@@ -385,27 +386,34 @@ define('WORDPRESS_ENABLED', false);
|
|||||||
// Devise pour les dons
|
// Devise pour les dons
|
||||||
// define('DONATION_CURRENCY', 'EUR');
|
// 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 mouvement
|
// Texte de présentation du podcast
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// Titre de la section de présentation (par défaut: "À propos")
|
// Titre de la section de présentation (par défaut: "À propos")
|
||||||
// define('MOVEMENT_TITLE', 'Notre mouvement');
|
// define('MOVEMENT_TITLE', 'Le podcast ANNU KUTE CED');
|
||||||
|
|
||||||
// Premier paragraphe de description (pour désactiver le bloc de présentation, commentez cette ligne)
|
// Premier paragraphe de description (pour désactiver le bloc de présentation, commentez cette ligne)
|
||||||
// define('MOVEMENT_DESCRIPTION', 'Nous sommes une association à but non lucratif dédiée à la promotion de nos langues et au traitement de l\'actualité.');
|
// define('MOVEMENT_DESCRIPTION', 'ANNU KUTE CED est un podcast diffusé sur le Fédivers.');
|
||||||
|
|
||||||
// Deuxième paragraphe de description (optionnel)
|
// Deuxième paragraphe de description (optionnel)
|
||||||
// define('MOVEMENT_DESCRIPTION_2', 'Parallèlement, nous proposons des alternatives aux géants du numérique comme les GAFAM, en privilégiant l\'utilisation de logiciels libres.');
|
// define('MOVEMENT_DESCRIPTION_2', 'Retrouvez nos épisodes sur Castopod, nos vidéos sur PeerTube et nos actualités sur Mastodon.');
|
||||||
|
|
||||||
// Image du mouvement à afficher dans la section de présentation
|
// Image du podcast à afficher dans la section de présentation
|
||||||
// define('MOVEMENT_IMAGE', 'img/movement_presentation.png');
|
// define('MOVEMENT_IMAGE', 'img/movement_presentation.png');
|
||||||
|
|
||||||
// Texte alternatif pour l'image du mouvement (accessibilité)
|
// Texte alternatif pour l'image (accessibilité)
|
||||||
// define('MOVEMENT_IMAGE_ALT', 'Texte alternatif pour l\'image du mouvement');
|
// define('MOVEMENT_IMAGE_ALT', 'Logo du podcast ANNU KUTE CED');
|
||||||
|
|
||||||
// Légende de l'image (peut contenir du HTML simple comme <br>)
|
// Légende de l'image (peut contenir du HTML simple comme <br>)
|
||||||
// define('MOVEMENT_CAPTION', 'Légende de l\'image');
|
// define('MOVEMENT_CAPTION', 'ANNU KUTE CED');
|
||||||
|
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -419,7 +427,7 @@ define('WORDPRESS_ENABLED', false);
|
|||||||
// define('NEXT_LIVE_TITLE', 'Prochain live');
|
// define('NEXT_LIVE_TITLE', 'Prochain live');
|
||||||
|
|
||||||
// Description de l'annonce du prochain live
|
// Description de l'annonce du prochain live
|
||||||
// define('NEXT_LIVE_DESCRIPTION', 'Rejoignez-nous pour notre prochain live !');
|
// define('NEXT_LIVE_DESCRIPTION', 'Rejoignez-nous pour le prochain enregistrement d\'ANNU KUTE CED !');
|
||||||
|
|
||||||
// Date du prochain live (format: Y-m-d H:i:s)
|
// Date du prochain live (format: Y-m-d H:i:s)
|
||||||
// define('NEXT_LIVE_DATE', '2025-10-11 10:00:00');
|
// define('NEXT_LIVE_DATE', '2025-10-11 10:00:00');
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
<?php require_once __DIR__ . '/nav-context.php'; ?>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="footer-header">
|
<div class="footer-header">
|
||||||
<div class="footer-logo">
|
<div class="footer-logo">
|
||||||
@@ -94,6 +95,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-copyright">
|
<div class="footer-copyright">
|
||||||
<?php echo LEGAL_COPYRIGHT; ?> <?php echo date('Y'); ?> - Licence libre <a href="<?php echo LEGAL_LICENSE_URL; ?>" target="_blank" rel="noopener noreferrer">GNU AGPL-V3</a>
|
<?php echo LEGAL_COPYRIGHT; ?> <?php echo date('Y'); ?> - Licence libre <a href="<?php echo LEGAL_LICENSE_URL; ?>" target="_blank" rel="noopener noreferrer"><?php echo LEGAL_LICENSE; ?></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<button id="theme-toggle" class="icon-button" aria-label="Basculer entre mode clair et sombre" title="Changer le thème">
|
<button id="theme-toggle" class="icon-button" aria-label="Basculer entre mode clair et sombre" title="Changer le thème">
|
||||||
<i class="fas fa-sun" aria-hidden="true"></i>
|
<i class="fas fa-sun" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="install-pwa" class="icon-button install-pwa-button" style="display: none;" nonce="<?php echo getCspNonce(); ?>" aria-label="Installer l'application">
|
<button id="install-pwa" class="icon-button install-pwa-button is-hidden" aria-label="Installer l'application">
|
||||||
<i class="fas fa-download" aria-hidden="true"></i>
|
<i class="fas fa-download" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="mobile-menu-toggle" aria-expanded="false" aria-controls="mobile-menu" aria-label="Ouvrir le menu de navigation">
|
<button class="mobile-menu-toggle" aria-expanded="false" aria-controls="mobile-menu" aria-label="Ouvrir le menu de navigation">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ if ($heroType === 'none') {
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-video-container">
|
<div class="hero-video-container">
|
||||||
<iframe
|
<iframe
|
||||||
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1&muted=1"
|
src="<?php echo e(PEERTUBE_URL . '/videos/embed/' . $liveStream['id'] . '?autoplay=1&muted=1'); ?>"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen="allowfullscreen"
|
allowfullscreen="allowfullscreen"
|
||||||
allow="autoplay; fullscreen"
|
allow="autoplay; fullscreen"
|
||||||
@@ -64,7 +64,7 @@ if ($heroType === 'none') {
|
|||||||
<i class="fas fa-user-circle" aria-hidden="true"></i>
|
<i class="fas fa-user-circle" aria-hidden="true"></i>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar">
|
<img src="<?php echo e($liveStream['channelAvatar']); ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span>
|
<span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,103 +75,9 @@ if ($heroType === 'none') {
|
|||||||
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
|
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
|
||||||
|
|
||||||
if ($showNextLiveAnnouncement) {
|
if ($showNextLiveAnnouncement) {
|
||||||
// Afficher l'annonce du prochain live
|
// Afficher l'annonce du prochain live (partial partagé avec direct.php)
|
||||||
// Définir l'image de fond si disponible
|
$nextLiveVariant = 'hero';
|
||||||
$bgImageStyle = '';
|
include __DIR__ . '/partials/next-live.php';
|
||||||
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
|
|
||||||
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<div class="hero-next-live" style="<?php echo $bgImageStyle; ?>" nonce="<?php echo getCspNonce(); ?>">
|
|
||||||
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
|
|
||||||
<div class="hero-next-live-image-container">
|
|
||||||
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
|
|
||||||
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
|
|
||||||
class="hero-next-live-image">
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div class="hero-next-live-content">
|
|
||||||
<i class="fas fa-calendar-alt"></i>
|
|
||||||
<?php
|
|
||||||
if (!empty(NEXT_LIVE_DATE)) {
|
|
||||||
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
|
|
||||||
$dayFormatter = new IntlDateFormatter(
|
|
||||||
'fr_FR',
|
|
||||||
IntlDateFormatter::FULL,
|
|
||||||
IntlDateFormatter::NONE,
|
|
||||||
DEFAULT_TIMEZONE,
|
|
||||||
IntlDateFormatter::GREGORIAN,
|
|
||||||
'EEEE d MMMM'
|
|
||||||
);
|
|
||||||
$formattedDay = $dayFormatter->format($liveDate);
|
|
||||||
$formattedDay = ucfirst($formattedDay);
|
|
||||||
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
|
|
||||||
} else {
|
|
||||||
$dynamicTitle = NEXT_LIVE_TITLE;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<h2><?php echo htmlspecialchars($dynamicTitle); ?></h2>
|
|
||||||
<?php
|
|
||||||
if (!empty(NEXT_LIVE_DATE)) {
|
|
||||||
$liveHour = $liveDate->format('H\hi');
|
|
||||||
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
|
|
||||||
} else {
|
|
||||||
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
|
|
||||||
<?php if (!empty(NEXT_LIVE_DATE)): ?>
|
|
||||||
<div class="hero-next-live-datetime">
|
|
||||||
<p class="hero-next-live-date">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
<?php
|
|
||||||
$formatter = new IntlDateFormatter(
|
|
||||||
'fr_FR',
|
|
||||||
IntlDateFormatter::FULL,
|
|
||||||
IntlDateFormatter::SHORT,
|
|
||||||
DEFAULT_TIMEZONE
|
|
||||||
);
|
|
||||||
echo $formatter->format($liveDate);
|
|
||||||
|
|
||||||
$offset = $liveDate->format('P');
|
|
||||||
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Autres fuseaux horaires -->
|
|
||||||
<div class="hero-next-live-timezones">
|
|
||||||
<?php
|
|
||||||
// Ordre croissant : du plus en retard au plus en avance
|
|
||||||
$timezones = [
|
|
||||||
'Ma\'ohi Nui' => 'Pacific/Tahiti',
|
|
||||||
'Martinique / Guadeloupe' => 'America/Martinique',
|
|
||||||
'Guyane' => 'America/Cayenne',
|
|
||||||
'France' => 'Europe/Paris',
|
|
||||||
'Kanaky' => 'Pacific/Noumea'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach($timezones as $name => $timezone):
|
|
||||||
$liveDateLocal = clone $liveDate;
|
|
||||||
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
|
|
||||||
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
|
|
||||||
|
|
||||||
$dayIndicator = '';
|
|
||||||
if ($dayDiff > 0) {
|
|
||||||
$dayIndicator = ' <span class="day-shift">+1j</span>';
|
|
||||||
} elseif ($dayDiff < 0) {
|
|
||||||
$dayIndicator = ' <span class="day-shift">-1j</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<span class="hero-timezone-item">
|
|
||||||
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
} else {
|
} else {
|
||||||
?>
|
?>
|
||||||
<div class="hero-no-live">
|
<div class="hero-no-live">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ function markdown_to_html($markdown) {
|
|||||||
$links = [];
|
$links = [];
|
||||||
$link_count = 0;
|
$link_count = 0;
|
||||||
|
|
||||||
|
// Note : le texte étant déjà échappé ci-dessus, les URLs extraites le sont
|
||||||
|
// aussi (« & » est devenu « & »). Il ne faut PAS les ré-échapper dans
|
||||||
|
// les callbacks ci-dessous, sinon on obtient un double encodage (« &amp; »).
|
||||||
|
|
||||||
// Conversion des liens Markdown [texte](url)
|
// Conversion des liens Markdown [texte](url)
|
||||||
$markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) {
|
$markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) {
|
||||||
$text = $matches[1];
|
$text = $matches[1];
|
||||||
@@ -30,7 +34,7 @@ function markdown_to_html($markdown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$placeholder = "___LINK_{$link_count}___";
|
$placeholder = "___LINK_{$link_count}___";
|
||||||
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
|
$links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
|
||||||
$link_count++;
|
$link_count++;
|
||||||
|
|
||||||
return $placeholder;
|
return $placeholder;
|
||||||
@@ -43,7 +47,7 @@ function markdown_to_html($markdown) {
|
|||||||
$url = $matches[1];
|
$url = $matches[1];
|
||||||
|
|
||||||
$placeholder = "___LINK_{$link_count}___";
|
$placeholder = "___LINK_{$link_count}___";
|
||||||
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
|
$links[$placeholder] = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
|
||||||
$link_count++;
|
$link_count++;
|
||||||
|
|
||||||
return $placeholder;
|
return $placeholder;
|
||||||
@@ -61,7 +65,7 @@ function markdown_to_html($markdown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$placeholder = "___LINK_{$link_count}___";
|
$placeholder = "___LINK_{$link_count}___";
|
||||||
$links[$placeholder] = '<a href="http://' . htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
|
$links[$placeholder] = '<a href="http://' . $domain . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
|
||||||
$link_count++;
|
$link_count++;
|
||||||
|
|
||||||
return $placeholder;
|
return $placeholder;
|
||||||
@@ -71,19 +75,45 @@ function markdown_to_html($markdown) {
|
|||||||
$markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown);
|
$markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown);
|
||||||
$markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown);
|
$markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown);
|
||||||
|
|
||||||
// Conversion des listes à puces
|
// Conversion des listes (à puces et numérotées) en une seule passe ligne à
|
||||||
$markdown = preg_replace('/^- (.*?)$/m', '<li>$1</li>', $markdown);
|
// ligne : les items consécutifs de même type forment une seule liste ; une
|
||||||
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ul>$0</ul>', $markdown);
|
// ligne hors liste ou un changement de type ferme la liste courante.
|
||||||
|
$lines = explode("\n", $markdown);
|
||||||
|
$markdown = '';
|
||||||
|
$listType = null; // 'ul', 'ol' ou null (hors liste)
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$itemType = null;
|
||||||
|
$itemText = null;
|
||||||
|
if (preg_match('/^- (.*)$/', $line, $matches)) {
|
||||||
|
$itemType = 'ul';
|
||||||
|
$itemText = $matches[1];
|
||||||
|
} elseif (preg_match('/^\d+\. (.*)$/', $line, $matches)) {
|
||||||
|
$itemType = 'ol';
|
||||||
|
$itemText = $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
// Conversion des listes numérotées
|
if ($itemType !== $listType) {
|
||||||
$markdown = preg_replace('/^\d+\. (.*?)$/m', '<li>$1</li>', $markdown);
|
if ($listType !== null) {
|
||||||
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ol>$0</ol>', $markdown);
|
$markdown .= '</' . $listType . ">\n";
|
||||||
|
}
|
||||||
|
if ($itemType !== null) {
|
||||||
|
$markdown .= '<' . $itemType . ">\n";
|
||||||
|
}
|
||||||
|
$listType = $itemType;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown .= ($itemType !== null ? '<li>' . $itemText . '</li>' : $line) . "\n";
|
||||||
|
}
|
||||||
|
if ($listType !== null) {
|
||||||
|
$markdown .= '</' . $listType . ">\n";
|
||||||
|
}
|
||||||
|
$markdown = rtrim($markdown, "\n");
|
||||||
|
|
||||||
// Gestion des retours à la ligne
|
// Gestion des retours à la ligne
|
||||||
$markdown = nl2br($markdown);
|
$markdown = nl2br($markdown);
|
||||||
|
|
||||||
// Nettoyage des balises br dans les listes
|
// Nettoyage des balises br autour des listes
|
||||||
$markdown = preg_replace('/<\/li><br \/>/', '</li>', $markdown);
|
$markdown = preg_replace('/(<\/li>|<ul>|<ol>)<br \/>/', '$1', $markdown);
|
||||||
|
|
||||||
// Restaurer les liens
|
// Restaurer les liens
|
||||||
foreach ($links as $placeholder => $link) {
|
foreach ($links as $placeholder => $link) {
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
<!-- Menu mobile (masqué par défaut) -->
|
<!-- Menu mobile (masqué par défaut) -->
|
||||||
|
<?php require_once __DIR__ . '/nav-context.php'; ?>
|
||||||
<div class="mobile-menu">
|
<div class="mobile-menu">
|
||||||
<button class="mobile-menu-close">
|
<button class="mobile-menu-close">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
|
|||||||
@@ -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) : '';
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -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; ?>
|
|
||||||
@@ -3,6 +3,20 @@
|
|||||||
* Fonctions de sécurité pour la validation et l'assainissement des entrées
|
* Fonctions de sécurité pour la validation et l'assainissement des entrées
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échappe une valeur pour une sortie HTML (texte ou attribut).
|
||||||
|
*
|
||||||
|
* Raccourci pour htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8') :
|
||||||
|
* les guillemets simples et doubles sont encodés, ce qui rend la sortie
|
||||||
|
* sûre aussi bien dans le contenu que dans les attributs.
|
||||||
|
*
|
||||||
|
* @param mixed $value Valeur à échapper
|
||||||
|
* @return string Valeur échappée
|
||||||
|
*/
|
||||||
|
function e($value) {
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide et assainit un ID de vidéo UUID
|
* Valide et assainit un ID de vidéo UUID
|
||||||
*
|
*
|
||||||
@@ -79,102 +93,87 @@ function validateCategoryId($categoryId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide et assainit un User-Agent
|
* Valeur par défaut livrée dans config.default.php : si CSRF_SECRET vaut
|
||||||
*
|
* encore cette valeur, le secret n'a pas été configuré pour l'instance.
|
||||||
* @param string $userAgent User-Agent à valider
|
|
||||||
* @return bool True si valide
|
|
||||||
*/
|
*/
|
||||||
function validateUserAgent($userAgent) {
|
if (!defined('CSRF_SECRET_PLACEHOLDER')) {
|
||||||
if (empty($userAgent)) {
|
define('CSRF_SECRET_PLACEHOLDER', 'change-me-in-config-local-php');
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bloquer les User-Agents suspects
|
|
||||||
$blockedPatterns = [
|
|
||||||
'/curl/i',
|
|
||||||
'/wget/i',
|
|
||||||
'/python/i',
|
|
||||||
'/bot/i',
|
|
||||||
'/scanner/i',
|
|
||||||
'/sqlmap/i'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($blockedPatterns as $pattern) {
|
|
||||||
if (preg_match($pattern, $userAgent)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide les en-têtes HTTP pour détecter les tentatives d'attaque
|
* Retourne le secret CSRF effectif utilisé pour signer les tokens.
|
||||||
*
|
*
|
||||||
* @return bool True si les en-têtes sont sûrs
|
* Si CSRF_SECRET est absent, vide ou vaut encore la valeur par défaut, un
|
||||||
|
* avertissement critique est enregistré et un secret éphémère propre au
|
||||||
|
* processus est généré (bin2hex(random_bytes(32))) : les tokens restent
|
||||||
|
* signés, mais sont invalidés à chaque redémarrage du processus PHP.
|
||||||
|
*
|
||||||
|
* @return string Secret CSRF effectif
|
||||||
*/
|
*/
|
||||||
function validateHttpHeaders() {
|
function getCsrfSecret() {
|
||||||
// Vérifier le User-Agent
|
static $secret = null;
|
||||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
||||||
if (!validateUserAgent($userAgent)) {
|
if ($secret !== null) {
|
||||||
error_log('SECURITY: Suspicious User-Agent detected: ' . $userAgent);
|
return $secret;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier les en-têtes suspects
|
if (defined('CSRF_SECRET') && CSRF_SECRET !== '' && CSRF_SECRET !== CSRF_SECRET_PLACEHOLDER) {
|
||||||
$suspiciousHeaders = [
|
$secret = CSRF_SECRET;
|
||||||
'HTTP_X_FORWARDED_FOR',
|
return $secret;
|
||||||
'HTTP_X_REAL_IP',
|
|
||||||
'HTTP_CLIENT_IP'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($suspiciousHeaders as $header) {
|
|
||||||
if (isset($_SERVER[$header])) {
|
|
||||||
$value = $_SERVER[$header];
|
|
||||||
// Bloquer les IPs privées dans les en-têtes de forwarding
|
|
||||||
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
|
||||||
error_log('SECURITY: Suspicious IP in header ' . $header . ': ' . $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
error_log('SECURITY CRITICAL: CSRF_SECRET is not configured (default value in use). '
|
||||||
|
. 'An ephemeral per-process secret was generated: CSRF tokens will be invalidated '
|
||||||
|
. 'on every process restart. Set CSRF_SECRET in config.local.php (bin2hex(random_bytes(32))).');
|
||||||
|
|
||||||
|
$secret = bin2hex(random_bytes(32));
|
||||||
|
return $secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère un token CSRF sécurisé
|
* Génère un token CSRF stateless (HMAC + timestamp).
|
||||||
*
|
*
|
||||||
* @return string Token CSRF
|
* 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() {
|
function generateCSRFToken() {
|
||||||
// Démarrer la session seulement si les en-têtes n'ont pas été envoyés
|
$timestamp = time();
|
||||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
$hash = hash_hmac('sha256', (string) $timestamp, getCsrfSecret());
|
||||||
session_start();
|
return $timestamp . ':' . $hash;
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($_SESSION['csrf_token'])) {
|
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $_SESSION['csrf_token'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide un token CSRF
|
* Valide un token CSRF stateless
|
||||||
*
|
*
|
||||||
* @param string $token Token à valider
|
* @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) {
|
function validateCSRFToken($token) {
|
||||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
if (empty($token) || !is_string($token)) {
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($_SESSION['csrf_token'])) {
|
|
||||||
return false;
|
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'];
|
return $GLOBALS['csp_nonce'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait l'origine « scheme://host » d'une URL configurée (pour la CSP).
|
||||||
|
*
|
||||||
|
* @param string $url URL à analyser
|
||||||
|
* @return string Origine normalisée, ou chaîne vide si l'URL est vide/invalide
|
||||||
|
*/
|
||||||
|
function cspOriginFromUrl($url) {
|
||||||
|
if (empty($url)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return $parsed['scheme'] . '://' . $parsed['host'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la Content Security Policy de la requête courante.
|
||||||
|
*
|
||||||
|
* img-src et media-src sont restreints aux domaines réellement utilisés :
|
||||||
|
* PeerTube (vignettes), Mastodon (avatars/médias) et son S3 éventuel,
|
||||||
|
* Castopod (pochettes/flux audio), Funkwhale (pochettes/flux audio) et
|
||||||
|
* WordPress (images mises en avant). En développement local, HTTP(S) général
|
||||||
|
* reste autorisé pour faciliter les tests avec du contenu fédéré.
|
||||||
|
*
|
||||||
|
* @param string $nonce Nonce CSP de la requête
|
||||||
|
* @return string Politique CSP complète
|
||||||
|
*/
|
||||||
|
function buildContentSecurityPolicy($nonce) {
|
||||||
|
$mastodonDomain = defined('MASTODON_INSTANCE_URL') ? cspOriginFromUrl(MASTODON_INSTANCE_URL) : '';
|
||||||
|
$peertubeDomain = defined('PEERTUBE_URL') ? cspOriginFromUrl(PEERTUBE_URL) : '';
|
||||||
|
$castopodDomain = (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL'))
|
||||||
|
? cspOriginFromUrl(CASTOPOD_URL) : '';
|
||||||
|
$funkwhaleDomain = (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED && defined('FUNKWHALE_URL'))
|
||||||
|
? cspOriginFromUrl(FUNKWHALE_URL) : '';
|
||||||
|
$wordpressDomain = defined('WORDPRESS_URL') ? cspOriginFromUrl(WORDPRESS_URL) : '';
|
||||||
|
$s3Domain = defined('MASTODON_S3_MEDIA_URL') ? cspOriginFromUrl(MASTODON_S3_MEDIA_URL) : '';
|
||||||
|
|
||||||
|
// Détecter si on est en développement local
|
||||||
|
$isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']);
|
||||||
|
|
||||||
|
$csp = "default-src 'self'; ";
|
||||||
|
$csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; ";
|
||||||
|
$csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; ";
|
||||||
|
|
||||||
|
// Images : uniquement les services réellement affichés (https: général en dev uniquement)
|
||||||
|
$imgSrc = "'self' data:";
|
||||||
|
foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $wordpressDomain, $s3Domain] as $domain) {
|
||||||
|
if ($domain !== '') {
|
||||||
|
$imgSrc .= ' ' . $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($isLocalDev) {
|
||||||
|
$imgSrc .= ' https: http:';
|
||||||
|
}
|
||||||
|
$csp .= "img-src " . $imgSrc . "; ";
|
||||||
|
|
||||||
|
$csp .= "font-src 'self' https://cdnjs.cloudflare.com; ";
|
||||||
|
|
||||||
|
// Frames : autoriser PeerTube uniquement
|
||||||
|
$frameSrc = "'self'" . ($peertubeDomain !== '' ? ' ' . $peertubeDomain : '');
|
||||||
|
if ($isLocalDev) {
|
||||||
|
$frameSrc .= ' https: http:';
|
||||||
|
}
|
||||||
|
$csp .= "frame-src " . $frameSrc . "; ";
|
||||||
|
|
||||||
|
// Connexions : autoriser Mastodon, PeerTube et Plausible
|
||||||
|
$connectSrc = "'self' https://plausible.io";
|
||||||
|
foreach ([$mastodonDomain, $peertubeDomain] as $domain) {
|
||||||
|
if ($domain !== '') {
|
||||||
|
$connectSrc .= ' ' . $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($isLocalDev) {
|
||||||
|
$connectSrc .= ' ws: wss:'; // WebSockets pour le dev
|
||||||
|
}
|
||||||
|
$csp .= "connect-src " . $connectSrc . "; ";
|
||||||
|
|
||||||
|
// Médias : flux audio Castopod/Funkwhale, médias Mastodon (instance ou S3)
|
||||||
|
$mediaSrc = "'self'";
|
||||||
|
foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $s3Domain] as $domain) {
|
||||||
|
if ($domain !== '') {
|
||||||
|
$mediaSrc .= ' ' . $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($isLocalDev) {
|
||||||
|
$mediaSrc .= ' https: http:';
|
||||||
|
}
|
||||||
|
$csp .= "media-src " . $mediaSrc . "; ";
|
||||||
|
|
||||||
|
$csp .= "object-src 'none'; ";
|
||||||
|
$csp .= "base-uri 'self'; ";
|
||||||
|
$csp .= "frame-ancestors 'self';";
|
||||||
|
|
||||||
|
return $csp;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applique des en-têtes de sécurité HTTP
|
* Applique des en-têtes de sécurité HTTP
|
||||||
*/
|
*/
|
||||||
@@ -200,8 +297,8 @@ function setSecurityHeaders() {
|
|||||||
// Protection contre le MIME sniffing
|
// Protection contre le MIME sniffing
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
// Protection XSS basique
|
// X-XSS-Protection volontairement absent : en-tête obsolète, supplanté par
|
||||||
header('X-XSS-Protection: 1; mode=block');
|
// la CSP et pouvant introduire des vulnérabilités dans les anciens navigateurs.
|
||||||
|
|
||||||
// Politique de référent
|
// Politique de référent
|
||||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||||
@@ -215,91 +312,8 @@ function setSecurityHeaders() {
|
|||||||
// Permissions Policy (feature policy)
|
// Permissions Policy (feature policy)
|
||||||
header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()');
|
header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()');
|
||||||
|
|
||||||
// Content Security Policy avec support Mastodon et PeerTube
|
// Content Security Policy (domaines réellement utilisés uniquement)
|
||||||
$nonce = getCspNonce();
|
header('Content-Security-Policy: ' . buildContentSecurityPolicy(getCspNonce()));
|
||||||
$mastodonDomain = '';
|
|
||||||
$peertubeDomain = '';
|
|
||||||
|
|
||||||
// Extraire le domaine Mastodon si configuré
|
|
||||||
if (defined('MASTODON_INSTANCE_URL')) {
|
|
||||||
$mastodonParsed = parse_url(MASTODON_INSTANCE_URL);
|
|
||||||
if ($mastodonParsed && isset($mastodonParsed['host'])) {
|
|
||||||
$mastodonDomain = $mastodonParsed['scheme'] . '://' . $mastodonParsed['host'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extraire le domaine PeerTube si configuré
|
|
||||||
if (defined('PEERTUBE_URL')) {
|
|
||||||
$peertubeParsed = parse_url(PEERTUBE_URL);
|
|
||||||
if ($peertubeParsed && isset($peertubeParsed['host'])) {
|
|
||||||
$peertubeDomain = $peertubeParsed['scheme'] . '://' . $peertubeParsed['host'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Détecter si on est en développement local
|
|
||||||
$isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']);
|
|
||||||
|
|
||||||
$csp = "default-src 'self'; ";
|
|
||||||
$csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; ";
|
|
||||||
$csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; ";
|
|
||||||
|
|
||||||
// Images : autoriser les domaines connus plus HTTPS général pour le contenu fédéré
|
|
||||||
$imgSrc = "'self' data: " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : '');
|
|
||||||
if ($isLocalDev) {
|
|
||||||
$imgSrc .= " https: http:";
|
|
||||||
} else {
|
|
||||||
$imgSrc .= " https:";
|
|
||||||
}
|
|
||||||
$csp .= "img-src " . $imgSrc . "; ";
|
|
||||||
|
|
||||||
$csp .= "font-src 'self' https://cdnjs.cloudflare.com; ";
|
|
||||||
|
|
||||||
// Frames : autoriser PeerTube uniquement
|
|
||||||
$frameSrc = "'self' " . ($peertubeDomain ? $peertubeDomain : '');
|
|
||||||
if ($isLocalDev) {
|
|
||||||
$frameSrc .= " https: http:";
|
|
||||||
}
|
|
||||||
$csp .= "frame-src " . $frameSrc . "; ";
|
|
||||||
|
|
||||||
// Connexions : autoriser Mastodon, PeerTube et Plausible
|
|
||||||
$connectSrc = "'self' https://plausible.io " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : '');
|
|
||||||
if ($isLocalDev) {
|
|
||||||
$connectSrc .= " ws: wss:"; // WebSockets pour le dev
|
|
||||||
}
|
|
||||||
$csp .= "connect-src " . $connectSrc . "; ";
|
|
||||||
|
|
||||||
// Médias : autoriser 'self', Mastodon, PeerTube et S3 Mastodon
|
|
||||||
$mediaSrc = "'self'";
|
|
||||||
|
|
||||||
if ($mastodonDomain) {
|
|
||||||
$mediaSrc .= " " . $mastodonDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($peertubeDomain) {
|
|
||||||
$mediaSrc .= " " . $peertubeDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter l'URL S3 Mastodon si configurée (pour les médias externalisés)
|
|
||||||
if (defined('MASTODON_S3_MEDIA_URL') && !empty(MASTODON_S3_MEDIA_URL)) {
|
|
||||||
$s3Parsed = parse_url(MASTODON_S3_MEDIA_URL);
|
|
||||||
if ($s3Parsed && isset($s3Parsed['host'])) {
|
|
||||||
$s3Domain = $s3Parsed['scheme'] . '://' . $s3Parsed['host'];
|
|
||||||
$mediaSrc .= " " . $s3Domain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($isLocalDev) {
|
|
||||||
$mediaSrc .= " https: http:";
|
|
||||||
} else {
|
|
||||||
$mediaSrc .= " https:";
|
|
||||||
}
|
|
||||||
$csp .= "media-src " . $mediaSrc . "; ";
|
|
||||||
|
|
||||||
$csp .= "object-src 'none'; ";
|
|
||||||
$csp .= "base-uri 'self'; ";
|
|
||||||
$csp .= "frame-ancestors 'self';";
|
|
||||||
|
|
||||||
header('Content-Security-Policy: ' . $csp);
|
|
||||||
|
|
||||||
// HTTPS strict transport security (seulement si HTTPS)
|
// HTTPS strict transport security (seulement si HTTPS)
|
||||||
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
||||||
@@ -310,18 +324,122 @@ function setSecurityHeaders() {
|
|||||||
/**
|
/**
|
||||||
* Valide l'origine de la requête pour les requêtes AJAX
|
* 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
|
* @return bool True si l'origine est valide
|
||||||
*/
|
*/
|
||||||
function validateAjaxOrigin() {
|
function validateAjaxOrigin() {
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
if (empty($host)) {
|
||||||
if (empty($origin) || empty($host)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedOrigin = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
|
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http');
|
||||||
|
$expectedOrigin = $scheme . '://' . $host;
|
||||||
|
|
||||||
return $origin === $expectedOrigin;
|
$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,18 +1,10 @@
|
|||||||
<!-- Sidebar de navigation -->
|
<!-- Sidebar de navigation -->
|
||||||
|
<?php require_once __DIR__ . '/nav-context.php'; ?>
|
||||||
<nav class="sidebar" role="navigation" aria-label="Navigation principale">
|
<nav class="sidebar" role="navigation" aria-label="Navigation principale">
|
||||||
<a href="/" class="logo" aria-label="Retour à l'accueil">
|
<a href="/" class="logo" aria-label="Retour à l'accueil">
|
||||||
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>">
|
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<?php
|
|
||||||
// Détecter la page courante et ses paramètres
|
|
||||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
|
||||||
$currentCategoryId = isset($_GET['id']) ? intval($_GET['id']) : null;
|
|
||||||
$currentQuery = isset($_GET['q']) ? trim($_GET['q']) : '';
|
|
||||||
$isTagSearch = !empty($currentQuery) && substr($currentQuery, 0, 1) === '#';
|
|
||||||
$currentTag = $isTagSearch ? substr($currentQuery, 1) : '';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<a href="/" class="nav-item <?php echo ($currentPage === 'index.php') ? 'active' : ''; ?>" data-title="Accueil" aria-current="<?php echo ($currentPage === 'index.php') ? 'page' : 'false'; ?>">
|
<a href="/" class="nav-item <?php echo ($currentPage === 'index.php') ? 'active' : ''; ?>" data-title="Accueil" aria-current="<?php echo ($currentPage === 'index.php') ? 'page' : 'false'; ?>">
|
||||||
<i class="fas fa-home" aria-hidden="true"></i> <span>Accueil</span>
|
<i class="fas fa-home" aria-hidden="true"></i> <span>Accueil</span>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ class SimpleAPICache {
|
|||||||
private $cacheDir;
|
private $cacheDir;
|
||||||
private $enabled;
|
private $enabled;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct($cacheDir = null) {
|
||||||
$this->cacheDir = __DIR__ . '/../cache/api';
|
$this->cacheDir = $cacheDir ?? (__DIR__ . '/../cache/api');
|
||||||
$this->enabled = true;
|
$this->enabled = true;
|
||||||
|
|
||||||
// Créer le répertoire de cache s'il n'existe pas
|
// Créer le répertoire de cache s'il n'existe pas
|
||||||
@@ -66,7 +66,26 @@ class SimpleAPICache {
|
|||||||
'created' => time()
|
'created' => time()
|
||||||
];
|
];
|
||||||
|
|
||||||
file_put_contents($file, json_encode($data));
|
// Verrou exclusif : évite les écritures concurrentes tronquées
|
||||||
|
file_put_contents($file, json_encode($data), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vide entièrement le cache (entrées valides comme expirées)
|
||||||
|
*
|
||||||
|
* @return int Nombre de fichiers supprimés
|
||||||
|
*/
|
||||||
|
public function clear() {
|
||||||
|
$files = glob($this->cacheDir . '/cache_*.json');
|
||||||
|
$deleted = 0;
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (unlink($file)) {
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,30 +110,57 @@ class SimpleAPICache {
|
|||||||
// Instance globale
|
// Instance globale
|
||||||
$GLOBALS['simple_api_cache'] = new SimpleAPICache();
|
$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
|
* Version cachée de callPeerTubeApi - remplace l'originale
|
||||||
*/
|
*/
|
||||||
function callPeerTubeApiCached($endpoint, $params = []) {
|
function callPeerTubeApiCached($endpoint, $params = []) {
|
||||||
$cache = $GLOBALS['simple_api_cache'];
|
// Cache désactivé : appel direct, sans lecture ni écriture
|
||||||
|
if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) {
|
||||||
// TTL selon le type de contenu
|
return callPeerTubeApiOriginal($endpoint, $params);
|
||||||
$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 = $GLOBALS['simple_api_cache'];
|
||||||
|
$ttl = getPeerTubeCacheTtl($endpoint);
|
||||||
|
|
||||||
// Essayer le cache d'abord
|
// Essayer le cache d'abord
|
||||||
$cached = $cache->get($endpoint, $params);
|
$cached = $cache->get($endpoint, $params);
|
||||||
if ($cached !== null) {
|
if ($cached !== null) {
|
||||||
|
|||||||
@@ -5,6 +5,18 @@
|
|||||||
* pour améliorer le SEO et l'affichage dans les moteurs de recherche
|
* pour améliorer le SEO et l'affichage dans les moteurs de recherche
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drapeaux json_encode pour la sortie JSON-LD.
|
||||||
|
*
|
||||||
|
* Les JSON_HEX_* encodent <, >, &, ' et " en séquences \u00XX : une valeur
|
||||||
|
* contenant "</script>" (titre de vidéo, nom de chaîne, titre d'épisode…)
|
||||||
|
* ne peut plus fermer le bloc <script> ni injecter de HTML dans la page.
|
||||||
|
* Le JSON reste valide et se décode à l'identique.
|
||||||
|
*/
|
||||||
|
if (!defined('JSONLD_ENCODE_FLAGS')) {
|
||||||
|
define('JSONLD_ENCODE_FLAGS', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère le JSON-LD pour un objet WebSite
|
* Génère le JSON-LD pour un objet WebSite
|
||||||
*
|
*
|
||||||
@@ -29,7 +41,7 @@ function generateWebSiteJsonLd() {
|
|||||||
],
|
],
|
||||||
"publisher" => [
|
"publisher" => [
|
||||||
"@type" => "Organization",
|
"@type" => "Organization",
|
||||||
"name" => "OKI",
|
"name" => ORGANIZATION_NAME,
|
||||||
"url" => $baseUrl,
|
"url" => $baseUrl,
|
||||||
"logo" => [
|
"logo" => [
|
||||||
"@type" => "ImageObject",
|
"@type" => "ImageObject",
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +81,8 @@ function generateVideoObjectJsonLd($videoData, $video) {
|
|||||||
? truncateText(strip_tags($video['description']), 300)
|
? truncateText(strip_tags($video['description']), 300)
|
||||||
: "Regardez cette vidéo sur " . SITE_NAME,
|
: "Regardez cette vidéo sur " . SITE_NAME,
|
||||||
"url" => $videoUrl,
|
"url" => $videoUrl,
|
||||||
|
"embedUrl" => PEERTUBE_URL . "/videos/embed/" . $video['id'],
|
||||||
|
"inLanguage" => "fr-FR",
|
||||||
"thumbnailUrl" => $thumbnailUrl,
|
"thumbnailUrl" => $thumbnailUrl,
|
||||||
"uploadDate" => formatDateISO8601($video['date']),
|
"uploadDate" => formatDateISO8601($video['date']),
|
||||||
"duration" => $duration,
|
"duration" => $duration,
|
||||||
@@ -156,7 +170,148 @@ function generateVideoObjectJsonLd($videoData, $video) {
|
|||||||
$data["videoFrameSize"] = "Portrait";
|
$data["videoFrameSize"] = "Portrait";
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
return json_encode($data, JSONLD_ENCODE_FLAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le JSON-LD pour un podcast (PodcastSeries + PodcastEpisode + AudioObject)
|
||||||
|
*
|
||||||
|
* @param array $episodes Épisodes formatés par getCastopodEpisodes()
|
||||||
|
* @return string JSON-LD pour le podcast, chaîne vide si aucun épisode
|
||||||
|
*/
|
||||||
|
function generatePodcastJsonLd($episodes) {
|
||||||
|
if (empty($episodes)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = getBaseUrl();
|
||||||
|
$first = $episodes[0];
|
||||||
|
|
||||||
|
// Informations de la série, déduites du flux RSS
|
||||||
|
$seriesName = !empty($first['podcastTitle']) ? $first['podcastTitle'] : SITE_NAME;
|
||||||
|
$seriesUrl = !empty($first['podcastLink']) ? $first['podcastLink'] : $baseUrl;
|
||||||
|
$seriesImage = !empty($first['image']) ? $first['image'] : $baseUrl . '/img/logo.png';
|
||||||
|
$webFeed = !empty($first['podcastSlug'])
|
||||||
|
? rtrim(CASTOPOD_URL, '/') . '/@' . $first['podcastSlug'] . '/feed'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$series = [
|
||||||
|
"@type" => "PodcastSeries",
|
||||||
|
"@id" => $seriesUrl . '#podcast',
|
||||||
|
"name" => $seriesName,
|
||||||
|
"url" => $seriesUrl,
|
||||||
|
"image" => $seriesImage,
|
||||||
|
"inLanguage" => "fr-FR",
|
||||||
|
"author" => [
|
||||||
|
"@type" => "Organization",
|
||||||
|
"name" => ORGANIZATION_NAME,
|
||||||
|
"url" => $baseUrl
|
||||||
|
],
|
||||||
|
"publisher" => [
|
||||||
|
"@type" => "Organization",
|
||||||
|
"name" => SITE_NAME,
|
||||||
|
"url" => $baseUrl,
|
||||||
|
"logo" => [
|
||||||
|
"@type" => "ImageObject",
|
||||||
|
"url" => $baseUrl . "/img/logo.png"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($webFeed) {
|
||||||
|
$series["webFeed"] = $webFeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$episodeItems = [];
|
||||||
|
foreach ($episodes as $episode) {
|
||||||
|
$item = [
|
||||||
|
"@type" => "PodcastEpisode",
|
||||||
|
"name" => $episode['title'],
|
||||||
|
"url" => $episode['link'],
|
||||||
|
"partOfSeries" => ["@id" => $seriesUrl . '#podcast']
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($episode['pubDate'])) {
|
||||||
|
$item["datePublished"] = formatDateISO8601($episode['pubDate']);
|
||||||
|
}
|
||||||
|
if (!empty($episode['image'])) {
|
||||||
|
$item["image"] = $episode['image'];
|
||||||
|
}
|
||||||
|
if (!empty($episode['description'])) {
|
||||||
|
$item["description"] = truncateText($episode['description'], 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = parseDurationToSeconds($episode['duration'] ?? '');
|
||||||
|
if ($seconds > 0) {
|
||||||
|
$item["timeRequired"] = formatDurationISO8601($seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($episode['audioUrl'])) {
|
||||||
|
$audio = [
|
||||||
|
"@type" => "AudioObject",
|
||||||
|
"contentUrl" => $episode['audioUrl']
|
||||||
|
];
|
||||||
|
$format = guessAudioEncodingFormat($episode['audioUrl']);
|
||||||
|
if ($format !== null) {
|
||||||
|
$audio["encodingFormat"] = $format;
|
||||||
|
}
|
||||||
|
$item["associatedMedia"] = $audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
$episodeItems[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
"@context" => "https://schema.org",
|
||||||
|
"@graph" => array_merge([$series], $episodeItems)
|
||||||
|
];
|
||||||
|
|
||||||
|
return json_encode($data, JSONLD_ENCODE_FLAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une durée iTunes en secondes
|
||||||
|
* Formats acceptés : "3600", "90", "MM:SS", "HH:MM:SS"
|
||||||
|
*
|
||||||
|
* @param string $duration Durée brute issue du flux RSS
|
||||||
|
* @return int Durée en secondes (0 si non parsable)
|
||||||
|
*/
|
||||||
|
function parseDurationToSeconds($duration) {
|
||||||
|
$duration = trim((string) $duration);
|
||||||
|
if ($duration === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($duration, ':') !== false) {
|
||||||
|
$parts = array_map('intval', explode(':', $duration));
|
||||||
|
$seconds = 0;
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$seconds = $seconds * 60 + $part;
|
||||||
|
}
|
||||||
|
return $seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int) $duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déduit le type MIME d'un fichier audio depuis son extension
|
||||||
|
*
|
||||||
|
* @param string $url URL du fichier audio
|
||||||
|
* @return string|null Type MIME (audio/mpeg, audio/mp4, audio/ogg) ou null
|
||||||
|
*/
|
||||||
|
function guessAudioEncodingFormat($url) {
|
||||||
|
$extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
||||||
|
$map = [
|
||||||
|
'mp3' => 'audio/mpeg',
|
||||||
|
'm4a' => 'audio/mp4',
|
||||||
|
'mp4' => 'audio/mp4',
|
||||||
|
'ogg' => 'audio/ogg',
|
||||||
|
'opus' => 'audio/ogg',
|
||||||
|
'wav' => 'audio/wav'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $map[$extension] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,7 +339,7 @@ function generateBreadcrumbJsonLd($breadcrumbs) {
|
|||||||
"itemListElement" => $listItems
|
"itemListElement" => $listItems
|
||||||
];
|
];
|
||||||
|
|
||||||
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
return json_encode($data, JSONLD_ENCODE_FLAGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -275,36 +430,132 @@ function formatDateISO8601($dateString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tronque un texte à une longueur donnée
|
* Formate une date en français (remplace strftime, déprécié depuis PHP 8.1)
|
||||||
|
*
|
||||||
|
* Utilise IntlDateFormatter quand l'extension intl est disponible,
|
||||||
|
* sinon replie sur un formatage manuel (mois français en toutes lettres).
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface $date Date à formater
|
||||||
|
* @param bool $withTime true pour ajouter « à HH:mm »
|
||||||
|
* @return string Date formatée, ex. « 11 octobre 2025 » ou « 11 octobre 2025 à 00:00 »
|
||||||
|
*/
|
||||||
|
function formatDateFr($date, $withTime = false) {
|
||||||
|
if (class_exists('IntlDateFormatter')) {
|
||||||
|
$formatter = new IntlDateFormatter(
|
||||||
|
'fr_FR',
|
||||||
|
IntlDateFormatter::FULL,
|
||||||
|
IntlDateFormatter::FULL,
|
||||||
|
$date->getTimezone(), // Le formateur ignore le fuseau du DateTime sans ça
|
||||||
|
IntlDateFormatter::GREGORIAN,
|
||||||
|
$withTime ? "d MMMM yyyy 'à' HH:mm" : 'd MMMM yyyy'
|
||||||
|
);
|
||||||
|
$formatted = $formatter->format($date);
|
||||||
|
if ($formatted !== false) {
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repli sans extension intl : mois français en toutes lettres
|
||||||
|
$months = [
|
||||||
|
1 => 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
|
||||||
|
];
|
||||||
|
$formatted = $date->format('j') . ' ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
|
||||||
|
|
||||||
|
if ($withTime) {
|
||||||
|
$formatted .= ' à ' . $date->format('H:i');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tronque un texte à une longueur donnée (en caractères UTF-8)
|
||||||
*
|
*
|
||||||
* @param string $text Texte à tronquer
|
* @param string $text Texte à tronquer
|
||||||
* @param int $length Longueur maximale
|
* @param int $length Longueur maximale
|
||||||
* @return string Texte tronqué
|
* @return string Texte tronqué
|
||||||
*/
|
*/
|
||||||
function truncateText($text, $length = 200) {
|
function truncateText($text, $length = 200) {
|
||||||
if (strlen($text) <= $length) {
|
if (mb_strlen($text) <= $length) {
|
||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
$truncated = substr($text, 0, $length);
|
$truncated = mb_substr($text, 0, $length);
|
||||||
$lastSpace = strrpos($truncated, ' ');
|
$lastSpace = mb_strrpos($truncated, ' ');
|
||||||
|
|
||||||
if ($lastSpace !== false) {
|
if ($lastSpace !== false) {
|
||||||
$truncated = substr($truncated, 0, $lastSpace);
|
$truncated = mb_substr($truncated, 0, $lastSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $truncated . '...';
|
return $truncated . '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtient l'URL de base du site
|
* Valide un nom d'hôte applicatif (hostname, IPv4 ou localhost, port optionnel)
|
||||||
|
*
|
||||||
|
* @param string $host Hôte à valider
|
||||||
|
* @return bool True si l'hôte est exploitable en toute sécurité dans une URL
|
||||||
|
*/
|
||||||
|
function isValidAppHostName($host) {
|
||||||
|
if (!is_string($host) || $host === '' || strlen($host) > 253) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels alphanumériques/tirets séparés par des points, port optionnel
|
||||||
|
return (bool) preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/i', $host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nom d'hôte de l'application, validé une fois à l'initialisation.
|
||||||
|
*
|
||||||
|
* Utilise la constante APP_HOST_NAME (configuration) et jamais l'en-tête
|
||||||
|
* HTTP_HOST fourni par le client, afin d'empêcher l'injection via Host.
|
||||||
|
*
|
||||||
|
* @return string Nom d'hôte validé ('localhost' en repli)
|
||||||
|
*/
|
||||||
|
function getAppHostName() {
|
||||||
|
static $validatedHost = null;
|
||||||
|
|
||||||
|
if ($validatedHost === null) {
|
||||||
|
$configuredHost = defined('APP_HOST_NAME') ? (string) APP_HOST_NAME : '';
|
||||||
|
if (isValidAppHostName($configuredHost)) {
|
||||||
|
$validatedHost = $configuredHost;
|
||||||
|
} else {
|
||||||
|
error_log('SECURITY: APP_HOST_NAME absent ou invalide, repli sur localhost');
|
||||||
|
$validatedHost = 'localhost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validatedHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient l'URL de base du site (schéma + hôte validé)
|
||||||
*
|
*
|
||||||
* @return string URL de base
|
* @return string URL de base
|
||||||
*/
|
*/
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
|
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
return $scheme . '://' . getAppHostName();
|
||||||
return $scheme . '://' . $host;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL absolue de la page courante.
|
||||||
|
*
|
||||||
|
* L'hôte provient de getBaseUrl() (APP_HOST_NAME validé, pas l'en-tête Host)
|
||||||
|
* et REQUEST_URI est débarrassé des guillemets, chevrons, espaces et
|
||||||
|
* caractères de contrôle qui casseraient un attribut HTML ou une URL.
|
||||||
|
*
|
||||||
|
* @return string URL absolue de la requête courante
|
||||||
|
*/
|
||||||
|
function getCurrentUrl() {
|
||||||
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$requestUri = preg_replace('/[\x00-\x20"<>\'`]/', '', $requestUri);
|
||||||
|
if ($requestUri === '' || $requestUri[0] !== '/') {
|
||||||
|
$requestUri = '/' . $requestUri;
|
||||||
|
}
|
||||||
|
return getBaseUrl() . $requestUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
|
|||||||
require_once 'includes/structured-data.php';
|
require_once 'includes/structured-data.php';
|
||||||
// Appliquer les en-têtes de sécurité
|
// Appliquer les en-têtes de sécurité
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
|
|
||||||
|
// Récupérer les épisodes Castopod (réutilisés pour le JSON-LD et l'affichage)
|
||||||
|
$castopodEpisodes = [];
|
||||||
|
if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') && !empty(CASTOPOD_URL)) {
|
||||||
|
$castopodEpisodes = getCastopodEpisodes();
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
@@ -21,8 +27,10 @@ setSecurityHeaders();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||||
<title><?php echo SITE_NAME; ?></title>
|
<title><?php echo SITE_NAME; ?></title>
|
||||||
|
<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="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>">
|
<link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>">
|
||||||
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?>
|
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?>
|
||||||
<link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>">
|
<link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>">
|
||||||
@@ -54,9 +62,9 @@ setSecurityHeaders();
|
|||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:title" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:description" content="Découvrez notre plateforme multimédia avec des vidéos, des shorts et des directs. Tendances, catégories et contenus exclusifs vous attendent.">
|
<meta property="og:description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>">
|
||||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
<meta property="og:url" content="<?php echo getBaseUrl() . '/'; ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:locale" content="fr_FR">
|
<meta property="og:locale" content="fr_FR">
|
||||||
@@ -64,8 +72,8 @@ setSecurityHeaders();
|
|||||||
<!-- Twitter Card Meta Tags -->
|
<!-- Twitter Card Meta Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="<?php echo SITE_NAME; ?>">
|
<meta name="twitter:title" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta name="twitter:description" content="Découvrez notre plateforme multimédia avec des vidéos, des shorts et des directs. Tendances, catégories et contenus exclusifs vous attendent.">
|
<meta name="twitter:description" content="<?php echo htmlspecialchars(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'; ?>">
|
||||||
|
|
||||||
<!-- Données structurées JSON-LD pour le site web -->
|
<!-- Données structurées JSON-LD pour le site web -->
|
||||||
<?php
|
<?php
|
||||||
@@ -78,6 +86,11 @@ setSecurityHeaders();
|
|||||||
];
|
];
|
||||||
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
|
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
|
||||||
outputJsonLd($breadcrumbJsonLd);
|
outputJsonLd($breadcrumbJsonLd);
|
||||||
|
|
||||||
|
// Données structurées du podcast (PodcastSeries + PodcastEpisode)
|
||||||
|
if (!empty($castopodEpisodes)) {
|
||||||
|
outputJsonLd(generatePodcastJsonLd($castopodEpisodes));
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Script pour éviter le flash en mode sombre -->
|
<!-- Script pour éviter le flash en mode sombre -->
|
||||||
@@ -95,7 +108,7 @@ setSecurityHeaders();
|
|||||||
|
|
||||||
<!-- ------ Script Plausible ------ -->
|
<!-- ------ Script Plausible ------ -->
|
||||||
|
|
||||||
<!-- <script defer data-domain="<?php echo $_SERVER['HTTP_HOST'] ?>" src="https://plausible.io/js/script.hash.outbound-links.pageview-props.tagged-events.js"></script> -->
|
<!-- <script defer data-domain="<?php echo htmlspecialchars(APP_HOST_NAME); ?>" src="https://plausible.io/js/script.hash.outbound-links.pageview-props.tagged-events.js"></script> -->
|
||||||
<!-- <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script> -->
|
<!-- <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script> -->
|
||||||
|
|
||||||
<!-- ------ Script Plausible ------ -->
|
<!-- ------ Script Plausible ------ -->
|
||||||
@@ -105,6 +118,7 @@ setSecurityHeaders();
|
|||||||
<?php include 'includes/sidebar.php'; ?>
|
<?php include 'includes/sidebar.php'; ?>
|
||||||
<!-- Contenu principal -->
|
<!-- Contenu principal -->
|
||||||
<main class="main-content" id="main-content" role="main">
|
<main class="main-content" id="main-content" role="main">
|
||||||
|
<h1 class="sr-only"><?php echo SITE_NAME . ' — ' . SITE_DESCRIPTION; ?></h1>
|
||||||
<?php include 'includes/header.php'; ?>
|
<?php include 'includes/header.php'; ?>
|
||||||
<!-- Hero and Mastodon container -->
|
<!-- Hero and Mastodon container -->
|
||||||
<div class="hero-mastodon-wrapper">
|
<div class="hero-mastodon-wrapper">
|
||||||
@@ -130,8 +144,7 @@ setSecurityHeaders();
|
|||||||
</div>
|
</div>
|
||||||
<div class="castopod-episodes-list">
|
<div class="castopod-episodes-list">
|
||||||
<?php
|
<?php
|
||||||
$castopodEpisodes = getCastopodEpisodes();
|
// $castopodEpisodes a été récupéré en début de page (JSON-LD + affichage)
|
||||||
|
|
||||||
if (empty($castopodEpisodes)) {
|
if (empty($castopodEpisodes)) {
|
||||||
echo '<div class="castopod-no-episodes">Aucun épisode disponible</div>';
|
echo '<div class="castopod-no-episodes">Aucun épisode disponible</div>';
|
||||||
} else {
|
} else {
|
||||||
@@ -390,7 +403,7 @@ setSecurityHeaders();
|
|||||||
<section class="video-section" aria-labelledby="shorts-heading">
|
<section class="video-section" aria-labelledby="shorts-heading">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-logo">
|
<div class="section-logo">
|
||||||
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
|
<img src="img/logo.png" alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<h2 id="shorts-heading" class="section-title">Shorts</h2>
|
<h2 id="shorts-heading" class="section-title">Shorts</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,9 +421,9 @@ setSecurityHeaders();
|
|||||||
foreach ($shorts as $video):
|
foreach ($shorts as $video):
|
||||||
?>
|
?>
|
||||||
<div class="carousel-item">
|
<div class="carousel-item">
|
||||||
<article class="video-card short-card" data-video-id="<?php echo $video['id']; ?>">
|
<article class="video-card short-card" data-video-id="<?php echo e($video['id']); ?>">
|
||||||
<div class="video-thumbnail short-thumbnail">
|
<div class="video-thumbnail short-thumbnail">
|
||||||
<img src="<?php echo $video['thumbnail']; ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
|
<img src="<?php echo e($video['thumbnail']); ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
|
||||||
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
|
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
|
||||||
<?php echo formatDuration($video['duration']); ?>
|
<?php echo formatDuration($video['duration']); ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +459,7 @@ setSecurityHeaders();
|
|||||||
<section class="video-section" aria-labelledby="recent-videos-heading">
|
<section class="video-section" aria-labelledby="recent-videos-heading">
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
<div class="section-logo">
|
<div class="section-logo">
|
||||||
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
|
<img src="img/logo.png" alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<h2 id="recent-videos-heading" class="section-title">Dernières vidéos</h2>
|
<h2 id="recent-videos-heading" class="section-title">Dernières vidéos</h2>
|
||||||
</header>
|
</header>
|
||||||
@@ -461,38 +474,7 @@ setSecurityHeaders();
|
|||||||
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
|
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
|
||||||
} else {
|
} else {
|
||||||
foreach ($recentVideos as $video):
|
foreach ($recentVideos as $video):
|
||||||
?>
|
echo renderVideoCard($video);
|
||||||
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
|
|
||||||
<div class="video-thumbnail">
|
|
||||||
<img src="<?php echo $video['thumbnail']; ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
|
|
||||||
<div class="video-play-icon" aria-hidden="true">
|
|
||||||
<i class="fas fa-play-circle"></i>
|
|
||||||
</div>
|
|
||||||
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
|
|
||||||
<?php echo formatDuration($video['duration']); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="video-info">
|
|
||||||
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
|
|
||||||
<div class="video-channel">
|
|
||||||
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
|
|
||||||
<div class="channel-avatar-placeholder">
|
|
||||||
<i class="fas fa-user-circle"></i>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="channel-name"><?php echo $video['channel']; ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-metadata">
|
|
||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
|
||||||
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php
|
|
||||||
endforeach;
|
endforeach;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -508,7 +490,7 @@ setSecurityHeaders();
|
|||||||
<section class="video-section" aria-labelledby="trending-videos-heading">
|
<section class="video-section" aria-labelledby="trending-videos-heading">
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
<div class="section-logo">
|
<div class="section-logo">
|
||||||
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
|
<img src="img/logo.png" alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<h2 id="trending-videos-heading" class="section-title">Tendances</h2>
|
<h2 id="trending-videos-heading" class="section-title">Tendances</h2>
|
||||||
</header>
|
</header>
|
||||||
@@ -523,36 +505,7 @@ setSecurityHeaders();
|
|||||||
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
|
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
|
||||||
} else {
|
} else {
|
||||||
foreach ($trendingVideos as $video):
|
foreach ($trendingVideos as $video):
|
||||||
?>
|
echo renderVideoCard($video);
|
||||||
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
|
|
||||||
<div class="video-thumbnail">
|
|
||||||
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>">
|
|
||||||
<div class="video-play-icon">
|
|
||||||
<i class="fas fa-play-circle"></i>
|
|
||||||
</div>
|
|
||||||
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
|
|
||||||
</div>
|
|
||||||
<div class="video-info">
|
|
||||||
<h3 class="video-title"><?php echo $video['title']; ?></h3>
|
|
||||||
<div class="video-channel">
|
|
||||||
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
|
|
||||||
<div class="channel-avatar-placeholder">
|
|
||||||
<i class="fas fa-user-circle"></i>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="channel-name"><?php echo $video['channel']; ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-metadata">
|
|
||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
|
||||||
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php
|
|
||||||
endforeach;
|
endforeach;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -573,45 +526,18 @@ setSecurityHeaders();
|
|||||||
foreach ($displayCategories as $category):
|
foreach ($displayCategories as $category):
|
||||||
if (!empty($category['videos'])):
|
if (!empty($category['videos'])):
|
||||||
?>
|
?>
|
||||||
<!-- Section Catégorie: <?php echo $category['name']; ?> -->
|
<!-- Section Catégorie: <?php echo e($category['name']); ?> -->
|
||||||
<section class="video-section" data-category-id="<?php echo $category['id']; ?>" aria-labelledby="category-heading-<?php echo $category['id']; ?>">
|
<section class="video-section" data-category-id="<?php echo e($category['id']); ?>" aria-labelledby="category-heading-<?php echo e($category['id']); ?>">
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
<div class="section-logo">
|
<div class="section-logo">
|
||||||
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
|
<img src="img/logo.png" alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<h2 id="category-heading-<?php echo $category['id']; ?>" class="section-title"><?php echo htmlspecialchars($category['name']); ?></h2>
|
<h2 id="category-heading-<?php echo e($category['id']); ?>" class="section-title"><?php echo htmlspecialchars($category['name']); ?></h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="video-grid">
|
<div class="video-grid">
|
||||||
<?php foreach ($category['videos'] as $video): ?>
|
<?php foreach ($category['videos'] as $video): ?>
|
||||||
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
|
<?php echo renderVideoCard($video); ?>
|
||||||
<div class="video-thumbnail">
|
|
||||||
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>">
|
|
||||||
<div class="video-play-icon">
|
|
||||||
<i class="fas fa-play-circle"></i>
|
|
||||||
</div>
|
|
||||||
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
|
|
||||||
</div>
|
|
||||||
<div class="video-info">
|
|
||||||
<h3 class="video-title"><?php echo $video['title']; ?></h3>
|
|
||||||
<div class="video-channel">
|
|
||||||
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
|
|
||||||
<div class="channel-avatar-placeholder">
|
|
||||||
<i class="fas fa-user-circle"></i>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="channel-name"><?php echo $video['channel']; ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-metadata">
|
|
||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
|
||||||
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -654,7 +580,8 @@ setSecurityHeaders();
|
|||||||
|
|
||||||
<!-- Section Tendances Hashtags -->
|
<!-- Section Tendances Hashtags -->
|
||||||
<aside class="tags-section-container" aria-labelledby="hashtags-heading">
|
<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">
|
<div class="tags-section">
|
||||||
<?php
|
<?php
|
||||||
@@ -678,34 +605,11 @@ setSecurityHeaders();
|
|||||||
<script src="js/mastodon-timeline.umd.js"></script>
|
<script src="js/mastodon-timeline.umd.js"></script>
|
||||||
<script src="js/mastodon-config.php?v=<?php echo md5(MASTODON_INSTANCE_URL . MASTODON_DATE_FORMAT . MASTODON_BTN_SEE_MORE . MASTODON_BTN_RELOAD . MASTODON_MAX_POST_FETCH . MASTODON_MAX_POST_SHOW); ?>"></script>
|
<script src="js/mastodon-config.php?v=<?php echo md5(MASTODON_INSTANCE_URL . MASTODON_DATE_FORMAT . MASTODON_BTN_SEE_MORE . MASTODON_BTN_RELOAD . MASTODON_MAX_POST_FETCH . MASTODON_MAX_POST_SHOW); ?>"></script>
|
||||||
|
|
||||||
<!-- PWA Service Worker -->
|
<!-- PWA : enregistrement du Service Worker + modal de mise à jour -->
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
|
|
||||||
|
<!-- PWA : bouton d'installation -->
|
||||||
<script nonce="<?php echo getCspNonce(); ?>">
|
<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
|
// Gestion de l'installation PWA
|
||||||
let deferredPrompt;
|
let deferredPrompt;
|
||||||
const installButton = document.getElementById('install-pwa');
|
const installButton = document.getElementById('install-pwa');
|
||||||
@@ -716,7 +620,7 @@ setSecurityHeaders();
|
|||||||
|
|
||||||
// Afficher le bouton d'installation s'il existe
|
// Afficher le bouton d'installation s'il existe
|
||||||
if (installButton) {
|
if (installButton) {
|
||||||
installButton.style.display = 'block';
|
installButton.classList.remove('is-hidden');
|
||||||
installButton.addEventListener('click', function() {
|
installButton.addEventListener('click', function() {
|
||||||
deferredPrompt.prompt();
|
deferredPrompt.prompt();
|
||||||
deferredPrompt.userChoice.then(function(choiceResult) {
|
deferredPrompt.userChoice.then(function(choiceResult) {
|
||||||
@@ -724,7 +628,7 @@ setSecurityHeaders();
|
|||||||
console.log('PWA installée');
|
console.log('PWA installée');
|
||||||
}
|
}
|
||||||
deferredPrompt = null;
|
deferredPrompt = null;
|
||||||
installButton.style.display = 'none';
|
installButton.classList.add('is-hidden');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -734,7 +638,7 @@ setSecurityHeaders();
|
|||||||
window.addEventListener('appinstalled', function() {
|
window.addEventListener('appinstalled', function() {
|
||||||
console.log('PWA installée avec succès');
|
console.log('PWA installée avec succès');
|
||||||
if (installButton) {
|
if (installButton) {
|
||||||
installButton.style.display = 'none';
|
installButton.classList.add('is-hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -2,9 +2,58 @@
|
|||||||
* Script de compte à rebours pour la page de maintenance
|
* Script de compte à rebours pour la page de maintenance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une date ISO 8601 en timestamp, compatible Safari.
|
||||||
|
*
|
||||||
|
* Safari renvoie Invalid Date pour les formats non strictement ISO
|
||||||
|
* (ex. « 2025-10-11 00:00:00 » émis historiquement par PHP) : la chaîne
|
||||||
|
* est donc analysée manuellement. Sans fuseau explicite, la date est
|
||||||
|
* interprétée en UTC — PHP émet de toute façon toujours un décalage.
|
||||||
|
*
|
||||||
|
* @param {string|number} value Date ISO 8601 (ou timestamp déjà numérique)
|
||||||
|
* @returns {number} Timestamp en millisecondes
|
||||||
|
*/
|
||||||
|
function parseTargetDate(value) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return new Date(value).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = String(value).match(
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
// Repli sur l'analyse native pour les formats inattendus
|
||||||
|
return new Date(value).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, year, month, day, hours = '0', minutes = '0', seconds = '0', offset] = match;
|
||||||
|
|
||||||
|
let timestamp = Date.UTC(
|
||||||
|
parseInt(year, 10),
|
||||||
|
parseInt(month, 10) - 1,
|
||||||
|
parseInt(day, 10),
|
||||||
|
parseInt(hours, 10),
|
||||||
|
parseInt(minutes, 10),
|
||||||
|
parseInt(seconds, 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (offset && offset !== 'Z') {
|
||||||
|
// Un décalage « +02:00 » signifie « 2 h d'avance sur UTC » : on le soustrait
|
||||||
|
const sign = offset[0] === '+' ? 1 : -1;
|
||||||
|
const offsetDigits = offset.slice(1).replace(':', '');
|
||||||
|
const offsetMinutes = sign * (
|
||||||
|
parseInt(offsetDigits.slice(0, 2), 10) * 60 + parseInt(offsetDigits.slice(2, 4), 10)
|
||||||
|
);
|
||||||
|
timestamp -= offsetMinutes * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
class CountdownTimer {
|
class CountdownTimer {
|
||||||
constructor(targetDate) {
|
constructor(targetDate) {
|
||||||
this.targetDate = new Date(targetDate).getTime();
|
this.targetDate = parseTargetDate(targetDate);
|
||||||
this.elements = {
|
this.elements = {
|
||||||
days: document.getElementById('countdown-days'),
|
days: document.getElementById('countdown-days'),
|
||||||
hours: document.getElementById('countdown-hours'),
|
hours: document.getElementById('countdown-hours'),
|
||||||
|
|||||||
@@ -428,8 +428,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
videoType = 'recent';
|
videoType = 'recent';
|
||||||
} else if (sectionTitle.includes('tendances')) {
|
} else if (sectionTitle.includes('tendances')) {
|
||||||
videoType = 'trending';
|
videoType = 'trending';
|
||||||
} else if (sectionTitle.includes('indépendance')) {
|
|
||||||
videoType = 'independence';
|
|
||||||
} else {
|
} else {
|
||||||
// Vérifier si c'est une section de catégorie
|
// Vérifier si c'est une section de catégorie
|
||||||
const categorySection = section.querySelector('[data-category-id]');
|
const categorySection = section.querySelector('[data-category-id]');
|
||||||
@@ -456,7 +454,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
// Préparer l'URL avec les paramètres
|
// 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) {
|
if (videoType === 'category' && categoryId) {
|
||||||
url += `&category=${categoryId}`;
|
url += `&category=${categoryId}`;
|
||||||
}
|
}
|
||||||
@@ -502,16 +500,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Initialiser le lazy loading pour les nouvelles images
|
// Initialiser le lazy loading pour les nouvelles images
|
||||||
initLazyLoading();
|
initLazyLoading();
|
||||||
} else {
|
} 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);
|
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;
|
button.disabled = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 2000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Erreur lors de la requête AJAX:', 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;
|
button.disabled = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 2000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Enregistrement du Service Worker et gestion des mises à jour.
|
||||||
|
*
|
||||||
|
* Quand une nouvelle version du site est déployée (sw.js modifié, suffixe de
|
||||||
|
* version des caches bumpé), le nouveau Service Worker s'installe en arrière-
|
||||||
|
* plan et reste en attente. Un modal propose alors la mise à jour :
|
||||||
|
* - « Mettre à jour » : le SW en attente prend le contrôle (SKIP_WAITING),
|
||||||
|
* les anciens caches sont purgés à l'activation, puis la page se recharge
|
||||||
|
* sur la nouvelle version (controllerchange).
|
||||||
|
* - « Plus tard » : le modal se ferme ; la mise à jour sera reproposée au
|
||||||
|
* prochain chargement de page.
|
||||||
|
*
|
||||||
|
* Aucune purge manuelle du cache navigateur n'est nécessaire.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modalShown = false;
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
|
// Recharge la page quand le nouveau Service Worker prend le contrôle
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||||
|
if (refreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshing = true;
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit et affiche le modal de mise à jour (une seule fois).
|
||||||
|
* @param {ServiceWorker} waitingWorker Le SW en attente d'activation
|
||||||
|
*/
|
||||||
|
function showUpdateModal(waitingWorker) {
|
||||||
|
if (modalShown || !waitingWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalShown = true;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'pwa-update-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-labelledby', 'pwa-update-title');
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="pwa-update-modal">' +
|
||||||
|
'<img class="pwa-update-logo" src="img/logo.png" alt="" aria-hidden="true">' +
|
||||||
|
'<h2 class="pwa-update-title" id="pwa-update-title">Nouvelle version disponible</h2>' +
|
||||||
|
'<p class="pwa-update-text">' +
|
||||||
|
'Une mise à jour du site est prête à être installée. ' +
|
||||||
|
'Elle est rapide et ne supprime aucune de vos données.' +
|
||||||
|
'</p>' +
|
||||||
|
'<div class="pwa-update-actions">' +
|
||||||
|
'<button type="button" class="pwa-update-btn pwa-update-btn-primary">' +
|
||||||
|
'Mettre à jour' +
|
||||||
|
'</button>' +
|
||||||
|
'<button type="button" class="pwa-update-btn pwa-update-btn-secondary">' +
|
||||||
|
'Plus tard' +
|
||||||
|
'</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Force le reflow pour que la transition CSS d'entrée joue
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.classList.add('pwa-update-visible');
|
||||||
|
|
||||||
|
const primaryBtn = overlay.querySelector('.pwa-update-btn-primary');
|
||||||
|
const secondaryBtn = overlay.querySelector('.pwa-update-btn-secondary');
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
overlay.classList.remove('pwa-update-visible');
|
||||||
|
overlay.addEventListener('transitionend', function () {
|
||||||
|
overlay.remove();
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryBtn.addEventListener('click', function () {
|
||||||
|
primaryBtn.disabled = true;
|
||||||
|
secondaryBtn.disabled = true;
|
||||||
|
primaryBtn.textContent = 'Mise à jour…';
|
||||||
|
// Le rechargement est déclenché par l'event controllerchange
|
||||||
|
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
});
|
||||||
|
|
||||||
|
secondaryBtn.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function onEscape(event) {
|
||||||
|
if (event.key === 'Escape' && document.body.contains(overlay)) {
|
||||||
|
closeModal();
|
||||||
|
document.removeEventListener('keydown', onEscape);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accessibilité : focus sur l'action principale
|
||||||
|
primaryBtn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surveille l'installation d'un nouveau Service Worker.
|
||||||
|
* @param {ServiceWorkerRegistration} registration
|
||||||
|
*/
|
||||||
|
function trackInstalling(registration) {
|
||||||
|
registration.addEventListener('updatefound', function () {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (!newWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newWorker.addEventListener('statechange', function () {
|
||||||
|
// installed + un contrôleur actif = mise à jour en attente
|
||||||
|
// (sans contrôleur, c'est la toute première installation)
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
showUpdateModal(registration.waiting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(function (registration) {
|
||||||
|
// Cas où une mise à jour est déjà en attente (installée lors
|
||||||
|
// d'une visite précédente, jamais activée)
|
||||||
|
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||||
|
showUpdateModal(registration.waiting);
|
||||||
|
}
|
||||||
|
trackInstalling(registration);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.log('Échec de l\'enregistrement du Service Worker:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
// Inclure la configuration
|
||||||
|
require_once 'includes/config.php';
|
||||||
|
// Appliquer les en-têtes de sécurité
|
||||||
|
setSecurityHeaders();
|
||||||
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Mentions Légales - FEDIVERSE OKI</title>
|
<title>Mentions Légales - <?php echo SITE_NAME; ?></title>
|
||||||
|
<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="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -13,25 +21,25 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
||||||
<link rel="manifest" href="site.webmanifest">
|
<link rel="manifest" href="site.webmanifest">
|
||||||
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#FF0000">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="Mentions Légales - FEDIVERSE OKI">
|
<meta property="og:title" content="Mentions Légales - <?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:description" content="Consultez les mentions légales de FEDIVERSE OKI. Informations légales, conditions d'utilisation et politique de confidentialité de notre plateforme multimédia.">
|
<meta property="og:description" content="Consultez les mentions légales de <?php echo SITE_NAME; ?>. Informations légales, conditions d'utilisation et politique de confidentialité.">
|
||||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<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:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="FEDIVERSE OKI">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:locale" content="fr_FR">
|
<meta property="og:locale" content="fr_FR">
|
||||||
|
|
||||||
<!-- Twitter Card Meta Tags -->
|
<!-- Twitter Card Meta Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Mentions Légales - FEDIVERSE OKI">
|
<meta name="twitter:title" content="Mentions Légales - <?php echo SITE_NAME; ?>">
|
||||||
<meta name="twitter:description" content="Consultez les mentions légales de FEDIVERSE OKI. Informations légales, conditions d'utilisation et politique de confidentialité de notre plateforme multimédia.">
|
<meta name="twitter:description" content="Consultez les mentions légales de <?php echo SITE_NAME; ?>. Informations légales, conditions d'utilisation et politique de confidentialité.">
|
||||||
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
||||||
|
|
||||||
<!-- Script pour éviter le flash en mode sombre -->
|
<!-- Script pour éviter le flash en mode sombre -->
|
||||||
<script>
|
<script nonce="<?php echo getCspNonce(); ?>">
|
||||||
(function() {
|
(function() {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
@@ -44,12 +52,6 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<?php
|
|
||||||
// Inclure la configuration
|
|
||||||
require_once 'includes/config.php';
|
|
||||||
// Appliquer les en-têtes de sécurité
|
|
||||||
setSecurityHeaders();
|
|
||||||
?>
|
|
||||||
<?php include 'includes/sidebar.php'; ?>
|
<?php include 'includes/sidebar.php'; ?>
|
||||||
<!-- Contenu principal -->
|
<!-- Contenu principal -->
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -58,9 +60,9 @@
|
|||||||
<!-- Section Mentions Légales -->
|
<!-- Section Mentions Légales -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-logo">
|
<div class="section-logo">
|
||||||
<img src="img/logo.png" alt="FEDIVERSE OKI">
|
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
|
||||||
</div>
|
</div>
|
||||||
<h2 class="section-title">Mentions Légales</h2>
|
<h1 class="section-title">Mentions Légales</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
<h3 class="info-header">2. Description du service</h3>
|
<h3 class="info-header">2. Description du service</h3>
|
||||||
<p class="info-text">
|
<p class="info-text">
|
||||||
<strong><?php echo SITE_NAME; ?></strong> <?php echo LEGAL_SERVICE_DESCRIPTION; ?>
|
<strong><?php echo SITE_NAME; ?></strong> <?php echo LEGAL_SERVICE_DESCRIPTION; ?>
|
||||||
|
Le site agrège les contenus du podcast disponibles sur PeerTube, Castopod et Mastodon.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="info-header">3. Responsabilité des utilisateurs</h3>
|
<h3 class="info-header">3. Responsabilité des utilisateurs</h3>
|
||||||
@@ -184,7 +187,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="info-text">
|
<p class="info-text">
|
||||||
Le code source de ce site est disponible sur Codeberg : <a href="<?php echo LEGAL_SOURCE_CODE_URL; ?>" target="_blank" rel="noopener noreferrer"><?php echo LEGAL_SOURCE_CODE_URL; ?></a>
|
Le code source de ce site est disponible sur LaBola : <a href="<?php echo LEGAL_SOURCE_CODE_URL; ?>" target="_blank" rel="noopener noreferrer"><?php echo LEGAL_SOURCE_CODE_URL; ?></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="info-text">
|
||||||
|
<strong>ANNU KUTE CED</strong> est un fork de <strong>FEDIVERSE OKI</strong>, développé par l'ORGANISATION KA INTERNATIONALE (OKI) sous licence AGPL-V3. Le fork est maintenu par le propriétaire du dépôt <em>cedric</em> (Cédric Famibelle-Pronzola), en accord avec la licence d'origine.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="info-header">9. Icônes et marques tierces</h3>
|
<h3 class="info-header">9. Icônes et marques tierces</h3>
|
||||||
@@ -238,5 +245,6 @@
|
|||||||
<?php include 'includes/footer.php'; ?>
|
<?php include 'includes/footer.php'; ?>
|
||||||
<?php include 'includes/mobile-menu.php'; ?>
|
<?php include 'includes/mobile-menu.php'; ?>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -24,26 +24,33 @@ $currentSearchPage = validatePageNumber($_GET['page'] ?? 1);
|
|||||||
$isTagSearch = !empty($query) && substr($query, 0, 1) === '#';
|
$isTagSearch = !empty($query) && substr($query, 0, 1) === '#';
|
||||||
$searchTag = $isTagSearch ? substr($query, 1) : '';
|
$searchTag = $isTagSearch ? substr($query, 1) : '';
|
||||||
|
|
||||||
// Rechercher les vidéos via l'API PeerTube si une requête est soumise
|
// Rechercher les vidéos via l'API PeerTube si une requête est soumise.
|
||||||
$searchResults = !empty($query) ? searchVideos($query, COUNT_VIDEO_SEARCH) : [];
|
// La pagination est déléguée à l'API (paramètre start) : chaque page ne
|
||||||
|
// récupère que VIDEOS_PER_PAGE vidéos et $resultsCount reçoit le total réel.
|
||||||
// Définir le nombre total de résultats
|
$resultsCount = 0;
|
||||||
$resultsCount = count($searchResults);
|
|
||||||
|
|
||||||
// Calculer le nombre total de pages
|
|
||||||
$totalPages = ceil($resultsCount / VIDEOS_PER_PAGE);
|
|
||||||
|
|
||||||
// S'assurer que la page actuelle est valide
|
|
||||||
$currentSearchPage = min($currentSearchPage, max(1, $totalPages));
|
|
||||||
|
|
||||||
// Calculer les indices de début et de fin pour la page actuelle
|
|
||||||
$startIndex = ($currentSearchPage - 1) * VIDEOS_PER_PAGE;
|
|
||||||
$endIndex = min($startIndex + VIDEOS_PER_PAGE, $resultsCount);
|
|
||||||
|
|
||||||
// Extraire les vidéos pour la page actuelle
|
|
||||||
$currentPageVideos = [];
|
$currentPageVideos = [];
|
||||||
if ($resultsCount > 0) {
|
$totalPages = 1;
|
||||||
$currentPageVideos = array_slice($searchResults, $startIndex, VIDEOS_PER_PAGE);
|
if (!empty($query)) {
|
||||||
|
$currentPageVideos = searchVideos(
|
||||||
|
$query,
|
||||||
|
VIDEOS_PER_PAGE,
|
||||||
|
($currentSearchPage - 1) * VIDEOS_PER_PAGE,
|
||||||
|
$resultsCount
|
||||||
|
);
|
||||||
|
$totalPages = max(1, (int) ceil($resultsCount / VIDEOS_PER_PAGE));
|
||||||
|
|
||||||
|
// Page demandée au-delà de la dernière : se replacer sur la dernière page valide
|
||||||
|
if ($currentSearchPage > $totalPages) {
|
||||||
|
$currentSearchPage = $totalPages;
|
||||||
|
$currentPageVideos = searchVideos(
|
||||||
|
$query,
|
||||||
|
VIDEOS_PER_PAGE,
|
||||||
|
($currentSearchPage - 1) * VIDEOS_PER_PAGE,
|
||||||
|
$resultsCount
|
||||||
|
);
|
||||||
|
// Le total a pu changer (ex. API en échec sur un start trop élevé)
|
||||||
|
$totalPages = max(1, (int) ceil($resultsCount / VIDEOS_PER_PAGE));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -53,8 +60,15 @@ if ($resultsCount > 0) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?></title>
|
<title><?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?></title>
|
||||||
|
<meta name="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; ?>">
|
||||||
|
<?php if (!empty($query)): ?>
|
||||||
|
<meta name="robots" content="noindex, follow">
|
||||||
|
<link rel="canonical" href="<?php echo getBaseUrl() . '/recherche.php?q=' . urlencode($query); ?>">
|
||||||
|
<?php else: ?>
|
||||||
|
<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="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -62,13 +76,13 @@ if ($resultsCount > 0) {
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
||||||
<link rel="manifest" href="site.webmanifest">
|
<link rel="manifest" href="site.webmanifest">
|
||||||
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#FF0000">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
|
<meta property="og:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour \"' . htmlspecialchars($query) . '\" sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. Plateforme multimédia avec un contenu de qualité et exclusif.'; ?>">
|
<meta property="og:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour « ' . htmlspecialchars($query) . ' » sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. ' . SITE_DESCRIPTION; ?>">
|
||||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:locale" content="fr_FR">
|
<meta property="og:locale" content="fr_FR">
|
||||||
@@ -76,8 +90,8 @@ if ($resultsCount > 0) {
|
|||||||
<!-- Twitter Card Meta Tags -->
|
<!-- Twitter Card Meta Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
|
<meta name="twitter:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
|
||||||
<meta name="twitter:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour \"' . htmlspecialchars($query) . '\" sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. Plateforme multimédia avec un contenu de qualité et exclusif.'; ?>">
|
<meta name="twitter:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour « ' . htmlspecialchars($query) . ' » sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. ' . SITE_DESCRIPTION; ?>">
|
||||||
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
<meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||||
|
|
||||||
<?php if (!empty($query) && !empty($currentPageVideos)): ?>
|
<?php if (!empty($query) && !empty($currentPageVideos)): ?>
|
||||||
<!-- Données structurées JSON-LD pour la page de recherche -->
|
<!-- Données structurées JSON-LD pour la page de recherche -->
|
||||||
@@ -132,12 +146,12 @@ if ($resultsCount > 0) {
|
|||||||
</div>
|
</div>
|
||||||
<?php if (!empty($query)): ?>
|
<?php if (!empty($query)): ?>
|
||||||
<?php if ($isTagSearch): ?>
|
<?php if ($isTagSearch): ?>
|
||||||
<h2 class="section-title">Vidéos avec le hashtag : "<?php echo htmlspecialchars($searchTag); ?>"</h2>
|
<h1 class="section-title">Vidéos avec le hashtag : "<?php echo htmlspecialchars($searchTag); ?>"</h1>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<h2 class="section-title">Résultats pour : "<?php echo htmlspecialchars($query); ?>"</h2>
|
<h1 class="section-title">Résultats pour : "<?php echo htmlspecialchars($query); ?>"</h1>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<h2 class="section-title">Rechercher des vidéos</h2>
|
<h1 class="section-title">Rechercher des vidéos</h1>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,34 +167,7 @@ if ($resultsCount > 0) {
|
|||||||
|
|
||||||
<div class="video-grid category-videos">
|
<div class="video-grid category-videos">
|
||||||
<?php foreach ($currentPageVideos as $video): ?>
|
<?php foreach ($currentPageVideos as $video): ?>
|
||||||
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
|
<?php echo renderVideoCard($video); ?>
|
||||||
<div class="video-thumbnail">
|
|
||||||
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
|
|
||||||
<div class="video-play-icon">
|
|
||||||
<i class="fas fa-play-circle"></i>
|
|
||||||
</div>
|
|
||||||
<span class="video-duration"><?php echo formatDuration($video['duration']); ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-info">
|
|
||||||
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
|
|
||||||
<div class="video-channel">
|
|
||||||
<?php if (strpos($video['channelAvatar'], 'default-avatar') !== false || empty($video['channelAvatar'])): ?>
|
|
||||||
<div class="channel-avatar-placeholder">
|
|
||||||
<i class="fas fa-user-circle"></i>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo htmlspecialchars($video['channel']); ?>" class="channel-avatar">
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="video-metadata">
|
|
||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
|
||||||
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -261,5 +248,6 @@ if ($resultsCount > 0) {
|
|||||||
|
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
<script src="js/search.js"></script>
|
<script src="js/search.js"></script>
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ Disallow: /conf/
|
|||||||
Disallow: /cache/
|
Disallow: /cache/
|
||||||
|
|
||||||
# Sitemap
|
# Sitemap
|
||||||
Sitemap: https://VOTRE-DOMAINE/sitemap.xml
|
Sitemap: https://example.com/sitemap.xml
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 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 script retourne un code non nul si une vérification
|
||||||
|
# échoue.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
warn=0
|
||||||
|
|
||||||
|
step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
# need <commande> <paquet> : vérifie la présence d'un outil
|
||||||
|
need() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "⚠️ '$1' non installé — check ignoré (paquet : $2)"
|
||||||
|
warn=1
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
step "PHP lint (*.php, *.php.sample)"
|
||||||
|
if need php php-cli; then
|
||||||
|
php_fail=0
|
||||||
|
while IFS= read -r f; do
|
||||||
|
if ! php -l "$f" > /dev/null; then
|
||||||
|
echo "❌ $f"
|
||||||
|
php_fail=1
|
||||||
|
fi
|
||||||
|
done < <(find . -path ./.git -prune -o \( -name '*.php' -o -name '*.php.sample' \) -print)
|
||||||
|
if [ "$php_fail" -eq 0 ]; then
|
||||||
|
echo "✅ PHP OK"
|
||||||
|
else
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "JS lint (sw.js, js/*.js)"
|
||||||
|
if need node nodejs; then
|
||||||
|
js_fail=0
|
||||||
|
for f in sw.js js/*.js; do
|
||||||
|
node --check "$f" || js_fail=1
|
||||||
|
done
|
||||||
|
if [ "$js_fail" -eq 0 ]; then
|
||||||
|
echo "✅ JS OK"
|
||||||
|
else
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "AsciiDoc (README.adoc, DEPLOY.adoc)"
|
||||||
|
if need asciidoctor asciidoctor; then
|
||||||
|
adoc_fail=0
|
||||||
|
for f in README.adoc DEPLOY.adoc; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
asciidoctor -o "/tmp/check-$$.html" "$f" || adoc_fail=1
|
||||||
|
done
|
||||||
|
rm -f "/tmp/check-$$.html"
|
||||||
|
if [ "$adoc_fail" -eq 0 ]; then
|
||||||
|
echo "✅ AsciiDoc OK"
|
||||||
|
else
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "JSON (site.webmanifest.sample)"
|
||||||
|
if need python3 python3; then
|
||||||
|
if python3 -m json.tool site.webmanifest.sample > /dev/null; then
|
||||||
|
echo "✅ JSON OK"
|
||||||
|
else
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "XML (sitemap.xml.sample, browserconfig.xml)"
|
||||||
|
if need xmllint libxml2-utils; then
|
||||||
|
if xmllint --noout sitemap.xml.sample browserconfig.xml; then
|
||||||
|
echo "✅ XML OK"
|
||||||
|
else
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Shellcheck (scripts shell)"
|
||||||
|
if need shellcheck shellcheck; then
|
||||||
|
if shellcheck docs/generate-readme-pdf.sh scripts/check.sh; then
|
||||||
|
echo "✅ Shellcheck OK"
|
||||||
|
else
|
||||||
|
fail=1
|
||||||
|
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."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$warn" -ne 0 ]; then
|
||||||
|
echo "⚠️ Checks disponibles OK, mais certains outils manquent (le CI exécutera tout)."
|
||||||
|
else
|
||||||
|
echo "✅ Tous les checks passent."
|
||||||
|
fi
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "FEDIVERSE OKI - ORGANISATION KA INTERNATIONALE",
|
"name": "ANNU KUTE CED - Hub multimédia du podcast",
|
||||||
"short_name": "FEDIVERSE OKI",
|
"short_name": "ANNU KUTE CED",
|
||||||
"description": "Plateforme multimédia indépendante",
|
"description": "Hub multimédia du podcast ANNU KUTE CED",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/</loc>
|
<loc>https://example.com/</loc>
|
||||||
<changefreq>daily</changefreq>
|
<changefreq>daily</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/index</loc>
|
<loc>https://example.com/index</loc>
|
||||||
<changefreq>daily</changefreq>
|
<changefreq>daily</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/index.php</loc>
|
<loc>https://example.com/index.php</loc>
|
||||||
<changefreq>daily</changefreq>
|
<changefreq>daily</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/direct</loc>
|
<loc>https://example.com/direct</loc>
|
||||||
<changefreq>hourly</changefreq>
|
<changefreq>hourly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/direct.php</loc>
|
<loc>https://example.com/direct.php</loc>
|
||||||
<changefreq>hourly</changefreq>
|
<changefreq>hourly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/mentions-legales</loc>
|
<loc>https://example.com/mentions-legales</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.3</priority>
|
<priority>0.3</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/mentions-legales.php</loc>
|
<loc>https://example.com/mentions-legales.php</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.3</priority>
|
<priority>0.3</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/recherche</loc>
|
<loc>https://example.com/recherche</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/recherche.php</loc>
|
<loc>https://example.com/recherche.php</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/categories</loc>
|
<loc>https://example.com/categories</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/categories.php</loc>
|
<loc>https://example.com/categories.php</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/video</loc>
|
<loc>https://example.com/video</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://VOTRE-DOMAINE/video.php</loc>
|
<loc>https://example.com/video.php</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
const CACHE_NAME = 'fediverse-oki-08072026-0720';
|
// Version du cache : à bumper à chaque déploiement (format JJMMAAAA-HHMM).
|
||||||
const STATIC_CACHE_NAME = 'fediverse-oki-static-08072026-0720';
|
// Tout changement de ce fichier déclenche l'installation d'un nouveau
|
||||||
const DYNAMIC_CACHE_NAME = 'fediverse-oki-dynamic-08072026-0720';
|
// 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-26072026-1946';
|
||||||
|
const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-26072026-1946';
|
||||||
|
|
||||||
|
// Nombre maximal d'entrées conservées dans le cache dynamique (LRU :
|
||||||
|
// les entrées les moins récemment utilisées sont évincées en premier).
|
||||||
|
const DYNAMIC_CACHE_LIMIT = 50;
|
||||||
|
|
||||||
// Ressources à mettre en cache immédiatement
|
// Ressources à mettre en cache immédiatement
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -12,7 +19,6 @@ const STATIC_ASSETS = [
|
|||||||
'/css/video-page.css',
|
'/css/video-page.css',
|
||||||
'/css/mastodon-timeline.min.css',
|
'/css/mastodon-timeline.min.css',
|
||||||
'/js/main.js',
|
'/js/main.js',
|
||||||
'/js/categories.js',
|
|
||||||
'/js/search.js',
|
'/js/search.js',
|
||||||
'/js/mastodon-timeline.umd.js',
|
'/js/mastodon-timeline.umd.js',
|
||||||
'/img/logo.png',
|
'/img/logo.png',
|
||||||
@@ -27,15 +33,6 @@ const STATIC_ASSETS = [
|
|||||||
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css'
|
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Pages à mettre en cache
|
|
||||||
const PAGES_TO_CACHE = [
|
|
||||||
'/',
|
|
||||||
'/index.php',
|
|
||||||
'/categories.php',
|
|
||||||
'/recherche.php',
|
|
||||||
'/mentions-legales.php'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Installation du Service Worker
|
// Installation du Service Worker
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
console.log('Service Worker: Installation');
|
console.log('Service Worker: Installation');
|
||||||
@@ -46,13 +43,15 @@ self.addEventListener('install', event => {
|
|||||||
console.log('Service Worker: Mise en cache des assets statiques');
|
console.log('Service Worker: Mise en cache des assets statiques');
|
||||||
return cache.addAll(STATIC_ASSETS);
|
return cache.addAll(STATIC_ASSETS);
|
||||||
})
|
})
|
||||||
.then(() => {
|
|
||||||
return self.skipWaiting();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Service Worker: Erreur lors de la mise en cache:', err);
|
console.error('Service Worker: Erreur lors de la mise en cache:', err);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
// Note : pas de skipWaiting() ici. Lors d'une mise à jour, le nouveau SW
|
||||||
|
// reste en attente jusqu'à ce que l'utilisateur accepte la mise à jour
|
||||||
|
// via le modal (message SKIP_WAITING envoyé par js/pwa-update.js).
|
||||||
|
// Lors de la toute première visite (aucun SW actif), l'activation est
|
||||||
|
// immédiate.
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activation du Service Worker
|
// Activation du Service Worker
|
||||||
@@ -129,7 +128,13 @@ self.addEventListener('fetch', event => {
|
|||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
const responseClone = response.clone();
|
const responseClone = response.clone();
|
||||||
caches.open(DYNAMIC_CACHE_NAME)
|
caches.open(DYNAMIC_CACHE_NAME)
|
||||||
.then(cache => cache.put(request, responseClone));
|
.then(cache => {
|
||||||
|
// cache.put() replace une entrée existante en fin de liste
|
||||||
|
// (clés ordonnées par insertion) : la taille limitée évince
|
||||||
|
// donc bien les entrées les moins récemment utilisées.
|
||||||
|
return cache.put(request, responseClone)
|
||||||
|
.then(() => limitCacheSize(cache, DYNAMIC_CACHE_LIMIT));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
@@ -171,6 +176,15 @@ self.addEventListener('fetch', event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fonctions utilitaires
|
// Fonctions utilitaires
|
||||||
|
// Évince les plus anciennes clés tant que le cache dépasse maxItems entrées.
|
||||||
|
function limitCacheSize(cache, maxItems) {
|
||||||
|
return cache.keys().then(keys => {
|
||||||
|
if (keys.length > maxItems) {
|
||||||
|
return cache.delete(keys[0]).then(() => limitCacheSize(cache, maxItems));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isStaticAsset(url) {
|
function isStaticAsset(url) {
|
||||||
return url.includes('/css/') ||
|
return url.includes('/css/') ||
|
||||||
url.includes('/js/') ||
|
url.includes('/js/') ||
|
||||||
@@ -202,20 +216,10 @@ function isApiRequest(url) {
|
|||||||
url.includes('mastodon-config.php');
|
url.includes('mastodon-config.php');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion des messages du client
|
// Gestion des messages du client : l'utilisateur a accepté la mise à jour,
|
||||||
|
// le SW en attente prend le contrôle (purge des anciens caches à l'activation).
|
||||||
self.addEventListener('message', event => {
|
self.addEventListener('message', event => {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notification de mise à jour
|
|
||||||
self.addEventListener('message', event => {
|
|
||||||
if (event.data && event.data.type === 'CHECK_UPDATE') {
|
|
||||||
// Vérifier s'il y a une mise à jour
|
|
||||||
event.ports[0].postMessage({
|
|
||||||
type: 'UPDATE_AVAILABLE',
|
|
||||||
version: CACHE_NAME
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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/)
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?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 -----------------------------
|
||||||
|
|
||||||
|
$donsLive = file_get_contents($root . '/dons.php');
|
||||||
|
$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 & "échappé"',
|
||||||
|
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(
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
linkUrlsInText('<script>alert(1)</script>'),
|
||||||
|
'linkUrlsInText échappe le HTML injecté dans le texte'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
'href="https://example.com/?a=1&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);
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests unitaires pour markdown_to_html (includes/lib/markdown.php)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Échappement XSS ---------------------------------------------------------
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
'<script>alert("x")</script>',
|
||||||
|
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 « & », jamais « &amp; »
|
||||||
|
assertEquals(
|
||||||
|
'Voir <a href="https://exemple.com/page?a=1&b=2" target="_blank" rel="noopener noreferrer">https://exemple.com/page?a=1&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&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;',
|
||||||
|
markdown_to_html('https://exemple.com/page?a=1&b=2'),
|
||||||
|
'markdown_to_html ne produit jamais de &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 />'
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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']);
|
||||||
@@ -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'] = '/';
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests unitaires pour le helper e() (includes/security.php)
|
||||||
|
* et le partial renderVideoCard() (includes/partials/video-card.php)
|
||||||
|
*
|
||||||
|
* Non-régression XSS (SEC-1) : les données de l'API PeerTube (titres,
|
||||||
|
* chaînes, vignettes, avatars) ne doivent jamais ressortir telles quelles
|
||||||
|
* dans le HTML d'une carte vidéo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- e() ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
e('<script>alert(1)</script>'),
|
||||||
|
'e() échappe les balises HTML'
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
'"guillemets" 'apostrophes'',
|
||||||
|
e('"guillemets" \'apostrophes\''),
|
||||||
|
'e() échappe guillemets et apostrophes (ENT_QUOTES)'
|
||||||
|
);
|
||||||
|
assertEquals('&', e('&'), 'e() échappe l\'esperluette');
|
||||||
|
assertEquals('', e(null), 'e() convertit null en chaîne vide');
|
||||||
|
assertEquals('42', e(42), 'e() convertit les nombres en chaîne');
|
||||||
|
|
||||||
|
// --- renderVideoCard : structure de base -------------------------------------
|
||||||
|
|
||||||
|
$video = [
|
||||||
|
'id' => 'abc-123',
|
||||||
|
'title' => 'Ma vidéo',
|
||||||
|
'thumbnail' => 'https://videos.example/lazy/abc.jpg',
|
||||||
|
'duration' => 125,
|
||||||
|
'channel' => 'Ma chaîne',
|
||||||
|
'channelAvatar' => 'https://videos.example/avatars/a.png',
|
||||||
|
'views' => 42,
|
||||||
|
'date' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = renderVideoCard($video);
|
||||||
|
|
||||||
|
assertContains('class="video-card"', $html, 'renderVideoCard génère une carte vidéo');
|
||||||
|
assertContains('data-video-id="abc-123"', $html, 'renderVideoCard expose l\'identifiant vidéo');
|
||||||
|
assertContains('src="https://videos.example/lazy/abc.jpg"', $html, 'renderVideoCard affiche la vignette');
|
||||||
|
assertContains('<h3 class="video-title">Ma vidéo</h3>', $html, 'renderVideoCard affiche le titre');
|
||||||
|
assertContains('<span class="channel-name">Ma chaîne</span>', $html, 'renderVideoCard affiche la chaîne');
|
||||||
|
assertContains('class="channel-avatar"', $html, 'renderVideoCard affiche l\'avatar personnalisé');
|
||||||
|
assertContains('2:05', $html, 'renderVideoCard affiche la durée formatée');
|
||||||
|
|
||||||
|
// --- renderVideoCard : avatar par défaut --------------------------------------
|
||||||
|
|
||||||
|
$videoDefaultAvatar = $video;
|
||||||
|
$videoDefaultAvatar['channelAvatar'] = 'img/default-avatar.png';
|
||||||
|
$htmlDefault = renderVideoCard($videoDefaultAvatar);
|
||||||
|
|
||||||
|
assertContains('channel-avatar-placeholder', $htmlDefault, 'renderVideoCard utilise un placeholder pour l\'avatar par défaut');
|
||||||
|
assertNotContains('class="channel-avatar"', $htmlDefault, 'renderVideoCard n\'affiche pas l\'image d\'avatar par défaut');
|
||||||
|
|
||||||
|
$videoEmptyAvatar = $video;
|
||||||
|
$videoEmptyAvatar['channelAvatar'] = '';
|
||||||
|
assertContains(
|
||||||
|
'channel-avatar-placeholder',
|
||||||
|
renderVideoCard($videoEmptyAvatar),
|
||||||
|
'renderVideoCard utilise un placeholder si l\'avatar est vide'
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- renderVideoCard : échappement XSS (SEC-1) ---------------------------------
|
||||||
|
|
||||||
|
$maliciousTitle = '"><img src=x onerror=alert(1)><script>alert(2)</script>';
|
||||||
|
$maliciousChannel = '<svg onload=alert(3)>';
|
||||||
|
$maliciousThumbnail = 'https://videos.example/x.jpg" onerror="alert(4)';
|
||||||
|
$maliciousAvatar = 'https://videos.example/a.png\' onerror=\'alert(5)';
|
||||||
|
|
||||||
|
$htmlXss = renderVideoCard([
|
||||||
|
'id' => $maliciousTitle,
|
||||||
|
'title' => $maliciousTitle,
|
||||||
|
'thumbnail' => $maliciousThumbnail,
|
||||||
|
'duration' => 60,
|
||||||
|
'channel' => $maliciousChannel,
|
||||||
|
'channelAvatar' => $maliciousAvatar,
|
||||||
|
'views' => 1,
|
||||||
|
'date' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNotContains($maliciousTitle, $htmlXss, 'renderVideoCard ne ressort pas le titre brut');
|
||||||
|
assertNotContains($maliciousChannel, $htmlXss, 'renderVideoCard ne ressort pas la chaîne brute');
|
||||||
|
assertNotContains($maliciousThumbnail, $htmlXss, 'renderVideoCard ne ressort pas la vignette brute');
|
||||||
|
assertNotContains($maliciousAvatar, $htmlXss, 'renderVideoCard ne ressort pas l\'avatar brut');
|
||||||
|
assertNotContains('<script>', $htmlXss, 'renderVideoCard ne génère aucune balise script injectée');
|
||||||
|
assertNotContains('onerror="', $htmlXss, 'renderVideoCard ne laisse passer aucun attribut d\'événement actif');
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
htmlspecialchars($maliciousTitle, ENT_QUOTES, 'UTF-8'),
|
||||||
|
$htmlXss,
|
||||||
|
'renderVideoCard affiche le titre échappé'
|
||||||
|
);
|
||||||
|
assertContains(
|
||||||
|
htmlspecialchars($maliciousChannel, ENT_QUOTES, 'UTF-8'),
|
||||||
|
$htmlXss,
|
||||||
|
'renderVideoCard affiche la chaîne échappée'
|
||||||
|
);
|
||||||
|
|
||||||
|
// L'échappement ENT_QUOTES doit aussi protéger les attributs à apostrophes
|
||||||
|
$htmlAttr = renderVideoCard([
|
||||||
|
'id' => 'xyz',
|
||||||
|
'title' => "Titre avec 'apostrophe'",
|
||||||
|
'thumbnail' => 'https://videos.example/x.jpg',
|
||||||
|
'duration' => 10,
|
||||||
|
'channel' => 'Chaîne',
|
||||||
|
'channelAvatar' => 'img/default-avatar.png',
|
||||||
|
'views' => 0,
|
||||||
|
'date' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
assertContains(''', $htmlAttr, 'renderVideoCard échappe les apostrophes dans les attributs');
|
||||||
|
assertNotContains("'apostrophe'", $htmlAttr, 'renderVideoCard ne laisse pas d\'apostrophe brute');
|
||||||
|
|
||||||
|
// --- renderVideoCard : données incomplètes --------------------------------------
|
||||||
|
|
||||||
|
$htmlMinimal = renderVideoCard(['id' => 'solo-1']);
|
||||||
|
|
||||||
|
assertContains('class="video-card"', $htmlMinimal, 'renderVideoCard tolère une vidéo quasi vide');
|
||||||
|
assertContains('data-video-id="solo-1"', $htmlMinimal, 'renderVideoCard conserve l\'id minimal');
|
||||||
|
assertContains('channel-avatar-placeholder', $htmlMinimal, 'renderVideoCard met un placeholder sans avatar');
|
||||||
@@ -120,9 +120,13 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?php echo !empty($video['title']) ? htmlspecialchars($video['title']) . ' - ' : ''; ?><?php echo SITE_NAME; ?></title>
|
<title><?php echo !empty($video['title']) ? htmlspecialchars($video['title']) . ' - ' : ''; ?><?php echo SITE_NAME; ?></title>
|
||||||
|
<meta name="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($videoNotFound) && !empty($video)): ?>
|
||||||
|
<link rel="canonical" href="<?php echo getBaseUrl() . '/video.php?id=' . $video['id']; ?>">
|
||||||
|
<?php endif; ?>
|
||||||
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||||
<link rel="stylesheet" href="css/video-page.css?v=<?php echo filemtime('css/video-page.css'); ?>">
|
<link rel="stylesheet" href="css/video-page.css?v=<?php echo filemtime('css/video-page.css'); ?>">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||||
@@ -130,24 +134,25 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
||||||
<link rel="manifest" href="site.webmanifest">
|
<link rel="manifest" href="site.webmanifest">
|
||||||
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#FF0000">
|
||||||
|
|
||||||
<!-- Meta tags pour le partage sur les réseaux sociaux -->
|
<!-- Meta tags pour le partage sur les réseaux sociaux -->
|
||||||
<meta property="og:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>">
|
<meta property="og:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>">
|
||||||
<meta property="og:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>">
|
<meta property="og:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>">
|
||||||
<?php if (isset($videoData['thumbnailPath'])): ?>
|
<?php if (isset($videoData['thumbnailPath'])): ?>
|
||||||
<meta property="og:image" content="<?php echo PEERTUBE_URL . $videoData['thumbnailPath']; ?>">
|
<meta property="og:image" content="<?php echo e(PEERTUBE_URL . $videoData['thumbnailPath']); ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<meta property="og:url" content="<?php echo (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"; ?>">
|
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
|
||||||
<meta property="og:type" content="video.other">
|
<meta property="og:type" content="video.other">
|
||||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||||
|
<meta property="og:locale" content="fr_FR">
|
||||||
|
|
||||||
<!-- Meta tags pour Twitter -->
|
<!-- Meta tags pour Twitter -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>">
|
<meta name="twitter:title" content="<?php echo !empty($video['title']) ? htmlspecialchars($video['title']) : 'Vidéo'; ?> - <?php echo SITE_NAME; ?>">
|
||||||
<meta name="twitter:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>">
|
<meta name="twitter:description" content="<?php echo !empty($video['description']) ? htmlspecialchars(substr(strip_tags($video['description']), 0, 200)) . '...' : 'Regardez cette vidéo sur ' . SITE_NAME; ?>">
|
||||||
<?php if (isset($videoData['thumbnailPath'])): ?>
|
<?php if (isset($videoData['thumbnailPath'])): ?>
|
||||||
<meta name="twitter:image" content="<?php echo PEERTUBE_URL . $videoData['thumbnailPath']; ?>">
|
<meta name="twitter:image" content="<?php echo e(PEERTUBE_URL . $videoData['thumbnailPath']); ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!isset($videoNotFound) && !empty($video)): ?>
|
<?php if (!isset($videoNotFound) && !empty($video)): ?>
|
||||||
@@ -219,7 +224,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
||||||
<span class="video-views"><i class="fas fa-eye" aria-hidden="true"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
<span class="video-views"><i class="fas fa-eye" aria-hidden="true"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="video-date"><i class="far fa-calendar-alt" aria-hidden="true"></i> <time datetime="<?php echo $video['date']; ?>"><?php echo formatDate($video['date']); ?></time></span>
|
<span class="video-date"><i class="far fa-calendar-alt" aria-hidden="true"></i> <time datetime="<?php echo e($video['date']); ?>"><?php echo formatDate($video['date']); ?></time></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="video-actions">
|
<div class="video-actions">
|
||||||
@@ -296,20 +301,20 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
$channelUrl = PEERTUBE_URL . '/c/' . $video['channelHandle'];
|
$channelUrl = PEERTUBE_URL . '/c/' . $video['channelHandle'];
|
||||||
?>
|
?>
|
||||||
<?php if (strpos($channelAvatar, 'default-avatar') !== false || empty($channelAvatar)): ?>
|
<?php if (strpos($channelAvatar, 'default-avatar') !== false || empty($channelAvatar)): ?>
|
||||||
<a href="<?php echo $channelUrl; ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
|
<a href="<?php echo e($channelUrl); ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
|
||||||
<div class="channel-avatar-placeholder" role="img" aria-label="Avatar par défaut">
|
<div class="channel-avatar-placeholder" role="img" aria-label="Avatar par défaut">
|
||||||
<i class="fas fa-user-circle" aria-hidden="true"></i>
|
<i class="fas fa-user-circle" aria-hidden="true"></i>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a href="<?php echo $channelUrl; ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
|
<a href="<?php echo e($channelUrl); ?>" target="_blank" rel="noopener noreferrer" class="channel-avatar-link" aria-label="Voir la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
|
||||||
<div class="channel-avatar">
|
<div class="channel-avatar">
|
||||||
<img src="<?php echo $channelAvatar; ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
|
<img src="<?php echo e($channelAvatar); ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($video['channel']); ?>">
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="channel-details">
|
<div class="channel-details">
|
||||||
<a href="<?php echo $channelUrl; ?>" target="_blank" rel="noopener noreferrer" class="channel-name-link">
|
<a href="<?php echo e($channelUrl); ?>" target="_blank" rel="noopener noreferrer" class="channel-name-link">
|
||||||
<h2 class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></h2>
|
<h2 class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></h2>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,7 +335,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="full-description" class="full-description" style="display: none;" nonce="<?php echo getCspNonce(); ?>">
|
<div id="full-description" class="full-description is-hidden">
|
||||||
<?php echo markdown_to_html($video['description']); ?>
|
<?php echo markdown_to_html($video['description']); ?>
|
||||||
<button class="show-less-btn" aria-expanded="true" aria-controls="full-description">
|
<button class="show-less-btn" aria-expanded="true" aria-controls="full-description">
|
||||||
<span>Voir moins</span>
|
<span>Voir moins</span>
|
||||||
@@ -357,7 +362,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<div class="section-title-wrapper">
|
<div class="section-title-wrapper">
|
||||||
<h2 id="comments-heading" class="section-title">Commentaires</h2>
|
<h2 id="comments-heading" class="section-title">Commentaires</h2>
|
||||||
</div>
|
</div>
|
||||||
<a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="view-on-peertube" aria-label="Voir cette vidéo sur <?php echo PEERTUBE_DISPLAY_NAME; ?>">
|
<a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="view-on-peertube" aria-label="Voir cette vidéo sur <?php echo PEERTUBE_DISPLAY_NAME; ?>">
|
||||||
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Voir sur <?php echo PEERTUBE_DISPLAY_NAME; ?>
|
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Voir sur <?php echo PEERTUBE_DISPLAY_NAME; ?>
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
@@ -370,7 +375,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<article class="comment">
|
<article class="comment">
|
||||||
<div class="comment-avatar">
|
<div class="comment-avatar">
|
||||||
<?php if (isset($comment['account']['avatar']) && !empty($comment['account']['avatar']['path'])): ?>
|
<?php if (isset($comment['account']['avatar']) && !empty($comment['account']['avatar']['path'])): ?>
|
||||||
<img src="<?php echo PEERTUBE_URL . $comment['account']['avatar']['path']; ?>" alt="<?php echo htmlspecialchars($comment['account']['displayName']); ?>">
|
<img src="<?php echo e(PEERTUBE_URL . $comment['account']['avatar']['path']); ?>" alt="<?php echo htmlspecialchars($comment['account']['displayName']); ?>">
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="channel-avatar-placeholder mini" role="img" aria-label="Avatar par défaut">
|
<div class="channel-avatar-placeholder mini" role="img" aria-label="Avatar par défaut">
|
||||||
<i class="fas fa-user-circle" aria-hidden="true"></i>
|
<i class="fas fa-user-circle" aria-hidden="true"></i>
|
||||||
@@ -380,14 +385,14 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<header class="comment-header">
|
<header class="comment-header">
|
||||||
<span class="comment-author"><?php echo htmlspecialchars($comment['account']['displayName']); ?></span>
|
<span class="comment-author"><?php echo htmlspecialchars($comment['account']['displayName']); ?></span>
|
||||||
<time class="comment-date" datetime="<?php echo $comment['createdAt']; ?>"><?php echo formatDate($comment['createdAt']); ?></time>
|
<time class="comment-date" datetime="<?php echo e($comment['createdAt']); ?>"><?php echo formatDate($comment['createdAt']); ?></time>
|
||||||
</header>
|
</header>
|
||||||
<div class="comment-text"><?php echo nl2br(htmlspecialchars($comment['text'])); ?></div>
|
<div class="comment-text"><?php echo nl2br(htmlspecialchars($comment['text'])); ?></div>
|
||||||
|
|
||||||
<?php if (isset($comment['totalReplies']) && $comment['totalReplies'] > 0): ?>
|
<?php if (isset($comment['totalReplies']) && $comment['totalReplies'] > 0): ?>
|
||||||
<div class="comment-replies-toggle">
|
<div class="comment-replies-toggle">
|
||||||
<a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="show-replies" aria-label="Voir les réponses sur PeerTube">
|
<a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="show-replies" aria-label="Voir les réponses sur PeerTube">
|
||||||
<i class="fas fa-reply" aria-hidden="true"></i> Voir les <?php echo $comment['totalReplies']; ?> réponse<?php echo $comment['totalReplies'] > 1 ? 's' : ''; ?>
|
<i class="fas fa-reply" aria-hidden="true"></i> Voir les <?php echo e($comment['totalReplies']); ?> réponse<?php echo $comment['totalReplies'] > 1 ? 's' : ''; ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -399,13 +404,13 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<div class="comments-info">
|
<div class="comments-info">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<p>Les commentaires sont visibles mais l'ajout de commentaires et les threads de réponses sont désactivés sur cette page.</p>
|
<p>Les commentaires sont visibles mais l'ajout de commentaires et les threads de réponses sont désactivés sur cette page.</p>
|
||||||
<p>Pour ajouter des commentaires ou voir les réponses, veuillez vous rendre sur <a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p>
|
<p>Pour ajouter des commentaires ou voir les réponses, veuillez vous rendre sur <a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="no-comments">
|
<div class="no-comments">
|
||||||
<i class="fas fa-comments"></i>
|
<i class="fas fa-comments"></i>
|
||||||
<p>Aucun commentaire pour cette vidéo.</p>
|
<p>Aucun commentaire pour cette vidéo.</p>
|
||||||
<p>Pour ajouter des commentaires, veuillez vous rendre sur <a href="<?php echo PEERTUBE_URL; ?>/videos/watch/<?php echo $videoData['uuid']; ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p>
|
<p>Pour ajouter des commentaires, veuillez vous rendre sur <a href="<?php echo e(PEERTUBE_URL . '/videos/watch/' . $videoData['uuid']); ?>" target="_blank" class="show-replies"> <?php echo PEERTUBE_DISPLAY_NAME; ?></a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,13 +424,13 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<div class="suggestion-list">
|
<div class="suggestion-list">
|
||||||
<?php foreach ($suggestedVideos as $suggestedVideo): ?>
|
<?php foreach ($suggestedVideos as $suggestedVideo): ?>
|
||||||
<article class="suggested-video">
|
<article class="suggested-video">
|
||||||
<a href="video.php?id=<?php echo $suggestedVideo['id']; ?>" class="suggested-video-link" aria-labelledby="suggestion-title-<?php echo $suggestedVideo['id']; ?>">
|
<a href="video.php?id=<?php echo e($suggestedVideo['id']); ?>" class="suggested-video-link" aria-labelledby="suggestion-title-<?php echo e($suggestedVideo['id']); ?>">
|
||||||
<div class="suggested-video-thumbnail">
|
<div class="suggested-video-thumbnail">
|
||||||
<img src="<?php echo $suggestedVideo['thumbnail']; ?>" alt="<?php echo $suggestedVideo['title']; ?>">
|
<img src="<?php echo e($suggestedVideo['thumbnail']); ?>" alt="<?php echo e($suggestedVideo['title']); ?>">
|
||||||
</div>
|
</div>
|
||||||
<div class="suggested-video-info">
|
<div class="suggested-video-info">
|
||||||
<span class="suggested-video-duration"><?php echo formatDuration($suggestedVideo['duration']); ?></span>
|
<span class="suggested-video-duration"><?php echo formatDuration($suggestedVideo['duration']); ?></span>
|
||||||
<h3 id="suggestion-title-<?php echo $suggestedVideo['id']; ?>" class="suggested-video-title"><?php echo htmlspecialchars($suggestedVideo['title']); ?></h3>
|
<h3 id="suggestion-title-<?php echo e($suggestedVideo['id']); ?>" class="suggested-video-title"><?php echo htmlspecialchars($suggestedVideo['title']); ?></h3>
|
||||||
<div class="suggested-video-channel">
|
<div class="suggested-video-channel">
|
||||||
<?php
|
<?php
|
||||||
// Vérifier si un avatar de chaîne est disponible pour la vidéo suggérée
|
// Vérifier si un avatar de chaîne est disponible pour la vidéo suggérée
|
||||||
@@ -437,9 +442,9 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<i class="fas fa-user-circle"></i>
|
<i class="fas fa-user-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<img src="<?php echo $suggestedAvatar; ?>" alt="<?php echo $suggestedVideo['channel']; ?>" class="channel-avatar mini">
|
<img src="<?php echo e($suggestedAvatar); ?>" alt="<?php echo e($suggestedVideo['channel']); ?>" class="channel-avatar mini">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="channel-name"><?php echo $suggestedVideo['channel']; ?></span>
|
<span class="channel-name"><?php echo e($suggestedVideo['channel']); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggested-video-metadata">
|
<div class="suggested-video-metadata">
|
||||||
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
|
||||||
@@ -462,6 +467,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<?php include 'includes/footer.php'; ?>
|
<?php include 'includes/footer.php'; ?>
|
||||||
<?php include 'includes/mobile-menu.php'; ?>
|
<?php include 'includes/mobile-menu.php'; ?>
|
||||||
|
|
||||||
|
<?php if (!isset($videoNotFound)): ?>
|
||||||
<!-- Modal de téléchargement -->
|
<!-- Modal de téléchargement -->
|
||||||
<div id="download-modal" class="modal">
|
<div id="download-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -477,13 +483,13 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
if (!empty($downloadOptions)):
|
if (!empty($downloadOptions)):
|
||||||
foreach ($downloadOptions as $option):
|
foreach ($downloadOptions as $option):
|
||||||
?>
|
?>
|
||||||
<a href="<?php echo $option['url']; ?>" class="download-option" download>
|
<a href="<?php echo e($option['url']); ?>" class="download-option" download>
|
||||||
<div class="download-resolution">
|
<div class="download-resolution">
|
||||||
<i class="fas fa-film"></i>
|
<i class="fas fa-film"></i>
|
||||||
<span><?php echo $option['resolution']; ?></span>
|
<span><?php echo e($option['resolution']); ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
<span class="download-size"><?php echo $option['size']; ?></span>
|
<span class="download-size"><?php echo e($option['size']); ?></span>
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -512,7 +518,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<div class="share-link-container">
|
<div class="share-link-container">
|
||||||
<p>Lien de la vidéo :</p>
|
<p>Lien de la vidéo :</p>
|
||||||
<div class="share-link-box">
|
<div class="share-link-box">
|
||||||
<input type="text" id="share-link" value="<?php echo htmlspecialchars((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" readonly>
|
<input type="text" id="share-link" value="<?php echo htmlspecialchars(getCurrentUrl()); ?>" readonly>
|
||||||
<button id="copy-link-btn" class="copy-btn" title="Copier le lien">
|
<button id="copy-link-btn" class="copy-btn" title="Copier le lien">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -521,32 +527,32 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
|
|
||||||
<p class="share-platforms-title">Partager sur :</p>
|
<p class="share-platforms-title">Partager sur :</p>
|
||||||
<div class="share-platforms">
|
<div class="share-platforms">
|
||||||
<a href="mailto:?subject=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&body=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" class="share-platform-btn" title="Partager par e-mail">
|
<a href="mailto:?subject=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&body=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . getCurrentUrl()); ?>" class="share-platform-btn" title="Partager par e-mail">
|
||||||
<i class="fas fa-envelope"></i>
|
<i class="fas fa-envelope"></i>
|
||||||
<span>E-mail</span>
|
<span>E-mail</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur Facebook">
|
<a href="https://www.facebook.com/sharer/sharer.php?u=<?php echo urlencode(getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur Facebook">
|
||||||
<i class="fab fa-facebook-f"></i>
|
<i class="fab fa-facebook-f"></i>
|
||||||
<span>Facebook</span>
|
<span>Facebook</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://twitter.com/intent/tweet?text=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&url=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur X/Twitter">
|
<a href="https://twitter.com/intent/tweet?text=<?php echo urlencode(htmlspecialchars($video['title'])); ?>&url=<?php echo urlencode(getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur X/Twitter">
|
||||||
<i class="fab fa-x-twitter"></i>
|
<i class="fab fa-x-twitter"></i>
|
||||||
<span>X</span>
|
<span>X</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://wa.me/?text=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur WhatsApp">
|
<a href="https://wa.me/?text=<?php echo urlencode(htmlspecialchars($video['title']) . ' - ' . getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur WhatsApp">
|
||||||
<i class="fab fa-whatsapp"></i>
|
<i class="fab fa-whatsapp"></i>
|
||||||
<span>WhatsApp</span>
|
<span>WhatsApp</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>" target="_blank" class="share-platform-btn" title="Partager sur LinkedIn">
|
<a href="https://www.linkedin.com/sharing/share-offsite/?url=<?php echo urlencode(getCurrentUrl()); ?>" target="_blank" class="share-platform-btn" title="Partager sur LinkedIn">
|
||||||
<i class="fab fa-linkedin-in"></i>
|
<i class="fab fa-linkedin-in"></i>
|
||||||
<span>LinkedIn</span>
|
<span>LinkedIn</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://t.me/share/url?url=<?php echo urlencode((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); ?>&text=<?php echo urlencode($video['title']); ?>" target="_blank" class="share-platform-btn" title="Partager sur Telegram">
|
<a href="https://t.me/share/url?url=<?php echo urlencode(getCurrentUrl()); ?>&text=<?php echo urlencode($video['title']); ?>" target="_blank" class="share-platform-btn" title="Partager sur Telegram">
|
||||||
<i class="fab fa-telegram-plane"></i>
|
<i class="fab fa-telegram-plane"></i>
|
||||||
<span>Telegram</span>
|
<span>Telegram</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -555,7 +561,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
<div class="share-embed">
|
<div class="share-embed">
|
||||||
<p>Intégrer la vidéo :</p>
|
<p>Intégrer la vidéo :</p>
|
||||||
<div class="share-link-box">
|
<div class="share-link-box">
|
||||||
<input type="text" id="embed-code" value='<iframe width="560" height="315" src="<?php echo $video['url']; ?>" frameborder="0" allowfullscreen></iframe>' readonly>
|
<input type="text" id="embed-code" value='<iframe width="560" height="315" src="<?php echo e($video['url']); ?>" frameborder="0" allowfullscreen></iframe>' readonly>
|
||||||
<button id="copy-embed-btn" class="copy-btn" title="Copier le code d'intégration">
|
<button id="copy-embed-btn" class="copy-btn" title="Copier le code d'intégration">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -575,15 +581,15 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
|
|
||||||
if (showMoreBtn) {
|
if (showMoreBtn) {
|
||||||
showMoreBtn.addEventListener('click', function() {
|
showMoreBtn.addEventListener('click', function() {
|
||||||
document.querySelector('.truncated-description').style.display = 'none';
|
document.querySelector('.truncated-description').classList.add('is-hidden');
|
||||||
document.querySelector('.full-description').style.display = 'block';
|
document.querySelector('.full-description').classList.remove('is-hidden');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showLessBtn) {
|
if (showLessBtn) {
|
||||||
showLessBtn.addEventListener('click', function() {
|
showLessBtn.addEventListener('click', function() {
|
||||||
document.querySelector('.full-description').style.display = 'none';
|
document.querySelector('.full-description').classList.add('is-hidden');
|
||||||
document.querySelector('.truncated-description').style.display = 'block';
|
document.querySelector('.truncated-description').classList.remove('is-hidden');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,6 +673,8 @@ if (empty($videoData) || isset($videoData['error'])) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||