Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||
@@ -25,6 +25,10 @@ temp/
|
||||
# Fichiers de 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)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
@@ -33,4 +37,9 @@ uploads/*
|
||||
# vendor/
|
||||
# node_modules/
|
||||
|
||||
# Artefacts de tests locaux
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
img/movement_presentation.png
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
= 🚀 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.
|
||||
|
||||
== 🧰 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]
|
||||
@@ -43,6 +43,8 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
||||
|
||||
@@ -52,7 +54,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
||||
<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="#ffffff">
|
||||
<meta name="theme-color" content="#FF0000">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
|
||||
@@ -112,7 +114,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
||||
<div class="section-logo">
|
||||
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
|
||||
</div>
|
||||
<h2 class="section-title">Catégorie : <?php echo htmlspecialchars($categoryName); ?></h2>
|
||||
<h1 class="section-title">Catégorie : <?php echo htmlspecialchars($categoryName); ?></h1>
|
||||
</div>
|
||||
|
||||
<?php if (empty($videos)): ?>
|
||||
@@ -167,6 +169,6 @@ if ($categoryId && isset($allCategories[$categoryId])) {
|
||||
<?php include 'includes/mobile-menu.php'; ?>
|
||||
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/categories.js"></script>
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -40,7 +40,9 @@ RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^([^\.]+)$ $1.php [NC,L]
|
||||
|
||||
# Rediriger les URLs avec .php vers les URLs sans extension
|
||||
# Sauf /ajax/ : un 301 transformerait le POST en GET et ferait perdre le token CSRF.
|
||||
RewriteCond %{THE_REQUEST} /([^.]+)\.php [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/ajax/ [NC]
|
||||
RewriteRule ^ /%1 [NC,L,R=301]
|
||||
|
||||
# Pour accéder à page.php via /page
|
||||
@@ -55,3 +57,15 @@ RewriteRule ^([^/]+)$ $1.php [L]
|
||||
RewriteCond %{HTTP:X-Forwarded-Proto} !https
|
||||
RewriteCond %{HTTPS} !on
|
||||
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 {
|
||||
listen 80;
|
||||
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;
|
||||
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É
|
||||
# ======================
|
||||
|
||||
# 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;
|
||||
return 404;
|
||||
}
|
||||
@@ -58,15 +62,23 @@ server {
|
||||
rewrite ^/([^.]+)$ /$1.php last;
|
||||
}
|
||||
|
||||
# Rediriger les URLs avec .php vers les URLs sans extension
|
||||
location ~ ^/(.+)\.php$ {
|
||||
return 301 /$1;
|
||||
}
|
||||
|
||||
# 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$ {
|
||||
# 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;
|
||||
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_NAME $fastcgi_script_name;
|
||||
}
|
||||
@@ -75,6 +87,18 @@ server {
|
||||
# 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
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
@@ -94,3 +118,31 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" 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;
|
||||
# # }
|
||||
|
||||
@@ -13,6 +13,8 @@ setSecurityHeaders();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
|
||||
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?> Ouverture prochaine.">
|
||||
<link rel="canonical" href="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/countdown.php'; ?>">
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>">
|
||||
@@ -183,5 +185,6 @@ setSecurityHeaders();
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="js/countdown.js"></script>
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -82,6 +82,45 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Avertissement de transparence sur la page de dons */
|
||||
.donation-disclaimer {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 30px;
|
||||
padding: 20px 24px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 4px solid var(--primary-red);
|
||||
border-radius: 12px;
|
||||
color: var(--text-color);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.donation-disclaimer p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.donation-disclaimer i {
|
||||
color: var(--primary-red);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.donation-disclaimer a {
|
||||
color: var(--primary-red);
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.donation-disclaimer a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.donation-disclaimer {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Interface de don */
|
||||
.donation-interface {
|
||||
background: var(--card-bg);
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
.search-results-count {
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
@@ -3167,7 +3167,8 @@ i.icon-mastodon,
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
background-color: var(--tag-bg);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -3412,3 +3413,166 @@ 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;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ $liveStream = getLiveStream();
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
||||
|
||||
@@ -32,7 +34,7 @@ $liveStream = getLiveStream();
|
||||
<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="#ffffff">
|
||||
<meta name="theme-color" content="#FF0000">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="Direct - <?php echo SITE_NAME; ?>">
|
||||
@@ -191,7 +193,7 @@ $liveStream = getLiveStream();
|
||||
$dynamicTitle = NEXT_LIVE_TITLE;
|
||||
}
|
||||
?>
|
||||
<h2><?php echo htmlspecialchars($dynamicTitle); ?></h2>
|
||||
<h1><?php echo htmlspecialchars($dynamicTitle); ?></h1>
|
||||
<?php
|
||||
if (!empty(NEXT_LIVE_DATE)) {
|
||||
$liveHour = $liveDate->format('H\hi');
|
||||
@@ -260,7 +262,7 @@ $liveStream = getLiveStream();
|
||||
?>
|
||||
<div class="no-live-message">
|
||||
<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>
|
||||
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
|
||||
</div>
|
||||
@@ -274,5 +276,6 @@ $liveStream = getLiveStream();
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
<?php include 'includes/mobile-menu.php'; ?>
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</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
|
||||
/**
|
||||
* Page de dons - Template d'exemple
|
||||
* Page de dons - Template d'exemple pour ANNU KUTE CED
|
||||
* Support via LiberaPay, Ko-fi et Stripe
|
||||
*
|
||||
* Pour personnaliser cette page :
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
require_once 'includes/config.php';
|
||||
require_once 'includes/security.php';
|
||||
require_once 'includes/structured-data.php';
|
||||
|
||||
// Vérifier si les dons sont activés
|
||||
if (!defined('DONATIONS_ENABLED') || !DONATIONS_ENABLED) {
|
||||
@@ -48,6 +49,24 @@ $currencySymbol = $currency === 'EUR' ? '€' : '$';
|
||||
$stripeOneTimeLinks = defined('STRIPE_ONE_TIME_LINKS') ? STRIPE_ONE_TIME_LINKS : [];
|
||||
$stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [];
|
||||
|
||||
/**
|
||||
* Convertit les URLs d'un texte en liens cliquables tout en échappant le reste.
|
||||
* Utilisé pour le message de transparence sur les dons.
|
||||
*/
|
||||
function linkUrlsInText(string $text): string {
|
||||
$parts = preg_split('/(https?:\/\/[^\s<]+)/i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
$result = '';
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1) {
|
||||
$url = htmlspecialchars($part, ENT_QUOTES, 'UTF-8');
|
||||
$result .= '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
|
||||
} else {
|
||||
$result .= htmlspecialchars($part, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
@@ -58,7 +77,8 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
||||
|
||||
<!-- PERSONNALISEZ: Titre et description de votre page de dons -->
|
||||
<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 -->
|
||||
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
|
||||
@@ -75,25 +95,32 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<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:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/dons.php'; ?>">
|
||||
<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 getBaseUrl() . '/img/logo.png'; ?>">
|
||||
<meta property="og:url" content="<?php echo getBaseUrl() . '/dons.php'; ?>">
|
||||
<meta property="og:type" content="website">
|
||||
<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 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "<?php echo SITE_NAME; ?>",
|
||||
"description": "<?php echo SITE_DESCRIPTION; ?>",
|
||||
"url": "<?php echo 'https://' . $_SERVER['HTTP_HOST']; ?>",
|
||||
"potentialAction": {
|
||||
"@type": "DonateAction"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<?php
|
||||
outputJsonLd(json_encode([
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "Organization",
|
||||
"name" => SITE_NAME,
|
||||
"description" => SITE_DESCRIPTION,
|
||||
"url" => getBaseUrl(),
|
||||
"potentialAction" => [
|
||||
"@type" => "DonateAction"
|
||||
]
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
?>
|
||||
|
||||
<!-- Script pour éviter le flash en mode sombre -->
|
||||
<script>
|
||||
@@ -118,24 +145,31 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
||||
<section class="donation-hero">
|
||||
<div class="donation-hero-content">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Section principale de don -->
|
||||
<section class="donation-main">
|
||||
<!-- Avertissement de transparence (optionnel) -->
|
||||
<?php if (defined('DONATIONS_OKI_DISCLAIMER') && !empty(DONATIONS_OKI_DISCLAIMER)): ?>
|
||||
<div class="donation-disclaimer" role="note">
|
||||
<p><i class="fas fa-info-circle"></i> <?php echo linkUrlsInText(DONATIONS_OKI_DISCLAIMER); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="donation-content">
|
||||
<div class="donation-message">
|
||||
<!-- PERSONNALISEZ: Votre message de don -->
|
||||
<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>
|
||||
<ul>
|
||||
<!-- PERSONNALISEZ: Vos objectifs -->
|
||||
<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-tools"></i> Développer de nouvelles fonctionnalités</li>
|
||||
<li><i class="fas fa-users"></i> Soutenir la création de contenu libre et accessible</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 pour le hub</li>
|
||||
<li><i class="fas fa-users"></i> Soutenir la création de contenu libre et accessible sur le Fédivers</li>
|
||||
</ul>
|
||||
<p><strong>Chaque don, même petit, fait la différence !</strong></p>
|
||||
</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>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<i class="fas fa-tools"></i>
|
||||
<h4>Maintenance & Développement</h4>
|
||||
<p>Assurer la sécurité, les mises à jour et l'évolution de nos outils technologiques demande un investissement constant.</p>
|
||||
<i class="fas fa-microphone-alt"></i>
|
||||
<h4>Production du podcast</h4>
|
||||
<p>Vos contributions nous aident à assurer la régularité et la qualité des épisodes d'ANNU KUTE CED.</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<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>
|
||||
</section>
|
||||
@@ -291,5 +325,6 @@ $stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [
|
||||
</script>
|
||||
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</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,16 +1,16 @@
|
||||
<?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.
|
||||
* 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_NAME')) define('ORGANIZATION_NAME', 'ORGANISATION KA INTERNATIONALE');
|
||||
if (!defined('ORGANIZATION_SHORT_NAME')) define('ORGANIZATION_SHORT_NAME', 'ANNU KUTE CED');
|
||||
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
|
||||
if (!defined('PEERTUBE_URL')) define('PEERTUBE_URL', 'https://gade.o-k-i.net');
|
||||
@@ -38,11 +38,8 @@ if (!defined('SHOW_VIDEO_VIEWS')) define('SHOW_VIDEO_VIEWS', false); // Masquer
|
||||
// format: [ID catégorie => Nom personnalisé]
|
||||
if (!defined('PRIORITY_CATEGORIES')) {
|
||||
define('PRIORITY_CATEGORIES', [
|
||||
11 => 'Actualités & Politique',
|
||||
15 => 'Science et Technologie',
|
||||
4 => 'Art',
|
||||
9 => 'Humour',
|
||||
10 => 'Divertissement'
|
||||
10 => 'Divertissement',
|
||||
15 => 'Science et Technologie'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,8 +58,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');
|
||||
|
||||
// Informations du site
|
||||
if (!defined('SITE_NAME')) define('SITE_NAME', 'FEDIVERSE OKI');
|
||||
if (!defined('SITE_DESCRIPTION')) define('SITE_DESCRIPTION', 'Plateforme multimedia indépendante');
|
||||
if (!defined('SITE_NAME')) define('SITE_NAME', 'ANNU KUTE CED');
|
||||
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_FAVICON')) define('SITE_FAVICON', 'img/favicon.png');
|
||||
|
||||
@@ -76,13 +73,13 @@ if (!defined('X_URL')) define('X_URL', '#');
|
||||
if (!defined('INSTAGRAM_URL')) define('INSTAGRAM_URL', '#');
|
||||
if (!defined('YOUTUBE_URL')) define('YOUTUBE_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
|
||||
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
|
||||
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_EMAIL')) define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
|
||||
if (!defined('LEGAL_HOST_NAME')) define('LEGAL_HOST_NAME', 'o2Switch');
|
||||
@@ -92,30 +89,39 @@ 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_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_SOURCE_CODE_URL')) define('LEGAL_SOURCE_CODE_URL', 'https://codeberg.org/OKI/fediverse.o-k-i.net');
|
||||
if (!defined('LEGAL_SERVICE_DESCRIPTION')) define('LEGAL_SERVICE_DESCRIPTION', 'est une plateforme multimédia indépendante.');
|
||||
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 le hub multimédia du podcast ANNU KUTE CED.');
|
||||
|
||||
// Fonctionnalités
|
||||
define('ENABLE_SEARCH', true);
|
||||
if (!defined('ENABLE_USER_ACCOUNTS')) define('ENABLE_USER_ACCOUNTS', false);
|
||||
|
||||
// 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)
|
||||
|
||||
// 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))).
|
||||
if (!defined('CSRF_SECRET')) {
|
||||
define('CSRF_SECRET', 'change-me-in-config-local-php');
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// Configuration de la section Hero (bannière d'accueil)
|
||||
// =========================================
|
||||
|
||||
// Type de contenu à afficher dans la section hero
|
||||
// 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')
|
||||
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')
|
||||
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');
|
||||
|
||||
// Configuration pour les playlists (HERO_TYPE = 'playlist')
|
||||
@@ -148,7 +154,7 @@ if (!defined('CASTOPOD_URL')) define('CASTOPOD_URL', 'https://kute.o-k-i.net');
|
||||
// Format: ['slug1', 'slug2', 'slug3']
|
||||
// Les épisodes de tous les podcasts seront mélangés et triés par date
|
||||
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)
|
||||
@@ -173,21 +179,14 @@ if (!defined('TAG_SHORT')) define('TAG_SHORT', 'short');
|
||||
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
|
||||
if (!defined('IMPORTANT_TAGS')) {
|
||||
define('IMPORTANT_TAGS', [
|
||||
'Chlordécone',
|
||||
'RCI',
|
||||
'Mé67',
|
||||
'Traduction',
|
||||
'fediverse'
|
||||
'ANNUKUTECED'
|
||||
]);
|
||||
}
|
||||
|
||||
// Hashtags populaires à afficher sur la page d'accueil
|
||||
if (!defined('POPULAR_TAGS')) {
|
||||
define('POPULAR_TAGS', [
|
||||
'fediverse',
|
||||
'guadeloupe',
|
||||
'Gaza',
|
||||
'Arte'
|
||||
'ANNUKUTECED'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -230,32 +229,43 @@ if (!defined('WORDPRESS_ENABLED')) define('WORDPRESS_ENABLED', false);
|
||||
// =========================================
|
||||
|
||||
// Activation du système de dons par défaut
|
||||
if (!defined('DONATIONS_ENABLED')) define('DONATIONS_ENABLED', false);
|
||||
// Par défaut les dons sont activés et pointent vers les comptes de l'association
|
||||
// ORGANISATION KA INTERNATIONALE (OKI), mainteneur de l'application.
|
||||
// Modifiez ces valeurs dans config.local.php pour utiliser vos propres comptes.
|
||||
if (!defined('DONATIONS_ENABLED')) define('DONATIONS_ENABLED', true);
|
||||
|
||||
// URLs des plateformes de don
|
||||
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', ''); // Ex: https://liberapay.com/votre-compte/donate
|
||||
if (!defined('KOFI_URL')) define('KOFI_URL', ''); // Ex: https://ko-fi.com/votre-compte
|
||||
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', 'https://liberapay.com/OKI/donate');
|
||||
if (!defined('KOFI_URL')) define('KOFI_URL', 'https://ko-fi.com/J3J5TOZPI'); // Ex: https://ko-fi.com/votre-compte
|
||||
if (!defined('STRIPE_ENABLED')) define('STRIPE_ENABLED', false);
|
||||
|
||||
// Message de transparence affiché sur la page de dons.
|
||||
// Renseignez ce texte lorsque les liens pointent vers les comptes d'une organisation
|
||||
// tierce (par exemple OKI) afin d'expliquer aux visiteurs pourquoi.
|
||||
// Les URLs présentes dans ce texte sont automatiquement transformées en liens cliquables.
|
||||
if (!defined('DONATIONS_OKI_DISCLAIMER')) {
|
||||
define('DONATIONS_OKI_DISCLAIMER', 'Les liens ci-dessous pointent vers les comptes de donation de l\'association ORGANISATION KA INTERNATIONALE (OKI) — o-k-i.net. Pour soutenir spécifiquement OKI : o-k-i.net/dons. Pour adapter cette page à votre propre instance, voir la documentation (lien "Code Source" en bas de la page)');
|
||||
}
|
||||
|
||||
// Configuration Stripe
|
||||
if (!defined('STRIPE_ONE_TIME_LINKS')) {
|
||||
define('STRIPE_ONE_TIME_LINKS', [
|
||||
1 => '', // Lien Stripe pour don de 1€
|
||||
5 => '', // Lien Stripe pour don de 5€
|
||||
10 => '', // Lien Stripe pour don de 10€
|
||||
20 => '', // Lien Stripe pour don de 20€
|
||||
50 => '', // Lien Stripe pour don de 50€
|
||||
'custom' => '' // Lien Stripe pour montant personnalisé
|
||||
1 => 'https://don.o-k-i.net/b/aEUdTw5SD3SH7m0bII', // Lien pour don de 1€
|
||||
5 => 'https://don.o-k-i.net/b/4gw6r480L1Kz5dScMO', // Lien pour don de 5€
|
||||
10 => 'https://don.o-k-i.net/b/5kA02G1Cn4WLbCgeUV', // Lien pour don de 10€
|
||||
20 => 'https://don.o-k-i.net/b/fZebLo94PfBpayc5kn', // Lien pour don de 20€
|
||||
50 => 'https://don.o-k-i.net/b/6oE5n05SDdth49O004', // Lien pour don de 50€
|
||||
'custom' => 'https://don.o-k-i.net/b/6oE2aO94P88X9u8eV4' // Lien pour montant personnalisé
|
||||
]);
|
||||
}
|
||||
|
||||
if (!defined('STRIPE_MONTHLY_LINKS')) {
|
||||
define('STRIPE_MONTHLY_LINKS', [
|
||||
1 => '', // Lien Stripe pour don mensuel de 1€
|
||||
5 => '', // Lien Stripe pour don mensuel de 5€
|
||||
10 => '', // Lien Stripe pour don mensuel de 10€
|
||||
20 => '', // Lien Stripe pour don mensuel de 20€
|
||||
50 => '', // Lien Stripe pour don mensuel de 50€
|
||||
1 => 'https://don.o-k-i.net/b/7sI9Dgch14WL7m0fZ3', // Lien pour don mensuel de 1€
|
||||
5 => 'https://don.o-k-i.net/b/8wM4iW4Ozbl95dS6ow', // Lien pour don mensuel de 5€
|
||||
10 => 'https://don.o-k-i.net/b/8wM2aO80L74TcGk3ci', // Lien pour don mensuel de 10€
|
||||
20 => 'https://don.o-k-i.net/b/00g7v894P88XgWAfZ7', // Lien pour don mensuel de 20€
|
||||
50 => 'https://don.o-k-i.net/b/4gw8zc6WHgFtcGkbIP', // Lien pour don mensuel de 50€
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -284,4 +294,3 @@ if (!defined('NEXT_LIVE_DATE')) define('NEXT_LIVE_DATE', '');
|
||||
// Chemin vers l'image d'annonce du prochain live (relatif à la racine du site)
|
||||
// Exemple: 'uploads/next-live.jpg'
|
||||
if (!defined('NEXT_LIVE_IMAGE')) define('NEXT_LIVE_IMAGE', 'uploads/next-live.jpg');
|
||||
?>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuration locale pour l'instance de PeerTube
|
||||
* Configuration locale pour l'instance ANNU KUTE CED
|
||||
*
|
||||
* Ce fichier est un exemple de configuration locale.
|
||||
* Pour l'utiliser:
|
||||
@@ -10,18 +10,20 @@
|
||||
* 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_NAME', 'ORGANISATION KA INTERNATIONALE');
|
||||
// define('ORGANIZATION_SHORT_NAME', 'ANNU KUTE CED');
|
||||
// define('ORGANIZATION_NAME', 'ANNU KUTE CED');
|
||||
|
||||
// =========================================
|
||||
// Configuration de l'API PeerTube
|
||||
// =========================================
|
||||
|
||||
// URL de l'API PeerTube (obligatoire)
|
||||
// define('PEERTUBE_URL', 'https://votre-instance.fr');
|
||||
// define('PEERTUBE_DISPLAY_NAME', 'votre-instance.fr');
|
||||
// Par défaut, le hub utilise la chaîne GADE du podcast :
|
||||
// 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)
|
||||
// define('API_KEY', 'votre_cle_api');
|
||||
@@ -38,56 +40,51 @@ define('APP_HOST_NAME', 'fediverse.o-k-i.net');
|
||||
// define('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')
|
||||
// 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é)
|
||||
// define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
|
||||
|
||||
// Configuration pour les playlists (HERO_TYPE = 'playlist')
|
||||
// Type de plateforme: 'peertube', 'funkwhale', 'castopod'
|
||||
// define('PLAYLIST_PLATFORM', 'peertube');
|
||||
// define('PLAYLIST_PLATFORM', 'castopod');
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
// define('PLAYLIST_TITLE', 'Ma Playlist');
|
||||
// define('PLAYLIST_TITLE', 'ANNU KUTE CED');
|
||||
|
||||
// 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
|
||||
// =========================================
|
||||
|
||||
// Tag pour les vidéos sur l'indépendance
|
||||
// define('TAG_INDEPENDENCE', 'indépendance');
|
||||
// define('TAG_INDEPENDANCE', 'indépendance');
|
||||
|
||||
// Tag pour les shorts
|
||||
// define('TAG_SHORT', 'short');
|
||||
|
||||
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
|
||||
define('IMPORTANT_TAGS', [
|
||||
'Chlordécone',
|
||||
'RCI',
|
||||
'Mé67',
|
||||
'Traduction',
|
||||
'fediverse'
|
||||
'ANNUKUTECED'
|
||||
]);
|
||||
|
||||
// Hashtags populaires à afficher sur la page d'accueil
|
||||
define('POPULAR_TAGS', [
|
||||
'fediverse',
|
||||
'guadeloupe',
|
||||
'Gaza',
|
||||
'Arte'
|
||||
'ANNUKUTECED'
|
||||
]);
|
||||
|
||||
// Durée maximale des shorts en secondes
|
||||
@@ -150,11 +147,8 @@ define('SHORTS_MAX_DURATION', 180); // 3 minutes
|
||||
// 17 : Kids
|
||||
// 18 : Food
|
||||
define('PRIORITY_CATEGORIES', [
|
||||
11 => 'Actualités & Politique',
|
||||
15 => 'Science et Technologie',
|
||||
4 => 'Art',
|
||||
9 => 'Humour',
|
||||
10 => 'Divertissement'
|
||||
10 => 'Divertissement',
|
||||
15 => 'Science et Technologie'
|
||||
// Ajoutez d'autres catégories selon vos besoins
|
||||
]);
|
||||
|
||||
@@ -163,10 +157,10 @@ define('PRIORITY_CATEGORIES', [
|
||||
// =========================================
|
||||
|
||||
// Nom du site
|
||||
// define('SITE_NAME', 'FEDIVERSE OKI');
|
||||
// define('SITE_NAME', 'ANNU KUTE CED');
|
||||
|
||||
// Description du site
|
||||
// define('SITE_DESCRIPTION', 'Plateforme multimedia indépendante');
|
||||
// define('SITE_DESCRIPTION', 'Hub multimédia du podcast ANNU KUTE CED');
|
||||
|
||||
// Logo du site
|
||||
// define('SITE_LOGO', 'img/logo.png');
|
||||
@@ -198,14 +192,15 @@ define('PRIORITY_CATEGORIES', [
|
||||
// define('TIKTOK_URL', 'https://tiktok.com/@votrecompte');
|
||||
|
||||
// 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
|
||||
// =========================================
|
||||
|
||||
// Email de contact
|
||||
// define('CONTACT_EMAIL', 'contact@votredomaine.com');
|
||||
// define('CONTACT_EMAIL', 'kontak@o-k-i.net');
|
||||
|
||||
// =========================================
|
||||
// Fonctionnalités
|
||||
@@ -235,7 +230,8 @@ define('PRIORITY_CATEGORIES', [
|
||||
// =========================================
|
||||
|
||||
// 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
|
||||
// define('MASTODON_DATE_FORMAT', 'fr-FR');
|
||||
@@ -261,7 +257,7 @@ define('PRIORITY_CATEGORIES', [
|
||||
// Mentions légales
|
||||
// =========================================
|
||||
|
||||
// define('LEGAL_COPYRIGHT', 'OKI');
|
||||
// define('LEGAL_COPYRIGHT', 'ANNU KUTE CED');
|
||||
// define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
|
||||
// define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
|
||||
// define('LEGAL_HOST_NAME', 'o2Switch');
|
||||
@@ -271,8 +267,9 @@ define('PRIORITY_CATEGORIES', [
|
||||
// define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
|
||||
// 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_SOURCE_CODE_URL', 'https://codeberg.org/OKI/fediverse.o-k-i.net');
|
||||
// define('LEGAL_SERVICE_DESCRIPTION', ' est une plateforme multimédia indépendante.');
|
||||
// Source du fork sur LaBola
|
||||
// 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
|
||||
@@ -314,14 +311,15 @@ define('WORDPRESS_ENABLED', false);
|
||||
// define('CASTOPOD_ENABLED', true);
|
||||
|
||||
// URL de l'instance Castopod
|
||||
// Instance KUTE : https://kute.o-k-i.net
|
||||
// define('CASTOPOD_URL', 'https://kute.o-k-i.net');
|
||||
|
||||
// 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
|
||||
// define('CASTOPOD_PODCAST_SLUGS', [
|
||||
// 'joukawouve',
|
||||
// 'cspcc',
|
||||
// 'radyobokaz'
|
||||
// 'annu_kute_cedric'
|
||||
// ]);
|
||||
|
||||
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
|
||||
@@ -333,6 +331,11 @@ define('WORDPRESS_ENABLED', false);
|
||||
// define('CACHE_ENABLED', true);
|
||||
// define('CACHE_DURATION', 3600); // 1 heure recommandé
|
||||
|
||||
// Clé secrète utilisée pour signer les tokens CSRF stateless.
|
||||
// Générez une valeur aléatoire unique pour votre instance :
|
||||
// php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
|
||||
// define('CSRF_SECRET', 'votre-cle-secrete-aleatoire');
|
||||
|
||||
// =========================================
|
||||
// Intégration Funkwhale (Musique)
|
||||
// =========================================
|
||||
@@ -351,10 +354,14 @@ define('WORDPRESS_ENABLED', false);
|
||||
// =========================================
|
||||
|
||||
// Activer/désactiver le système de dons
|
||||
// Par défaut les dons sont activés et pointent vers le compte Liberapay de OKI.
|
||||
// Décommentez et passez à false pour désactiver complètement la page de dons.
|
||||
// define('DONATIONS_ENABLED', true);
|
||||
|
||||
// URLs des plateformes de don
|
||||
// define('LIBERAPAY_URL', 'https://liberapay.com/votre-compte/donate');
|
||||
// Les exemples ci-dessous reproduisent la configuration par défaut (OKI).
|
||||
// Remplacez-les par vos propres comptes si vous déployez une instance indépendante.
|
||||
// define('LIBERAPAY_URL', 'https://liberapay.com/OKI/donate');
|
||||
// define('KOFI_URL', 'https://ko-fi.com/votre-compte');
|
||||
|
||||
// Activer/désactiver les dons via Stripe
|
||||
@@ -385,27 +392,34 @@ define('WORDPRESS_ENABLED', false);
|
||||
// Devise pour les dons
|
||||
// define('DONATION_CURRENCY', 'EUR');
|
||||
|
||||
// Message de transparence affiché sur la page de dons.
|
||||
// À renseigner si les liens pointent vers les comptes d'une organisation tierce
|
||||
// (par exemple OKI) afin d'expliquer aux visiteurs pourquoi.
|
||||
// Les URLs présentes dans ce texte sont automatiquement transformées en liens cliquables.
|
||||
// Laissez vide ou commentez pour masquer ce message.
|
||||
// define('DONATIONS_OKI_DISCLAIMER', 'Les liens ci-dessous pointent vers les comptes de donation de l\'association ORGANISATION KA INTERNATIONALE (OKI) — https://o-k-i.net. Pour soutenir spécifiquement OKI : https://o-k-i.net/dons. Pour adapter cette page à votre propre instance, voir la documentation : https://labola.o-k-i.net/cedric/annu-kute-ced');
|
||||
|
||||
// =========================================
|
||||
// Texte de présentation du mouvement
|
||||
// Texte de présentation du podcast
|
||||
// =========================================
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
// 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');
|
||||
|
||||
// Texte alternatif pour l'image du mouvement (accessibilité)
|
||||
// define('MOVEMENT_IMAGE_ALT', 'Texte alternatif pour l\'image du mouvement');
|
||||
// Texte alternatif pour l'image (accessibilité)
|
||||
// define('MOVEMENT_IMAGE_ALT', 'Logo du podcast ANNU KUTE CED');
|
||||
|
||||
// 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 +433,7 @@ define('WORDPRESS_ENABLED', false);
|
||||
// define('NEXT_LIVE_TITLE', '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)
|
||||
// define('NEXT_LIVE_DATE', '2025-10-11 10:00:00');
|
||||
|
||||
@@ -664,7 +664,7 @@ function getCastopodEpisodes($castopodUrl = null, $podcastSlugs = null, $count =
|
||||
}
|
||||
|
||||
$castopodUrl = $castopodUrl ?? CASTOPOD_URL;
|
||||
$podcastSlugs = $podcastSlugs ?? (defined('CASTOPOD_PODCAST_SLUGS') ? CASTOPOD_PODCAST_SLUGS : ['joukawouve']);
|
||||
$podcastSlugs = $podcastSlugs ?? (defined('CASTOPOD_PODCAST_SLUGS') ? CASTOPOD_PODCAST_SLUGS : ['annu_kute_cedric']);
|
||||
$count = $count ?? CASTOPOD_EPISODES_COUNT;
|
||||
|
||||
// Convertir en tableau si c'est une chaîne unique (rétrocompatibilité)
|
||||
@@ -866,8 +866,9 @@ function getCastopodEpisodes($castopodUrl = null, $podcastSlugs = null, $count =
|
||||
// Limiter au nombre demandé
|
||||
$allEpisodes = array_slice($allEpisodes, 0, $count);
|
||||
|
||||
// Mettre en cache
|
||||
if (defined('CACHE_ENABLED') && CACHE_ENABLED) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,6 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -142,39 +142,49 @@ function validateHttpHeaders() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
// Démarrer la session seulement si les en-têtes n'ont pas été envoyés
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['csrf_token'];
|
||||
$timestamp = time();
|
||||
$hash = hash_hmac('sha256', (string) $timestamp, CSRF_SECRET);
|
||||
return $timestamp . ':' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide un token CSRF
|
||||
* Valide un token CSRF stateless
|
||||
*
|
||||
* @param string $token Token à valider
|
||||
* @return bool True si le token est valide
|
||||
* @return bool True si le token est valide et non expiré
|
||||
*/
|
||||
function validateCSRFToken($token) {
|
||||
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
if (empty($token) || !is_string($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($_SESSION['csrf_token'], $token);
|
||||
$parts = explode(':', $token, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$timestamp, $hash] = $parts;
|
||||
|
||||
// Vérifier que le timestamp est numérique et pas trop ancien (1 heure)
|
||||
if (!ctype_digit($timestamp)) {
|
||||
return false;
|
||||
}
|
||||
$age = abs(time() - (int) $timestamp);
|
||||
if ($age > 3600) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedHash = hash_hmac('sha256', $timestamp, CSRF_SECRET);
|
||||
return hash_equals($expectedHash, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,18 +320,31 @@ function setSecurityHeaders() {
|
||||
/**
|
||||
* Valide l'origine de la requête pour les requêtes AJAX
|
||||
*
|
||||
* Accepte le header Origin, ou à défaut un Referer same-origin
|
||||
* (certains navigateurs n'envoient pas Origin sur les requêtes same-origin).
|
||||
*
|
||||
* @return bool True si l'origine est valide
|
||||
*/
|
||||
function validateAjaxOrigin() {
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if (empty($origin) || empty($host)) {
|
||||
if (empty($host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedOrigin = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
|
||||
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http');
|
||||
$expectedOrigin = $scheme . '://' . $host;
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (!empty($origin)) {
|
||||
return $origin === $expectedOrigin;
|
||||
}
|
||||
|
||||
// Fallback : vérifier le referer si Origin est absent
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if (!empty($referer)) {
|
||||
return strpos($referer, $expectedOrigin) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
?>
|
||||
@@ -29,7 +29,7 @@ function generateWebSiteJsonLd() {
|
||||
],
|
||||
"publisher" => [
|
||||
"@type" => "Organization",
|
||||
"name" => "OKI",
|
||||
"name" => ORGANIZATION_NAME,
|
||||
"url" => $baseUrl,
|
||||
"logo" => [
|
||||
"@type" => "ImageObject",
|
||||
@@ -69,6 +69,8 @@ function generateVideoObjectJsonLd($videoData, $video) {
|
||||
? truncateText(strip_tags($video['description']), 300)
|
||||
: "Regardez cette vidéo sur " . SITE_NAME,
|
||||
"url" => $videoUrl,
|
||||
"embedUrl" => PEERTUBE_URL . "/videos/embed/" . $video['id'],
|
||||
"inLanguage" => "fr-FR",
|
||||
"thumbnailUrl" => $thumbnailUrl,
|
||||
"uploadDate" => formatDateISO8601($video['date']),
|
||||
"duration" => $duration,
|
||||
@@ -159,6 +161,147 @@ function generateVideoObjectJsonLd($videoData, $video) {
|
||||
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le JSON-LD pour les fils d'Ariane (BreadcrumbList)
|
||||
*
|
||||
|
||||
@@ -13,6 +13,12 @@ if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
|
||||
require_once 'includes/structured-data.php';
|
||||
// Appliquer les en-têtes de sécurité
|
||||
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>
|
||||
<html lang="fr">
|
||||
@@ -21,6 +27,8 @@ setSecurityHeaders();
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>">
|
||||
@@ -54,9 +62,9 @@ setSecurityHeaders();
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<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: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:description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>">
|
||||
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||
<meta property="og:url" content="<?php echo getBaseUrl() . '/'; ?>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||
<meta property="og:locale" content="fr_FR">
|
||||
@@ -64,8 +72,8 @@ setSecurityHeaders();
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<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:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
||||
<meta name="twitter:description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>">
|
||||
<meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||
|
||||
<!-- Données structurées JSON-LD pour le site web -->
|
||||
<?php
|
||||
@@ -78,6 +86,11 @@ setSecurityHeaders();
|
||||
];
|
||||
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
|
||||
outputJsonLd($breadcrumbJsonLd);
|
||||
|
||||
// Données structurées du podcast (PodcastSeries + PodcastEpisode)
|
||||
if (!empty($castopodEpisodes)) {
|
||||
outputJsonLd(generatePodcastJsonLd($castopodEpisodes));
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Script pour éviter le flash en mode sombre -->
|
||||
@@ -105,6 +118,7 @@ setSecurityHeaders();
|
||||
<?php include 'includes/sidebar.php'; ?>
|
||||
<!-- Contenu principal -->
|
||||
<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'; ?>
|
||||
<!-- Hero and Mastodon container -->
|
||||
<div class="hero-mastodon-wrapper">
|
||||
@@ -130,8 +144,7 @@ setSecurityHeaders();
|
||||
</div>
|
||||
<div class="castopod-episodes-list">
|
||||
<?php
|
||||
$castopodEpisodes = getCastopodEpisodes();
|
||||
|
||||
// $castopodEpisodes a été récupéré en début de page (JSON-LD + affichage)
|
||||
if (empty($castopodEpisodes)) {
|
||||
echo '<div class="castopod-no-episodes">Aucun épisode disponible</div>';
|
||||
} else {
|
||||
@@ -390,7 +403,7 @@ setSecurityHeaders();
|
||||
<section class="video-section" aria-labelledby="shorts-heading">
|
||||
<div class="section-header">
|
||||
<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>
|
||||
<h2 id="shorts-heading" class="section-title">Shorts</h2>
|
||||
</div>
|
||||
@@ -446,7 +459,7 @@ setSecurityHeaders();
|
||||
<section class="video-section" aria-labelledby="recent-videos-heading">
|
||||
<header class="section-header">
|
||||
<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>
|
||||
<h2 id="recent-videos-heading" class="section-title">Dernières vidéos</h2>
|
||||
</header>
|
||||
@@ -508,7 +521,7 @@ setSecurityHeaders();
|
||||
<section class="video-section" aria-labelledby="trending-videos-heading">
|
||||
<header class="section-header">
|
||||
<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>
|
||||
<h2 id="trending-videos-heading" class="section-title">Tendances</h2>
|
||||
</header>
|
||||
@@ -526,7 +539,7 @@ setSecurityHeaders();
|
||||
?>
|
||||
<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']; ?>">
|
||||
<img src="<?php echo htmlspecialchars($video['thumbnail']); ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
|
||||
<div class="video-play-icon">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</div>
|
||||
@@ -577,7 +590,7 @@ setSecurityHeaders();
|
||||
<section class="video-section" data-category-id="<?php echo $category['id']; ?>" aria-labelledby="category-heading-<?php echo $category['id']; ?>">
|
||||
<header class="section-header">
|
||||
<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>
|
||||
<h2 id="category-heading-<?php echo $category['id']; ?>" class="section-title"><?php echo htmlspecialchars($category['name']); ?></h2>
|
||||
</header>
|
||||
@@ -586,7 +599,7 @@ setSecurityHeaders();
|
||||
<?php foreach ($category['videos'] as $video): ?>
|
||||
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
|
||||
<div class="video-thumbnail">
|
||||
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>">
|
||||
<img src="<?php echo htmlspecialchars($video['thumbnail']); ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
|
||||
<div class="video-play-icon">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</div>
|
||||
@@ -654,7 +667,8 @@ setSecurityHeaders();
|
||||
|
||||
<!-- Section Tendances Hashtags -->
|
||||
<aside class="tags-section-container" aria-labelledby="hashtags-heading">
|
||||
<h2 id="hashtags-heading" class="section-title centered">Tendances</h2>
|
||||
<h2 id="hashtags-heading" class="section-title centered"><i class="fas fa-fire trending-title-icon" aria-hidden="true"></i> Tendances</h2>
|
||||
<p class="trending-hashtag-emojis" aria-hidden="true">🎙️🔥📈🔝⬆️↗️🚩🇲🇶</p>
|
||||
|
||||
<div class="tags-section">
|
||||
<?php
|
||||
@@ -678,34 +692,11 @@ setSecurityHeaders();
|
||||
<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>
|
||||
|
||||
<!-- 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(); ?>">
|
||||
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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -456,7 +456,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
button.disabled = true;
|
||||
|
||||
// Préparer l'URL avec les paramètres
|
||||
let url = `ajax/load-more-videos.php?type=${videoType}&page=${page}`;
|
||||
let url = `ajax/load-more-videos?type=${videoType}&page=${page}`;
|
||||
if (videoType === 'category' && categoryId) {
|
||||
url += `&category=${categoryId}`;
|
||||
}
|
||||
@@ -502,16 +502,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialiser le lazy loading pour les nouvelles images
|
||||
initLazyLoading();
|
||||
} else {
|
||||
// En cas d'erreur, afficher un message et réactiver le bouton
|
||||
// En cas d'erreur, afficher un message temporaire et réactiver le bouton
|
||||
console.error('Erreur lors du chargement des vidéos:', data.error);
|
||||
button.textContent = 'Voir plus';
|
||||
const originalText = 'Voir plus';
|
||||
button.textContent = 'Erreur de chargement';
|
||||
button.disabled = false;
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Erreur lors de la requête AJAX:', error);
|
||||
button.textContent = 'Voir plus';
|
||||
const originalText = 'Voir plus';
|
||||
button.textContent = 'Erreur de chargement';
|
||||
button.disabled = false;
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +1,17 @@
|
||||
<?php
|
||||
// Inclure la configuration
|
||||
require_once 'includes/config.php';
|
||||
// Appliquer les en-têtes de sécurité
|
||||
setSecurityHeaders();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
||||
|
||||
@@ -13,25 +21,25 @@
|
||||
<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="#ffffff">
|
||||
<meta name="theme-color" content="#FF0000">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="Mentions Légales - FEDIVERSE OKI">
|
||||
<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:title" content="Mentions Légales - <?php echo SITE_NAME; ?>">
|
||||
<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:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
||||
<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">
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Mentions Légales - FEDIVERSE OKI">
|
||||
<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:title" content="Mentions Légales - <?php echo SITE_NAME; ?>">
|
||||
<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'; ?>">
|
||||
|
||||
<!-- Script pour éviter le flash en mode sombre -->
|
||||
<script>
|
||||
<script nonce="<?php echo getCspNonce(); ?>">
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
@@ -44,12 +52,6 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<?php
|
||||
// Inclure la configuration
|
||||
require_once 'includes/config.php';
|
||||
// Appliquer les en-têtes de sécurité
|
||||
setSecurityHeaders();
|
||||
?>
|
||||
<?php include 'includes/sidebar.php'; ?>
|
||||
<!-- Contenu principal -->
|
||||
<div class="main-content">
|
||||
@@ -58,9 +60,9 @@
|
||||
<!-- Section Mentions Légales -->
|
||||
<div class="section-header">
|
||||
<div class="section-logo">
|
||||
<img src="img/logo.png" alt="FEDIVERSE OKI">
|
||||
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
|
||||
</div>
|
||||
<h2 class="section-title">Mentions Légales</h2>
|
||||
<h1 class="section-title">Mentions Légales</h1>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
@@ -73,6 +75,7 @@
|
||||
<h3 class="info-header">2. Description du service</h3>
|
||||
<p class="info-text">
|
||||
<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>
|
||||
|
||||
<h3 class="info-header">3. Responsabilité des utilisateurs</h3>
|
||||
@@ -184,7 +187,11 @@
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h3 class="info-header">9. Icônes et marques tierces</h3>
|
||||
@@ -238,5 +245,6 @@
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
<?php include 'includes/mobile-menu.php'; ?>
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -53,6 +53,13 @@ if ($resultsCount > 0) {
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
||||
|
||||
@@ -62,11 +69,11 @@ if ($resultsCount > 0) {
|
||||
<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="#ffffff">
|
||||
<meta name="theme-color" content="#FF0000">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
|
||||
<meta property="og:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour \"' . htmlspecialchars($query) . '\" sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. 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:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
||||
<meta property="og:type" content="website">
|
||||
@@ -76,7 +83,7 @@ if ($resultsCount > 0) {
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
|
||||
<meta name="twitter:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour \"' . htmlspecialchars($query) . '\" sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. 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'; ?>">
|
||||
|
||||
<?php if (!empty($query) && !empty($currentPageVideos)): ?>
|
||||
@@ -132,12 +139,12 @@ if ($resultsCount > 0) {
|
||||
</div>
|
||||
<?php if (!empty($query)): ?>
|
||||
<?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: ?>
|
||||
<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 else: ?>
|
||||
<h2 class="section-title">Rechercher des vidéos</h2>
|
||||
<h1 class="section-title">Rechercher des vidéos</h1>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -261,5 +268,6 @@ if ($resultsCount > 0) {
|
||||
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/search.js"></script>
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,4 +22,4 @@ Disallow: /conf/
|
||||
Disallow: /cache/
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://VOTRE-DOMAINE/sitemap.xml
|
||||
Sitemap: https://example.com/sitemap.xml
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "FEDIVERSE OKI - ORGANISATION KA INTERNATIONALE",
|
||||
"short_name": "FEDIVERSE OKI",
|
||||
"description": "Plateforme multimédia indépendante",
|
||||
"name": "ANNU KUTE CED - Hub multimédia du podcast",
|
||||
"short_name": "ANNU KUTE CED",
|
||||
"description": "Hub multimédia du podcast ANNU KUTE CED",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/</loc>
|
||||
<loc>https://example.com/</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/index</loc>
|
||||
<loc>https://example.com/index</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/index.php</loc>
|
||||
<loc>https://example.com/index.php</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/direct</loc>
|
||||
<loc>https://example.com/direct</loc>
|
||||
<changefreq>hourly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/direct.php</loc>
|
||||
<loc>https://example.com/direct.php</loc>
|
||||
<changefreq>hourly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/mentions-legales</loc>
|
||||
<loc>https://example.com/mentions-legales</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/mentions-legales.php</loc>
|
||||
<loc>https://example.com/mentions-legales.php</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/recherche</loc>
|
||||
<loc>https://example.com/recherche</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/recherche.php</loc>
|
||||
<loc>https://example.com/recherche.php</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/categories</loc>
|
||||
<loc>https://example.com/categories</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/categories.php</loc>
|
||||
<loc>https://example.com/categories.php</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/video</loc>
|
||||
<loc>https://example.com/video</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://VOTRE-DOMAINE/video.php</loc>
|
||||
<loc>https://example.com/video.php</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const CACHE_NAME = 'fediverse-oki-08072026-0720';
|
||||
const STATIC_CACHE_NAME = 'fediverse-oki-static-08072026-0720';
|
||||
const DYNAMIC_CACHE_NAME = 'fediverse-oki-dynamic-08072026-0720';
|
||||
// Version du cache : à bumper à chaque déploiement (format JJMMAAAA-HHMM).
|
||||
// Tout changement de ce fichier déclenche l'installation d'un nouveau
|
||||
// Service Worker chez les visiteurs ; les anciens caches sont purgés à
|
||||
// l'activation (voir l'event 'activate' plus bas).
|
||||
const STATIC_CACHE_NAME = 'annu-kute-ced-static-26072026-1946';
|
||||
const DYNAMIC_CACHE_NAME = 'annu-kute-ced-dynamic-26072026-1946';
|
||||
|
||||
// Ressources à mettre en cache immédiatement
|
||||
const STATIC_ASSETS = [
|
||||
@@ -12,7 +15,6 @@ const STATIC_ASSETS = [
|
||||
'/css/video-page.css',
|
||||
'/css/mastodon-timeline.min.css',
|
||||
'/js/main.js',
|
||||
'/js/categories.js',
|
||||
'/js/search.js',
|
||||
'/js/mastodon-timeline.umd.js',
|
||||
'/img/logo.png',
|
||||
@@ -46,13 +48,15 @@ self.addEventListener('install', event => {
|
||||
console.log('Service Worker: Mise en cache des assets statiques');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
.then(() => {
|
||||
return self.skipWaiting();
|
||||
})
|
||||
.catch(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
|
||||
@@ -202,20 +206,10 @@ function isApiRequest(url) {
|
||||
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 => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
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,160 @@
|
||||
"""
|
||||
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}"
|
||||
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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,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,90 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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 = vm.runInContext(COUNTDOWN_SOURCE + '\nCountdownTimer;', sandbox);
|
||||
|
||||
return { CountdownTimer, window: windowMock, timers };
|
||||
}
|
||||
|
||||
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(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(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(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(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(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(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]);
|
||||
});
|
||||
});
|
||||
@@ -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, initCategories() — appelée au chargement de config.php — est
|
||||
* neutralisée et PEERTUBE_CATEGORIES vaut un tableau vide.
|
||||
*
|
||||
* 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'; // fournit les fonctions de formatage
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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');
|
||||
|
||||
// --- Nettoyage du dossier temporaire ----------------------------------------
|
||||
|
||||
foreach (glob($tmpDir . '/cache_*.json') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($tmpDir);
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests unitaires pour les fonctions de formatage de includes/config.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'
|
||||
);
|
||||
|
||||
// --- 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');
|
||||
|
||||
// --- 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'
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests unitaires pour markdown_to_html (includes/lib/markdown.php)
|
||||
*
|
||||
* Les sorties attendues reflètent le comportement actuel de la fonction,
|
||||
* y compris ses particularités (voir la note sur les listes à puces).
|
||||
*/
|
||||
|
||||
// --- Échappement XSS ---------------------------------------------------------
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
// --- Listes ------------------------------------------------------------------
|
||||
|
||||
// Note : la passe des listes numérotées s'applique aussi aux <li> déjà
|
||||
// produits par la passe des puces, d'où un double enveloppement <ul><ol>.
|
||||
// C'est le comportement actuel, verrouillé ici contre toute régression.
|
||||
assertEquals(
|
||||
"<ul><ol><li>a</li>\n<li>b</li></ol></ul>",
|
||||
markdown_to_html("- a\n- b"),
|
||||
'markdown_to_html convertit les listes à puces (double enveloppement actuel)'
|
||||
);
|
||||
assertEquals(
|
||||
"<ol><li>a</li>\n<li>b</li></ol>",
|
||||
markdown_to_html("1. a\n2. b"),
|
||||
'markdown_to_html convertit les listes numérotées en <ol>'
|
||||
);
|
||||
|
||||
// --- 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,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,157 @@
|
||||
<?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'
|
||||
);
|
||||
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');
|
||||
@@ -120,6 +120,10 @@ if (empty($videoData) || isset($videoData['error'])) {
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<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/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">
|
||||
@@ -130,7 +134,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
||||
<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="#ffffff">
|
||||
<meta name="theme-color" content="#FF0000">
|
||||
|
||||
<!-- 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; ?>">
|
||||
@@ -141,6 +145,7 @@ if (empty($videoData) || isset($videoData['error'])) {
|
||||
<meta property="og:url" content="<?php echo (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"; ?>">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||
<meta property="og:locale" content="fr_FR">
|
||||
|
||||
<!-- Meta tags pour Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
@@ -668,5 +673,6 @@ if (empty($videoData) || isset($videoData['error'])) {
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="js/pwa-update.js?v=<?php echo filemtime('js/pwa-update.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||