= 🎙️ ANNU KUTE CED — Hub multimédia du podcast
:toc: left
:toc-title: Sommaire / Contents
:toclevels: 3

[[top]]
🌍 *Hub multimédia du podcast ANNU KUTE CED* +
🌍 *Multimedia hub for the ANNU KUTE CED podcast*

[.text-center]
*Choisissez votre langue / Choose your language :* +
<<fr,🇫🇷 Version française>> · <<en,🇬🇧 English version>>

'''

[[fr]]
== 🇫🇷 Version française

=== 📖 Description

*ANNU KUTE CED* est une interface web responsive qui fait office de *hub multimédia* pour le podcast du même nom. Elle regroupe en un seul lieu :

- 🎥 la chaîne *PeerTube* du podcast (vidéos, shorts, directs) ;
- 🎙️ son compte *Castopod* (derniers épisodes, lecture audio intégrée) ;
- 📡 sa timeline *Mastodon* (actualités et annonces).

La plateforme est légère, déployable sur un serveur mutualisé (PHP + serveur web, sans base de données) et optimisée pour mobile et desktop. C'est aussi une *Progressive Web App* installable, avec mode hors ligne.

🎯 *Mission* : offrir un point d'entrée unique, libre et décentralisé, pour découvrir, écouter et suivre le podcast ANNU KUTE CED sans dépendre des grandes plateformes propriétaires.

=== 🌳 Origine du projet

Cette application est un *fork* de https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI[*FEDIVERSE OKI*^], développée par l'https://o-k-i.net[ORGANISATION KA INTERNATIONALE (OKI)^], elle-même issue du projet https://codeberg.org/Ka-Ubuntu/kaubuntu.re[kaubuntu.re^] du mouvement https://kaubuntu.com[Ka-Ubuntu^]. La licence d'origine (GNU AGPL v3) est conservée et respectée.

[cols="1,3",options="header"]
|===
| Élément | Détail

| Dépôt upstream
| https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI

| Dépôt de ce fork
| `git@labola.o-k-i.net:cedric/annu-kute-ced.git`

| Domaine (exemple)
| https://example.com

| Gouvernance
| Application maintenue par OKI ; fork porté par le propriétaire du dépôt *cedric* (Cédric Famibelle-Pronzola)

| Licence
| https://www.gnu.org/licenses/agpl-3.0.html[GNU Affero General Public License v3^] (AGPL-V3) ou ultérieure
|===

=== 🔗 Sources et instances utilisées

Le hub est configuré par défaut pour agréger les sources du podcast ANNU KUTE CED :

[cols="1,2,3",options="header"]
|===
| Service | Instance | Compte / chaîne

| 🎥 PeerTube (vidéos, lives)
| GADE — `https://gade.o-k-i.net`
| https://gade.o-k-i.net/c/annu_kute_ced/videos[`annu_kute_ced`^]

| 🎙️ Castopod (épisodes audio)
| KUTE — `https://kute.o-k-i.net`
| https://kute.o-k-i.net/@annu_kute_cedric[`@annu_kute_cedric`^]

| 📡 Mastodon (timeline)
| BOKANTE — `https://bokante.o-k-i.net`
| https://bokante.o-k-i.net/@cedric[`@cedric`^]

| 🎵 Funkwhale (musique, optionnel, désactivé par défaut)
| MIZIK — `https://mizik.o-k-i.net`
| morceaux aléatoires de l'instance
|===

Toutes ces sources sont modifiables dans `includes/config.local.php` (voir <<fr-configuration,Configuration>>).

=== ✨ Fonctionnalités

==== 🎥 Vidéos PeerTube

- Vidéos récentes, tendances et par catégories (configurables)
- *Shorts* : carrousel dédié aux vidéos portrait de moins de 3 minutes (défilement tactile)
- Page vidéo complète : lecteur PeerTube embarqué, description Markdown, badge de licence Creative Commons, commentaires (lecture seule), vidéos suggérées
- *Téléchargement* : fichiers directs et playlists HLS, avec résolution et taille
- *Partage* : copie de lien, code d'intégration, e-mail, Facebook, X, WhatsApp, LinkedIn, Telegram
- Recherche plein texte et recherche par hashtag (préfixe `#`)
- Pagination AJAX « Voir plus » (protégée par jeton CSRF et vérification d'origine)

==== 📺 Directs et annonces

- Page *Direct* (`/direct`) : embed du live PeerTube s'il est en cours, avec autoplay
- *Annonce du prochain live* : date et heure converties automatiquement pour 5 territoires (Ma'ohi Nui, Martinique/Guadeloupe, Guyane, France, Kanaky), image personnalisable (4:5, 1:1, 16:9)
- Section *hero* d'accueil configurable : live, vidéo unique, playlist (PeerTube / Funkwhale / Castopod) ou masquée

==== 🎙️ Podcast et audio

- *Castopod* : derniers épisodes via flux RSS, avec *lecteur audio intégré* (lecture/pause, avance automatique à l'épisode suivant, un seul flux à la fois)
- *Funkwhale* (optionnel) : sélection aléatoire de morceaux de l'instance, avec le même lecteur intégré

==== 📡 Réseaux sociaux et contenus externes

- *Timeline Mastodon* intégrée (bibliothèque vendored https://gitlab.com/idotj/mastodon-embed-timeline[mastodon-embed-timeline^] v4.7.0, AGPLv3)
- *Compatibilité Pleroma* : adaptateur qui convertit les réponses de l'API Pleroma au format Mastodon attendu par la timeline
- *WordPress* (optionnel) : derniers articles via l'API REST, avec image à la une et auteur

==== 📱 PWA et confort d'utilisation

- *Progressive Web App* installable (bouton d'installation automatique)
- Mode hors ligne : pages et ressources statiques en cache via Service Worker
- Indicateur visuel de perte de connexion
- *Mode sombre* (basé sur `prefers-color-scheme`, mémorisé en `localStorage`)
- Interface entièrement responsive (mobile, tablette, desktop)

==== 💝 Dons, compte à rebours et divers

- *Page de dons* : LiberaPay, Ko-fi et Stripe (dons ponctuels et mensuels, montants suggérés)
- *Compte à rebours* : page de lancement/maintenance multi-fuseaux qui verrouille le site jusqu'à la date cible
- Bloc « À propos » configurable (titre, deux paragraphes, image légendée)

==== 🔍 SEO et données structurées

- *JSON-LD* généré automatiquement : `WebSite` (avec `SearchAction`), `PodcastSeries` et `PodcastEpisode` (avec `AudioObject`) pour le podcast Castopod, `VideoObject` (avec `embedUrl`), `CollectionPage`, `BreadcrumbList`, `Organization`
- *Meta descriptions* sur toutes les pages et *URL canoniques* (`link rel="canonical"`) ; pages de résultats de recherche en `noindex, follow`
- Balises *Open Graph* et *Twitter Cards*
- `sitemap.xml` et `robots.txt` fournis en exemples
- Prêt pour *Plausible Analytics* (voir <<fr-analytics,Analytics>>) : désactivé par défaut, sans cookies et conforme RGPD

==== 🛡️ Sécurité

- En-têtes de sécurité complets : CSP dynamique avec *nonce* par requête, HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`
- Protection *CSRF* sur les requêtes AJAX, vérification de l'en-tête `Origin`
- Validation anti-*SSRF* des URLs d'instances et liste blanche des endpoints de l'API PeerTube
- Échappement systématique des sorties (anti-XSS)
- Exemples de configuration sécurisée pour Apache et Nginx dans `conf/`

=== 🛠️ Technologies utilisées

- 📄 HTML5, 🎨 CSS3 (Media Queries), ⚡ JavaScript vanilla (sans framework, sans jQuery)
- 🐘 PHP 7.4+ (aucune base de données requise)
- 🔧 Service Worker (cache offline) et 📋 Web App Manifest (installation)
- 📦 Bibliothèques :
  * 🎯 Font Awesome 7 (icônes, via CDN cdnjs)
  * 📡 mastodon-embed-timeline v4.7.0 (vendored dans `js/`, AGPLv3)
  * 📊 Plausible Analytics (pré-configuré, à activer manuellement)

=== 📁 Structure du projet

[source]
----
├── .gitea/
│   └── workflows/                  # CI/CD Gitea Actions (check-pr, deploy-prod)
├── ajax/
│   └── load-more-videos.php        # Endpoint AJAX « Voir plus » (CSRF + Origin)
├── conf/
│   ├── .htaccess.sample            # Configuration Apache sécurisée
│   └── nginx.conf.sample           # Configuration Nginx sécurisée
├── css/                            # Feuilles de style (styles, vidéo, catégories,
│   │                               #   recherche, dons, countdown, mastodon,
│   ├── castopod-podcasts.css       #   castopod, funkwhale, wordpress, grille…)
│   └── …
├── docs/
│   ├── wireframes/                 # Maquettes d'origine (PNG desktop + mobile)
│   ├── docinfo.html                # Styles d'impression (fallback emoji) pour l'export
│   └── generate-readme-pdf.sh      # Export de la doc : README.html / README.pdf
├── img/                            # Logo, favicons, icône de lecture
├── includes/
│   ├── config.php                  # Bootstrap + fonctions API (PeerTube, Castopod,
│   │                               #   Funkwhale) et formatage
│   ├── config.default.php          # Valeurs par défaut de toutes les constantes
│   ├── config.local.php.sample     # Modèle de configuration locale (à copier)
│   ├── security.php                # CSRF, CSP + nonce, en-têtes, validateurs
│   ├── simple-cache.php            # Cache fichier de l'API (cache/api/)
│   ├── wordpress.php               # Intégration API REST WordPress
│   ├── structured-data.php         # Générateurs JSON-LD
│   ├── header.php                  # Barre supérieure (recherche, réseaux, thème…)
│   ├── footer.php                  # Pied de page
│   ├── sidebar.php                 # Navigation latérale
│   ├── mobile-menu.php             # Menu mobile coulissant
│   ├── hero-section.php            # Bannière d'accueil (live/vidéo/playlist)
│   └── lib/
│       └── markdown.php            # Rendu Markdown (sous-ensemble PeerTube)
├── js/
│   ├── main.js                     # UI globale (thème, carrousel, « Voir plus »…)
│   ├── audio-player.js             # Lecteur audio Castopod / Funkwhale
│   ├── countdown.js                # Compte à rebours
│   ├── search.js                   # Cartes cliquables de la recherche
│   ├── mastodon-config.php         # Configuration JS de la timeline (servie en JS)
│   ├── mastodon-timeline.umd.js    # mastodon-embed-timeline v4.7.0 (vendored)
│   ├── pleroma-adapter.js          # Adaptateur Pleroma → API Mastodon
│   └── pwa-update.js               # Enregistrement SW + modal de mise à jour
├── scripts/
│   ├── benchmark.php               # Benchmark TTFB froid/chaud des pages clés
│   ├── check.sh                    # Vérifications qualité en local (avant push)
│   └── warm-cache.php              # Préchauffage du cache API (cron)
├── uploads/                        # Images d'annonces (non versionné, hors .gitkeep)
├── index.php                       # Page d'accueil (agrégateur)
├── video.php                       # Page de lecture d'une vidéo
├── categories.php                  # Page d'une catégorie
├── recherche.php                   # Page de recherche
├── direct.php                      # Page du direct / annonce du prochain live
├── countdown.php                   # Page de compte à rebours (maintenance)
├── sw.js                           # Service Worker (PWA)
├── site.webmanifest.sample         # Exemple de manifest PWA
├── browserconfig.xml               # Tuiles Windows
├── sitemap.xml.sample              # Exemple de sitemap
├── robots.txt.sample               # Exemple de robots.txt
├── mentions-legales.php.sample     # Exemple de mentions légales
├── dons.php.sample                 # Exemple de page de dons
├── LICENSE                         # GNU AGPL v3
├── DEPLOY.adoc                     # Guide de déploiement serveur + CI/CD
└── README.adoc
----

NOTE: Les fichiers marqués « legacy » sont conservés pour référence mais ne sont inclus par aucune page ; la page d'accueil rend ces sections en interne.

=== 📋 Prérequis

- 🐘 *PHP 7.4+* (recommandé : PHP 8.0+)
- 📦 *Extensions PHP requises :*
  * `curl` — appels API (PeerTube, Castopod, Funkwhale, WordPress)
  * `json` — traitement des réponses
  * `intl` — dates internationales et fuseaux horaires
  * `mbstring` — chaînes multi-octets
  * `xml` (SimpleXML) — recommandée pour Castopod (un fallback regex existe)
- 🌐 *Serveur web* : Nginx (recommandé) ou Apache
- 🔒 *HTTPS* : requis pour les fonctionnalités PWA

.Installation de l'extension `intl`
[source,bash]
----
# Ubuntu / Debian
sudo apt-get install php-intl

# Fedora / RHEL / CentOS
sudo dnf install php-intl

# Redémarrer le serveur web
sudo systemctl restart apache2        # Apache
sudo systemctl restart php8.3-fpm     # Nginx + php-fpm

# Vérifier
php -m | grep intl
----

=== 🚀 Installation

. 📥 Clonez le dépôt :
+
[source,bash]
----
git clone git@labola.o-k-i.net:cedric/annu-kute-ced.git
cd annu-kute-ced
----
. 📦 Vérifiez les prérequis PHP (voir ci-dessus).
. 🔧 Créez votre configuration locale et les fichiers d'instance :
+
[source,bash]
----
cp includes/config.local.php.sample includes/config.local.php
cp site.webmanifest.sample site.webmanifest
cp robots.txt.sample robots.txt
cp sitemap.xml.sample sitemap.xml
cp mentions-legales.php.sample mentions-legales.php
cp dons.php.sample dons.php
----
. 🛡️ Installez la configuration serveur sécurisée depuis `conf/` (voir <<fr-securite,Sécurité>>).
. ✍️ Rendez le dossier `cache/` accessible en écriture par le serveur web (créé automatiquement au premier appel API).
. 🔒 Assurez-vous que HTTPS est actif (obligatoire pour la PWA).
. 🌐 Faites pointer votre domaine (ex. `example.com`) vers la racine du projet.

[[fr-configuration]]
=== ⚙️ Configuration

==== Le système en trois fichiers

[cols="1,2,2",options="header"]
|===
| Fichier | Rôle | Versionné ?

| `includes/config.php`
| Bootstrap : charge les autres fichiers et contient toutes les fonctions métier
| ✅ Oui

| `includes/config.default.php`
| Valeurs par défaut de chaque constante (définies seulement si absentes)
| ✅ Oui

| `includes/config.local.php`
| Vos surcharges locales — *prioritaires* car chargées en premier
| ❌ Non (`.gitignore`)
|===

Pour personnaliser l'instance, ne modifiez jamais `config.default.php` : définissez la constante dans `config.local.php`. Comme ce dernier est chargé avant, vos valeurs ont toujours la priorité.

[source,bash]
----
cp includes/config.local.php.sample includes/config.local.php
----

==== Référence des constantes

Valeurs par défaut issues de `includes/config.default.php`.

.Application et organisation
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `APP_HOST_NAME`
| `'example.com'`
| Nom de domaine affiché (mentions légales…)

| `ORGANIZATION_SHORT_NAME`
| `'ANNU KUTE CED'`
| Nom court de l'organisation

| `ORGANIZATION_NAME`
| `'ANNU KUTE CED'`
| Nom complet de l'organisation
|===

.PeerTube
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `PEERTUBE_URL`
| `'https://gade.o-k-i.net'`
| URL de l'instance PeerTube (obligatoire)

| `PEERTUBE_DISPLAY_NAME`
| `'gade.o-k-i.net'`
| Nom affiché de l'instance

| `API_KEY`
| `''`
| Clé API optionnelle (`Authorization: ApiKey …`)

| `SHOW_VIDEO_VIEWS`
| `false`
| Afficher le nombre de vues sur les cartes vidéo

| `PRIORITY_CATEGORIES`
| `[10, 15]`
| Catégories mises en avant : `[ID => 'Nom']`, ordre = ordre d'affichage (accueil, sidebar, footer, menu mobile)

| `DEFAULT_TIMEZONE`
| `'America/Guadeloupe'`
| Fuseau horaire d'affichage (https://www.php.net/manual/fr/timezones.php[liste PHP^])
|===

.Section hero (bannière d'accueil)
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `HERO_TYPE`
| `'live'`
| `'live'` · `'video'` · `'playlist'` · `'none'`

| `LIVE_ACCOUNT_NAME`
| `'annu_kute_ced'`
| Compte PeerTube surveillé pour les directs (hero + page `/direct`)

| `HERO_VIDEO_ID`
| `'fDE6tvQMXRuBpE4eDDS7kj'`
| UUID de la vidéo (mode `video`), extrait de l'URL `/w/<uuid>`

| `HERO_VIDEO_TITLE`
| `'Vidéo de présentation'`
| Titre (accessibilité)

| `PLAYLIST_PLATFORM`
| `'peertube'`
| `'peertube'` · `'funkwhale'` · `'castopod'`

| `PLAYLIST_INSTANCE_URL`
| `''`
| URL de base de la plateforme de playlist

| `PLAYLIST_ID`
| `''`
| ID de la playlist ; pour Castopod : `'@slug'`

| `PLAYLIST_TITLE` / `PLAYLIST_DESCRIPTION`
| `''` / `''`
| Textes optionnels (sinon récupérés via API)
|===

.Castopod (podcast)
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `CASTOPOD_ENABLED`
| `true`
| Activer la section podcast de l'accueil

| `CASTOPOD_URL`
| `'https://kute.o-k-i.net'`
| URL de l'instance Castopod

| `CASTOPOD_PODCAST_SLUGS`
| `['annu_kute_cedric']`
| Slug(s) de podcast ; flux RSS = `{URL}/@{slug}/feed`. Plusieurs podcasts : épisodes fusionnés et triés par date

| `CASTOPOD_EPISODES_COUNT`
| `5`
| Nombre total d'épisodes affichés
|===

WARNING: Castopod applique un *rate limit* strict (HTTP 429) sur les flux RSS. Le code applique un backoff exponentiel (5 s, 10 s, 15 s) uniquement en cas de 429. *Activez le cache* (`CACHE_ENABLED`) et préchauffez-le avec `scripts/warm-cache.php` pour éviter ces délais à chaque page.

.Funkwhale (musique, optionnel)
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `FUNKWHALE_ENABLED`
| `false`
| Activer la section musique

| `FUNKWHALE_URL`
| `'https://mizik.o-k-i.net'`
| URL de l'instance Funkwhale

| `FUNKWHALE_TRACKS_COUNT`
| `10`
| Morceaux aléatoires locaux affichés (50 mis en cache)
|===

.Mastodon (timeline)
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `MASTODON_INSTANCE_URL`
| `'https://bokante.o-k-i.net'`
| Instance Mastodon (ou Pleroma, via l'adaptateur)

| `MASTODON_URL`
| `'https://bokante.o-k-i.net/@cedric'`
| URL publique du compte (liens « Voir plus », footer)

| `MASTODON_DATE_FORMAT`
| `'fr-FR'`
| Locale de date des posts

| `MASTODON_BTN_SEE_MORE` / `MASTODON_BTN_RELOAD`
| `'Voir plus de post'` / `'Rafraichir'`
| Libellés des boutons

| `MASTODON_MAX_POST_FETCH` / `MASTODON_MAX_POST_SHOW`
| `'10'` / `'10'`
| Posts récupérés / affichés

| `MASTODON_S3_MEDIA_URL`
| _(non défini)_
| Origine S3 des médias (ajoutée à la CSP `media-src`)
|===

NOTE: La timeline n'a pas de flag d'activation : elle est toujours affichée sur l'accueil.

.WordPress (optionnel)
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `WORDPRESS_ENABLED`
| `false`
| Activer la section articles

| `WORDPRESS_URL`
| `''`
| URL du site WordPress (sans `/` final)

| `WORDPRESS_POSTS_COUNT`
| `6`
| Nombre d'articles (API REST `/wp-json/wp/v2/`)
|===

.Dons
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `DONATIONS_ENABLED`
| `true`
| Activer le système (icône cœur, lien « Soutenir », page `/dons.php`)

| `LIBERAPAY_URL` / `KOFI_URL`
| `'https://liberapay.com/OKI/donate'` / `''`
| URLs des plateformes externes. Par défaut le lien Liberapay pointe vers OKI, mainteneur de l'application.

| `STRIPE_ENABLED`
| `false`
| Activer l'interface Stripe (onglets ponctuel / mensuel)

| `STRIPE_ONE_TIME_LINKS`
| `[1, 5, 10, 20, 50, 'custom'] => ''`
| Liens de paiement Stripe par montant

| `STRIPE_MONTHLY_LINKS`
| `[1, 5, 10, 20, 50] => ''`
| Liens d'abonnement mensuel Stripe

| `DONATION_AMOUNTS`
| `[1, 5, 10, 20, 50]`
| Montants suggérés

| `DONATION_CURRENCY`
| `'EUR'`
| Devise

| `DONATIONS_OKI_DISCLAIMER`
| Texte explicite
| Message affiché sur `/dons.php` lorsque les liens pointent vers les comptes d'une organisation tierce (OKI par défaut). Laissez vide pour masquer.
|===

NOTE: Au moins une plateforme (LiberaPay, Ko-fi ou Stripe) doit être configurée pour que `dons.php` s'affiche, sinon la page renvoie une erreur 500. Par défaut, seul le compte Liberapay de OKI est renseigné.

TIP: Si vous déployez une instance indépendante, remplacez `LIBERAPAY_URL` par votre propre lien et adaptez ou videz `DONATIONS_OKI_DISCLAIMER`.

.Compte à rebours (maintenance / lancement)
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `COUNTDOWN_ENABLED`
| `false`
| ⚠️ Si `true`, *toutes* les pages redirigent vers `countdown.php`

| `COUNTDOWN_TARGET_DATE`
| `'2025-10-11 00:00:00'`
| Date cible (`Y-m-d H:i:s`) ; à la fin, redirection vers `/`

| `COUNTDOWN_TIMEZONES`
| 5 territoires
| `[Libellé => fuseau PHP]` affichés sur la page
|===

.Annonce du prochain live
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `NEXT_LIVE_ENABLED`
| `false`
| Afficher l'annonce quand aucun direct n'est en cours

| `NEXT_LIVE_TITLE` / `NEXT_LIVE_DESCRIPTION`
| `'Prochain live'` / `'Rejoignez-nous…'`
| Textes (date et heure ajoutées automatiquement)

| `NEXT_LIVE_DATE`
| `''`
| Date du live (`Y-m-d H:i:s`)

| `NEXT_LIVE_IMAGE`
| `'uploads/next-live.jpg'`
| Image d'annonce (utilisée seulement si le fichier existe)
|===

.Site, réseaux sociaux et contact
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `SITE_NAME`
| `'ANNU KUTE CED'`
| Nom du site (titres, meta)

| `SITE_DESCRIPTION`
| `'Hub multimédia du podcast ANNU KUTE CED'`
| Description SEO

| `SITE_LOGO` / `SITE_FAVICON`
| `'img/logo.png'` / `'img/favicon.png'`
| Chemins des images

| `FACEBOOK_URL`, `X_URL`, `INSTAGRAM_URL`, `YOUTUBE_URL`, `TIKTOK_URL`
| `'#'`
| `'#'` ou vide = icône masquée

| `CONTACT_EMAIL`
| `'kontak@o-k-i.net'`
| E-mail de contact (footer)
|===

.Mentions légales
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `LEGAL_COPYRIGHT`
| `'ANNU KUTE CED'`
| Titulaire du copyright

| `LEGAL_WEBMASTER_NAME` / `LEGAL_WEBMASTER_EMAIL`
| `'Cédric Famibelle-Pronzola'` / `'contact@cedric-pronzola.dev'`
| Webmaster

| `LEGAL_HOST_NAME`, `LEGAL_HOST_COMPANY`, `LEGAL_HOST_RCS`, `LEGAL_HOST_ADDRESS`
| valeurs o2Switch
| Coordonnées de l'hébergeur

| `LEGAL_CONTACT_EMAIL`
| `'kontak@o-k-i.net'`
| Contact juridique

| `LEGAL_LICENSE` / `LEGAL_LICENSE_URL`
| AGPL-V3 + URL GNU
| Licence du code

| `LEGAL_SOURCE_CODE_URL`
| `'https://labola.o-k-i.net/cedric/annu-kute-ced'`
| Dépôt du code source (exigé par l'AGPL pour un service réseau)

| `LEGAL_SERVICE_DESCRIPTION`
| `'est le hub multimédia du podcast ANNU KUTE CED.'`
| Phrase de description du service
|===

.Tags et filtres
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `SHORTS_MAX_DURATION`
| `180`
| Durée max d'un short (s) ; ratio portrait (`aspectRatio ≤ 1`) aussi requis

| `IMPORTANT_TAGS`
| `['ANNUKUTECED']`
| Hashtags de la sidebar, du footer et du menu mobile

| `POPULAR_TAGS`
| `['ANNUKUTECED']`
| Hashtags affichés sur l'accueil
|===

.Pagination et volumes d'affichage
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `VIDEOS_PER_PAGE`
| `12`
| Vidéos par page (recherche)

| `COUNT_VIDEO_SEARCH`
| `20`
| Résultats récupérés par recherche

| `RECENT_VIDEOS_COUNT` / `TRENDING_VIDEOS_COUNT` / `CATEGORY_VIDEOS_COUNT`
| `6`
| Taille des sections d'accueil

| `SHORTS_COUNT` / `SHORTS_COUNT_SEARCH`
| `6` / `100`
| Shorts affichés / pool de recherche

| `LOAD_MORE_COUNT`
| `6`
| Vidéos chargées par clic sur « Voir plus »

| `FEATURED_VIDEOS_COUNT`
| `6`
| Réservé (section non affichée actuellement)
|===

.Cache
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `CACHE_ENABLED`
| `true`
| Active le cache pour *Castopod et Funkwhale*. Indispensable contre le rate limit RSS de Castopod ; les résultats vides (erreur, 429) ne sont jamais mis en cache

| `CACHE_DURATION`
| `3600`
| Durée de vie du cache (s)
|===

NOTE: Le cache de l'API PeerTube et de WordPress (`includes/simple-cache.php`) est *toujours actif*, indépendamment de `CACHE_ENABLED`, avec des TTL par endpoint (catégories 1 h, vidéos/recherche 10 min, articles WP 15 min, comptes 5 min). Les fichiers sont stockés dans `cache/api/`.

TIP: Pour éviter que le premier visiteur ne paye le coût des appels API sur cache froid, préchauffez le cache via cron : `php /var/www/annu-kute-ced/scripts/warm-cache.php` (toutes les 5 minutes par exemple).

.Sécurité
[cols="2,2,3",options="header"]
|===
| Constante | Défaut | Description

| `CSRF_SECRET`
| `'change-me-in-config-local-php'`
| Clé secrète utilisée pour signer les tokens CSRF stateless. À remplacer impérativement dans `config.local.php` par une valeur aléatoire (`php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"`)
|===

.Bloc « À propos » (pas de valeur par défaut — bloc masqué tant que non défini)
[cols="2,3",options="header"]
|===
| Constante | Description

| `MOVEMENT_TITLE`
| Titre du bloc (défaut template : « À propos »)

| `MOVEMENT_DESCRIPTION`
| Premier paragraphe ; *commenter cette ligne masque tout le bloc*

| `MOVEMENT_DESCRIPTION_2`
| Second paragraphe (optionnel)

| `MOVEMENT_IMAGE` / `MOVEMENT_IMAGE_ALT` / `MOVEMENT_CAPTION`
| Image (`img/movement_presentation.png`, non versionnée), texte alternatif et légende (HTML simple accepté)
|===

==== Exemple complet de `config.local.php`

[source,php]
----
<?php
define('APP_HOST_NAME', 'example.com');

// PeerTube (chaîne GADE du podcast)
define('PEERTUBE_URL', 'https://gade.o-k-i.net');
define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');

// Hero : direct de la chaîne, sinon annonce du prochain live
define('HERO_TYPE', 'live');
define('LIVE_ACCOUNT_NAME', 'annu_kute_ced');
define('NEXT_LIVE_ENABLED', true);
define('NEXT_LIVE_TITLE', 'Prochain live');
define('NEXT_LIVE_DESCRIPTION', 'Enregistrement du prochain épisode !');
define('NEXT_LIVE_DATE', '2025-10-11 10:00:00');

// Castopod (compte KUTE du podcast)
define('CASTOPOD_ENABLED', true);
define('CASTOPOD_URL', 'https://kute.o-k-i.net');
define('CASTOPOD_PODCAST_SLUGS', ['annu_kute_cedric']);
define('CASTOPOD_EPISODES_COUNT', 5);

// Mastodon (compte BOKANTE)
define('MASTODON_INSTANCE_URL', 'https://bokante.o-k-i.net');
define('MASTODON_URL', 'https://bokante.o-k-i.net/@cedric');

// Cache : indispensable pour Castopod (rate limit RSS)
define('CACHE_ENABLED', true);
define('CACHE_DURATION', 3600);
----

=== 🎨 Personnalisation de l'apparence

. 🖼️ Remplacez `img/logo.png` par votre logo (et les favicons du dossier `img/`)
. 🎨 Modifiez les couleurs dans `css/styles.css`
. 📝 Textes : la plupart passent par les constantes de `config.local.php` (bloc « À propos », libellés Mastodon, etc.)

==== Fichiers d'instance à personnaliser

Les fichiers `sitemap.xml`, `robots.txt`, `site.webmanifest`, `mentions-legales.php` et `dons.php` sont ignorés par Git : copiez les `.sample` puis adaptez-les. Les exemples fournis utilisent le domaine générique `example.com` : remplacez-le par votre domaine réel. Dans `mentions-legales.php`, remplacez aussi le placeholder `VOTRE-DATE-MAJ`.

=== 🧭 Ordre des sections sur la page d'accueil

La page d'accueil assemble les sections dans cet ordre, chacune conditionnée par sa configuration :

. *Hero* — si `HERO_TYPE` ≠ `'none'` (live, vidéo ou playlist)
. *Podcasts Castopod* — si `CASTOPOD_ENABLED` et `CASTOPOD_URL` non vide
. *Morceaux Funkwhale* — si `FUNKWHALE_ENABLED` et `FUNKWHALE_URL` non vide
. *Timeline Mastodon* — toujours affichée
. *Articles WordPress* — si `WORDPRESS_ENABLED` et `WORDPRESS_URL` non vide
. *Hashtags populaires* (`POPULAR_TAGS`)
. *Shorts* — carrousel
. *Dernières vidéos* + bouton « Voir plus »
. *Tendances* + « Voir plus »
. *Une section par catégorie prioritaire* (`PRIORITY_CATEGORIES`, seulement si elle contient des vidéos) + « Voir plus »
. *Bloc « À propos »* (si `MOVEMENT_DESCRIPTION` défini) + aside hashtags

Mise en page desktop : hero/Castopod/Funkwhale + Mastodon + WordPress s'organisent en colonnes (1fr 2fr, ou 1fr 2fr 1fr avec WordPress) ; empilés verticalement sur mobile.

[[fr-securite]]
=== 🛡️ Sécurité

==== Ce que l'application applique déjà

- *CSP dynamique* : `default-src 'self'` avec *nonce* unique par requête pour les scripts/styles inline ; origines PeerTube/Mastodon/CDN ajoutées automatiquement ; assouplissements `localhost` pour le développement
- *En-têtes* : `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `X-XSS-Protection`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` restrictive, HSTS (HTTPS uniquement), CORP/COOP `same-origin`
+
NOTE: L'en-tête COEP a été *volontairement retiré* (commit `62d4d99`) : `require-corp` bloquait les embeds cross-origin de PeerTube, Mastodon, Castopod et Funkwhale.
- *CSRF* : jeton stateless HMAC-SHA256 (timestamp + signature, validité 1 h) injecté en `<meta>`, vérifié par `hash_equals` sur l'endpoint AJAX ; vérification de l'en-tête `Origin` ou du `Referer`
- *Anti-SSRF* : validation des URLs d'instances (schéma http/https, blocage IP privées et `localhost`), liste blanche des endpoints de l'API PeerTube, cURL sans redirections
- *Anti-XSS* : échappement systématique des sorties ; validation des entrées (UUID vidéo, requête de recherche ≤ 200 car., numéros de page, ID de catégorie 1–20)

==== Configuration serveur (Nginx, recommandé)

Le fichier `conf/nginx.conf.sample` est l'équivalent Nginx complet du `.htaccess` : mêmes protections (fichiers de configuration et `.sample`, dossiers `/includes/`, `/cache/`, `/docs/`, `/conf/`, fichiers cachés, pas de listing), masquage de l'extension `.php` (URLs propres `/video` au lieu de `/video.php`), `no-cache` pour `sw.js` et `site.webmanifest`, plus le cache des assets et gzip. Le bloc `:80` sert le site immédiatement (aucun certificat requis) ; le bloc `443` et la redirection HTTPS sont générés par `certbot --nginx` (voir link:DEPLOY.adoc[DEPLOY.adoc]).

[source,bash]
----
# Adaptez les chemins dans conf/nginx.conf.sample puis :
sudo cp conf/nginx.conf.sample /etc/nginx/sites-available/example.com
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
----

==== Configuration serveur (Apache, alternative)

[source,bash]
----
cp conf/.htaccess.sample .htaccess
----

Protections incluses : blocage des fichiers de configuration et `.sample`, des dossiers `/includes/`, `/cache/`, `/docs/`, `/conf/` ; pas de listing de répertoires ; fichiers cachés bloqués ; HTTPS forcé ; masquage de l'extension `.php`.

WARNING: Cette configuration est *essentielle* : sans elle, vos fichiers de configuration sont exposés aux visiteurs.

=== 📱 Progressive Web App (PWA)

==== Fonctionnalités

- 📲 *Installation native* : bouton « Installer » dans le header à la première visite
- 🌐 *Mode hors ligne* : cache des pages visitées et des ressources statiques
- 📡 *Détection de connexion* : indicateur visuel en cas de perte réseau
- 🔄 *Mise à jour sans friction* : quand une nouvelle version est déployée, un modal stylé la propose ; en acceptant, l'utilisateur la reçoit immédiatement — *aucune purge manuelle du cache navigateur nécessaire*

==== Stratégies de cache du Service Worker (`sw.js`)

[cols="1,2,3",options="header"]
|===
| Type de requête | Stratégie | Détail

| Ressources statiques (`/css/`, `/js/`, `/img/`, CDN cdnjs)
| *Cache First*
| Mise en cache à l'installation et au premier accès ; repli sur `img/logo.png` si une image échoue

| Pages (`/`, `*.php`…)
| *Network First*
| Réseau d'abord, cache en secours hors ligne

| API et AJAX (`/ajax/`, `mastodon-config.php`…)
| *Network Only*
| Jamais de cache ; réponse JSON 503 synthétique hors ligne
|===

==== Cycle de mise à jour automatique

Le mécanisme (`js/pwa-update.js` + `sw.js`) garantit que les visiteurs reçoivent les nouvelles versions *sans purger manuellement le cache de leur navigateur* :

. Au déploiement, *bumpez* le suffixe de version des noms de cache dans `sw.js` (format `JJMMAAAA-HHMM`, ex. `24072026-0612`)
. Au chargement d'une page, le navigateur détecte le nouveau `sw.js` et installe le nouveau Service Worker en arrière-plan ; il reste *en attente* (pas de `skipWaiting()` automatique)
. Un *modal* propose la mise à jour à l'utilisateur :
  * *« Mettre à jour »* → le SW en attente reçoit `SKIP_WAITING`, purge les anciens caches à l'activation, prend le contrôle (`controllerchange`) et la page se recharge sur la nouvelle version
  * *« Plus tard »* → le modal se ferme ; la mise à jour est reproposée au prochain chargement
. Une mise à jour déjà installée lors d'une visite précédente mais jamais activée est également signalée

WARNING: Pour que la détection fonctionne, `sw.js` ne doit *pas* être caché longtemps côté serveur : les configurations fournies (`conf/nginx.conf.sample`, `conf/.htaccess.sample`) le servent en `no-cache` (ainsi que `site.webmanifest`). Si vous utilisez votre propre configuration serveur, reproduisez cette règle.

==== Installation par les visiteurs

- 🌐 *Chrome/Edge* : menu → « Installer example.com »
- 🍎 *Safari iOS* : Partager → « Ajouter à l'écran d'accueil »
- 🦊 *Firefox Android* : menu → « Installer »

[[fr-analytics]]
=== 📊 Analytics (Plausible)

Le site est *pré-configuré* pour https://plausible.io[Plausible Analytics^] (sans cookies, conforme RGPD, données anonymes), mais le script est *désactivé par défaut*.

Pour l'activer :

. Ouvrez `index.php` et *décommentez* le snippet Plausible (balise `<script defer data-domain=… src="https://plausible.io/js/script…js">` et son shim `window.plausible`).
. C'est tout : la CSP autorise déjà `https://plausible.io` en `script-src` et `connect-src`, et le `data-domain` utilise automatiquement le domaine courant.

Données alors collectées (anonymes) : pages visitées, pays d'origine (IP non stockée), type d'appareil, navigateur, temps passé.

=== 🚀 Déploiement sur serveur mutualisé

. 🐘 Vérifiez que l'hébergeur fournit PHP 7.4+ et les extensions requises
. 🔒 Activez HTTPS (obligatoire pour la PWA)
. 📤 Transférez les fichiers via FTP à la racine du site
. 🔧 Permissions : `644` pour les fichiers, `755` pour les dossiers ; `cache/` accessible en écriture
. 📄 Créez les fichiers d'instance depuis les `.sample` (config, manifest, robots, sitemap, mentions légales, dons)
. 🛡️ Copiez `conf/.htaccess.sample` en `.htaccess` (Apache)
. 🌐 Faites pointer votre domaine (ex. `example.com`) vers le dossier d'installation
. 🧪 Testez la PWA via les outils de développement du navigateur (onglet Application)

=== 👨‍💻 Développement et contribution

. 🌿 Créez une branche : `git checkout -b ma-fonctionnalite`
. 💾 Committez avec des *messages atomiques en anglais*, au format conventionnel (< 70 caractères) :
  * `feat: add …`, `fix: prevent …`, `docs: update …`, `refactor: …`, `typo: …`
. 📤 Poussez : `git push origin ma-fonctionnalite`
. 🔀 Ouvrez une pull request

Bonnes pratiques du dépôt :

- Les remontées de bugs et propositions sont les bienvenues sur le dépôt du fork ; les améliorations génériques peuvent être proposées en upstream (FEDIVERSE OKI)
- Ne versionnez jamais `config.local.php` ni les fichiers dérivés des `.sample`
- Quelques constantes sont définies mais non utilisées par le code actuel (`ENABLE_SEARCH`, `ENABLE_COMMENTS`, `ENABLE_USER_ACCOUNTS`, `TAG_SHORT`) : ne vous en servez pas comme points d'extension

==== 📄 Exporter la documentation (HTML / PDF)

Ce README peut être exporté en HTML et en PDF en une seule commande (tout fichier `.adoc` peut être passé en argument) :

[source,bash]
----
docs/generate-readme-pdf.sh                    # README.html + README.pdf
docs/generate-readme-pdf.sh --html             # HTML seulement
docs/generate-readme-pdf.sh --pdf              # PDF seulement
docs/generate-readme-pdf.sh DEPLOY.adoc        # DEPLOY.html + DEPLOY.pdf
docs/generate-readme-pdf.sh DEPLOY.adoc --pdf  # DEPLOY.pdf seulement
----

Les fichiers sont produits à la racine du projet (nommés d'après la source) et ignorés par Git (`.gitignore`).

Prérequis : `asciidoctor`, `chromium` (rendu headless) et le paquet `fonts-noto-color-emoji` (émojis couleur dans le PDF). La feuille de style `docs/docinfo.html` (fallback emoji + règles d'impression) est injectée automatiquement via le mécanisme `docinfo` d'Asciidoctor.

NOTE: `asciidoctor-pdf` (utilisé par l'extension AsciiDoc de VSCodium) n'est pas utilisable ici : son moteur Prawn ne sait pas embarquer les polices emoji couleur (CBDT/COLR), les émojis disparaissent du PDF. Chromium les gère nativement.

==== ✅ Vérifications locales et CI/CD

Avant de pousser, exécutez les vérifications locales complètes :

[source,bash]
----
scripts/check.sh
----

Ce script lance : lint PHP (`php -l` sur tous les fichiers, samples inclus), lint JS (`node --check`), rendu AsciiDoc, validation JSON (`site.webmanifest.sample`) et XML (`sitemap.xml.sample`, `browserconfig.xml`), shellcheck. Un outil manquant est signalé et le check correspondant ignoré.

Pipelines Gitea Actions (`.gitea/workflows/`) :

- *Vérification PR* (`check-pr.yml`) : lint PHP et JS, tests unitaires PHP et JS bloquants sur toute pull request vers `main`
- *Déploiement PROD* (`deploy-prod.yml`) : lint PHP et JS, tests unitaires PHP et JS sur push sur `main`, puis déploiement en SSH sur le serveur (`git pull --ff-only` + bump de version du Service Worker, qui déclenche le modal de mise à jour chez les visiteurs)

Les validations AsciiDoc, JSON, XML et shellcheck ne sont pas exécutées dans le CI : elles doivent être passées en local avec `scripts/check.sh`.

La mise en place complète du serveur de production (clone, fichiers d'instance, clés SSH, secrets Gitea) est documentée dans link:DEPLOY.adoc[DEPLOY.adoc].

==== 🧪 Tests

Le projet dispose de trois niveaux de tests, tous *sans framework lourd* (aucune dépendance commitée dans le repo) :

- *Tests unitaires PHP* (`tests/php/`) : script PHP natif (`php tests/php/run.php`). Couvre les validateurs (`security.php`), le cache (`simple-cache.php`), les formateurs (`config.php`) et le rendu Markdown.
- *Tests unitaires JS* (`tests/js/`) : `node:test` natif (`node tests/js/run.js`). Couvre `CountdownTimer` et l'adaptateur Pleroma → Mastodon.
- *Tests E2E* (`tests/e2e/`) : Playwright (Python), installé en local uniquement. Couvre la homepage, la page vidéo et l'endpoint AJAX « Voir plus ».

[source,bash]
----
# Tests unitaires PHP et JS (aussi lancés par scripts/check.sh)
php tests/php/run.php
node tests/js/run.js

# Tests E2E (nécessite Playwright installé hors repo)
python3 -m venv /tmp/test-venv
/tmp/test-venv/bin/pip install playwright pytest
/tmp/test-venv/bin/playwright install chromium
/tmp/test-venv/bin/python -m pytest tests/e2e/
----

NOTE: Les tests E2E démarrent un serveur PHP local (`php -S`) et s'appuient sur la configuration par défaut. Les appels réseau vers PeerTube sont sautés proprement si l'instance est injoignable.

==== ⏱️ Benchmark de performance

Le script `scripts/benchmark.php` mesure le TTFB (_time to first byte_) des pages clés — accueil (`/`), page vidéo (`/video.php?id=…`) et page catégorie (`/categories.php?id=…`) — dans deux conditions :

- *à froid* : cache API vidé (le premier visiteur paie le coût des appels API externes) ;
- *à chaud* : cache API rempli (moyenne sur 3 requêtes).

Il démarre un serveur PHP local (`php -S`), découvre automatiquement une vidéo et une catégorie réelles via l'API PeerTube (les pages correspondantes sont ignorées si l'instance est injoignable) et affiche la taille du cache API avant et après les mesures.

[source,bash]
----
php scripts/benchmark.php
----

NOTE: Le script vide le cache API (`cache/api`) : à utiliser hors production, ou suivi d'un préchauffage (`scripts/warm-cache.php`).

=== 📜 Licence

Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*

Ce programme est un logiciel libre : vous pouvez le redistribuer et/ou le modifier selon les termes de la licence publique générale GNU Affero publiée par la Free Software Foundation, soit la version 3 de la licence, soit (à votre choix) toute version ultérieure.

Ce programme est distribué dans l'espoir qu'il sera utile, mais SANS AUCUNE GARANTIE ; sans même la garantie implicite de COMMERCIALISATION ou d'ADAPTATION À UN USAGE PARTICULIER. Voir la https://www.gnu.org/licenses/agpl-3.0.html[licence publique générale GNU Affero^] pour plus de détails (texte intégral dans `LICENSE`).

=== 📞 Contact

Pour toute question ou suggestion concernant cette plateforme : mailto:kontak@o-k-i.net[kontak@o-k-i.net]

<<top,⬆ Retour au sommaire>>

'''

[[en]]
== 🇬🇧 English version

=== 📖 Description

*ANNU KUTE CED* is a responsive web interface acting as a *multimedia hub* for the podcast of the same name. It brings together in a single place:

- 🎥 the podcast's *PeerTube* channel (videos, shorts, live streams);
- 🎙️ its *Castopod* account (latest episodes, built-in audio playback);
- 📡 its *Mastodon* timeline (news and announcements).

The platform is lightweight, deployable on shared hosting (PHP + web server, no database), and optimized for both mobile and desktop. It is also an installable *Progressive Web App* with offline support.

🎯 *Mission*: provide a single, free and decentralized entry point to discover, listen to and follow the ANNU KUTE CED podcast without relying on Big Tech platforms.

=== 🌳 Project origin

This application is a *fork* of https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI[*FEDIVERSE OKI*^], developed by the https://o-k-i.net[ORGANISATION KA INTERNATIONALE (OKI)^], itself derived from the https://codeberg.org/Ka-Ubuntu/kaubuntu.re[kaubuntu.re^] project by the https://kaubuntu.com[Ka-Ubuntu^] movement. The original license (GNU AGPL v3) is preserved and respected.

[cols="1,3",options="header"]
|===
| Item | Detail

| Upstream repository
| https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI

| This fork's repository
| `git@labola.o-k-i.net:cedric/annu-kute-ced.git`

| Domain (example)
| https://example.com

| Governance
| Application maintained by OKI; fork carried by the repository owner *cedric* (Cédric Famibelle-Pronzola)

| License
| https://www.gnu.org/licenses/agpl-3.0.html[GNU Affero General Public License v3^] (AGPL-V3) or later
|===

=== 🔗 Aggregated sources and instances

The hub is configured by default to aggregate the ANNU KUTE CED podcast's sources:

[cols="1,2,3",options="header"]
|===
| Service | Instance | Account / channel

| 🎥 PeerTube (videos, lives)
| GADE — `https://gade.o-k-i.net`
| https://gade.o-k-i.net/c/annu_kute_ced/videos[`annu_kute_ced`^]

| 🎙️ Castopod (audio episodes)
| KUTE — `https://kute.o-k-i.net`
| https://kute.o-k-i.net/@annu_kute_cedric[`@annu_kute_cedric`^]

| 📡 Mastodon (timeline)
| BOKANTE — `https://bokante.o-k-i.net`
| https://bokante.o-k-i.net/@cedric[`@cedric`^]

| 🎵 Funkwhale (music, optional, disabled by default)
| MIZIK — `https://mizik.o-k-i.net`
| random tracks from the instance
|===

All of these sources can be changed in `includes/config.local.php` (see <<en-configuration,Configuration>>).

=== ✨ Features

==== 🎥 PeerTube videos

- Recent, trending and per-category videos (configurable)
- *Shorts*: dedicated carousel for portrait videos under 3 minutes (touch scrolling)
- Full video page: embedded PeerTube player, Markdown description, Creative Commons licence badge, comments (read-only), suggested videos
- *Downloads*: direct files and HLS playlists, with resolution and size
- *Sharing*: copy link, embed code, e-mail, Facebook, X, WhatsApp, LinkedIn, Telegram
- Full-text search and hashtag search (`#` prefix)
- AJAX "Load more" pagination (protected by CSRF token and Origin check)

==== 📺 Live streams and announcements

- *Live page* (`/direct`): embeds the PeerTube live stream when one is running, with autoplay
- *Next live announcement*: date and time automatically converted for 5 territories (Ma'ohi Nui, Martinique/Guadeloupe, French Guiana, France, Kanaky), custom image (4:5, 1:1, 16:9)
- Configurable homepage *hero section*: live, single video, playlist (PeerTube / Funkwhale / Castopod) or hidden

==== 🎙️ Podcast and audio

- *Castopod*: latest episodes via RSS feed, with a *built-in audio player* (play/pause, auto-advance to the next episode, one stream at a time)
- *Funkwhale* (optional): random track selection from the instance, using the same built-in player

==== 📡 Social networks and external content

- Embedded *Mastodon timeline* (vendored https://gitlab.com/idotj/mastodon-embed-timeline[mastodon-embed-timeline^] v4.7.0 library, AGPLv3)
- *Pleroma compatibility*: adapter converting Pleroma API responses to the Mastodon format expected by the timeline
- *WordPress* (optional): latest posts via the REST API, with featured image and author

==== 📱 PWA and user experience

- Installable *Progressive Web App* (automatic install button)
- Offline mode: visited pages and static assets cached via Service Worker
- Visual indicator when the connection is lost
- *Dark mode* (based on `prefers-color-scheme`, persisted in `localStorage`)
- Fully responsive interface (mobile, tablet, desktop)

==== 💝 Donations, countdown and more

- *Donation page*: LiberaPay, Ko-fi and Stripe (one-time and monthly donations, suggested amounts)
- *Countdown*: multi-timezone launch/maintenance page that locks the whole site until the target date
- Configurable "About" block (title, two paragraphs, captioned image)

==== 🔍 SEO and structured data

- Automatically generated *JSON-LD*: `WebSite` (with `SearchAction`), `PodcastSeries` and `PodcastEpisode` (with `AudioObject`) for the Castopod podcast, `VideoObject` (with `embedUrl`), `CollectionPage`, `BreadcrumbList`, `Organization`
- *Meta descriptions* on every page and *canonical URLs* (`link rel="canonical"`); search result pages set to `noindex, follow`
- *Open Graph* and *Twitter Cards* meta tags
- `sitemap.xml` and `robots.txt` provided as samples
- Ready for *Plausible Analytics* (see <<en-analytics,Analytics>>): disabled by default, cookie-free and GDPR-compliant

==== 🛡️ Security

- Full security headers: dynamic CSP with a per-request *nonce*, HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`
- *CSRF* protection on AJAX requests, `Origin` header verification
- Anti-*SSRF* validation of instance URLs and an allowlist of PeerTube API endpoints
- Systematic output escaping (anti-XSS)
- Hardened Apache and Nginx configuration samples in `conf/`

=== 🛠️ Tech stack

- 📄 HTML5, 🎨 CSS3 (Media Queries), ⚡ vanilla JavaScript (no framework, no jQuery)
- 🐘 PHP 7.4+ (no database required)
- 🔧 Service Worker (offline cache) and 📋 Web App Manifest (installation)
- 📦 Libraries:
  * 🎯 Font Awesome 7 (icons, via cdnjs CDN)
  * 📡 mastodon-embed-timeline v4.7.0 (vendored in `js/`, AGPLv3)
  * 📊 Plausible Analytics (pre-configured, to be enabled manually)

=== 📁 Project structure

[source]
----
├── .gitea/
│   └── workflows/                  # Gitea Actions CI/CD (check-pr, deploy-prod)
├── ajax/
│   └── load-more-videos.php        # "Load more" AJAX endpoint (CSRF + Origin)
├── conf/
│   ├── .htaccess.sample            # Hardened Apache configuration
│   └── nginx.conf.sample           # Hardened Nginx configuration
├── css/                            # Stylesheets (styles, video, categories,
│   │                               #   search, donations, countdown, mastodon,
│   ├── castopod-podcasts.css       #   castopod, funkwhale, wordpress, grid…)
│   └── …
├── docs/
│   ├── wireframes/                 # Original mockups (desktop + mobile PNGs)
│   ├── docinfo.html                # Print styles (emoji fallback) for the export
│   └── generate-readme-pdf.sh      # Docs export: README.html / README.pdf
├── img/                            # Logo, favicons, play icon
├── includes/
│   ├── config.php                  # Bootstrap + API functions (PeerTube, Castopod,
│   │                               #   Funkwhale) and formatting helpers
│   ├── config.default.php          # Default values for every constant
│   ├── config.local.php.sample     # Local configuration template (to copy)
│   ├── security.php                # CSRF, CSP + nonce, headers, validators
│   ├── simple-cache.php            # File-based API cache (cache/api/)
│   ├── wordpress.php               # WordPress REST API integration
│   ├── structured-data.php         # JSON-LD generators
│   ├── header.php                  # Top bar (search, socials, theme…)
│   ├── footer.php                  # Footer
│   ├── sidebar.php                 # Side navigation
│   ├── mobile-menu.php             # Slide-in mobile menu
│   ├── hero-section.php            # Homepage banner (live/video/playlist)
│   └── lib/
│       └── markdown.php            # Markdown renderer (PeerTube-flavored subset)
├── js/
│   ├── main.js                     # Global UI (theme, carousel, "Load more"…)
│   ├── audio-player.js             # Castopod / Funkwhale audio player
│   ├── countdown.js                # Countdown timer
│   ├── search.js                   # Clickable search result cards
│   ├── mastodon-config.php         # Timeline JS config (served as JavaScript)
│   ├── mastodon-timeline.umd.js    # mastodon-embed-timeline v4.7.0 (vendored)
│   ├── pleroma-adapter.js          # Pleroma → Mastodon API adapter
│   └── pwa-update.js               # SW registration + update modal
├── scripts/
│   ├── benchmark.php               # Cold/warm TTFB benchmark of key pages
│   ├── check.sh                    # Local quality checks (before pushing)
│   └── warm-cache.php              # API cache warm-up (cron)
├── uploads/                        # Announcement images (not versioned, except .gitkeep)
├── index.php                       # Homepage (aggregator)
├── video.php                       # Video playback page
├── categories.php                  # Category page
├── recherche.php                   # Search page
├── direct.php                      # Live stream / next-live announcement page
├── countdown.php                   # Countdown page (maintenance)
├── sw.js                           # Service Worker (PWA)
├── site.webmanifest.sample         # PWA manifest sample
├── browserconfig.xml               # Windows tiles
├── sitemap.xml.sample              # Sitemap sample
├── robots.txt.sample               # robots.txt sample
├── mentions-legales.php.sample     # Legal notice sample
├── dons.php.sample                 # Donation page sample
├── LICENSE                         # GNU AGPL v3
├── DEPLOY.adoc                     # Server deployment + CI/CD guide
└── README.adoc
----

NOTE: Files marked "legacy" are kept for reference but are not included by any page; the homepage renders those sections internally.

=== 📋 Requirements

- 🐘 *PHP 7.4+* (recommended: PHP 8.0+)
- 📦 *Required PHP extensions:*
  * `curl` — API calls (PeerTube, Castopod, Funkwhale, WordPress)
  * `json` — response handling
  * `intl` — international dates and timezones
  * `mbstring` — multi-byte strings
  * `xml` (SimpleXML) — recommended for Castopod (a regex fallback exists)
- 🌐 *Web server*: Nginx (recommended) or Apache
- 🔒 *HTTPS*: required for PWA features

.Installing the `intl` extension
[source,bash]
----
# Ubuntu / Debian
sudo apt-get install php-intl

# Fedora / RHEL / CentOS
sudo dnf install php-intl

# Restart the web server
sudo systemctl restart apache2        # Apache
sudo systemctl restart php8.3-fpm     # Nginx + php-fpm

# Check
php -m | grep intl
----

=== 🚀 Installation

. 📥 Clone the repository:
+
[source,bash]
----
git clone git@labola.o-k-i.net:cedric/annu-kute-ced.git
cd annu-kute-ced
----
. 📦 Check the PHP requirements (see above).
. 🔧 Create your local configuration and instance files:
+
[source,bash]
----
cp includes/config.local.php.sample includes/config.local.php
cp site.webmanifest.sample site.webmanifest
cp robots.txt.sample robots.txt
cp sitemap.xml.sample sitemap.xml
cp mentions-legales.php.sample mentions-legales.php
cp dons.php.sample dons.php
----
. 🛡️ Install the hardened server configuration from `conf/` (see <<en-securite,Security>>).
. ✍️ Make the `cache/` directory writable by the web server (created automatically on the first API call).
. 🔒 Make sure HTTPS is enabled (mandatory for the PWA).
. 🌐 Point your domain (e.g. `example.com`) to the project root.

[[en-configuration]]
=== ⚙️ Configuration

==== The three-file system

[cols="1,2,2",options="header"]
|===
| File | Role | Versioned?

| `includes/config.php`
| Bootstrap: loads the other files and holds all business functions
| ✅ Yes

| `includes/config.default.php`
| Default values for every constant (defined only if missing)
| ✅ Yes

| `includes/config.local.php`
| Your local overrides — *take precedence* as they load first
| ❌ No (`.gitignore`)
|===

To customize the instance, never edit `config.default.php`: define the constant in `config.local.php`. Since it loads first, your values always win.

[source,bash]
----
cp includes/config.local.php.sample includes/config.local.php
----

==== Constants reference

Default values from `includes/config.default.php`.

.Application and organization
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `APP_HOST_NAME`
| `'example.com'`
| Displayed domain name (legal notice…)

| `ORGANIZATION_SHORT_NAME`
| `'ANNU KUTE CED'`
| Short organization name

| `ORGANIZATION_NAME`
| `'ANNU KUTE CED'`
| Full organization name
|===

.PeerTube
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `PEERTUBE_URL`
| `'https://gade.o-k-i.net'`
| PeerTube instance URL (required)

| `PEERTUBE_DISPLAY_NAME`
| `'gade.o-k-i.net'`
| Displayed instance name

| `API_KEY`
| `''`
| Optional API key (`Authorization: ApiKey …`)

| `SHOW_VIDEO_VIEWS`
| `false`
| Show view counts on video cards

| `PRIORITY_CATEGORIES`
| `[10, 15]`
| Featured categories: `[ID => 'Name']`, order = display order (homepage, sidebar, footer, mobile menu)

| `DEFAULT_TIMEZONE`
| `'America/Guadeloupe'`
| Display timezone (https://www.php.net/manual/en/timezones.php[PHP list^])
|===

.Hero section (homepage banner)
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `HERO_TYPE`
| `'live'`
| `'live'` · `'video'` · `'playlist'` · `'none'`

| `LIVE_ACCOUNT_NAME`
| `'annu_kute_ced'`
| PeerTube account watched for live streams (hero + `/direct` page)

| `HERO_VIDEO_ID`
| `'fDE6tvQMXRuBpE4eDDS7kj'`
| Video UUID (`video` mode), extracted from the `/w/<uuid>` URL

| `HERO_VIDEO_TITLE`
| `'Vidéo de présentation'`
| Title (accessibility)

| `PLAYLIST_PLATFORM`
| `'peertube'`
| `'peertube'` · `'funkwhale'` · `'castopod'`

| `PLAYLIST_INSTANCE_URL`
| `''`
| Base URL of the playlist platform

| `PLAYLIST_ID`
| `''`
| Playlist ID; for Castopod: `'@slug'`

| `PLAYLIST_TITLE` / `PLAYLIST_DESCRIPTION`
| `''` / `''`
| Optional texts (otherwise fetched via API)
|===

.Castopod (podcast)
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `CASTOPOD_ENABLED`
| `true`
| Enable the homepage podcast section

| `CASTOPOD_URL`
| `'https://kute.o-k-i.net'`
| Castopod instance URL

| `CASTOPOD_PODCAST_SLUGS`
| `['annu_kute_cedric']`
| Podcast slug(s); RSS feed = `{URL}/@{slug}/feed`. Multiple podcasts: episodes merged and date-sorted

| `CASTOPOD_EPISODES_COUNT`
| `5`
| Total number of episodes displayed
|===

WARNING: Castopod enforces a strict *rate limit* (HTTP 429) on RSS feeds. The code applies exponential backoff (5 s, 10 s, 15 s) only on HTTP 429. *Enable the cache* (`CACHE_ENABLED`) and warm it with `scripts/warm-cache.php` to avoid these delays on every page.

.Funkwhale (music, optional)
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `FUNKWHALE_ENABLED`
| `false`
| Enable the music section

| `FUNKWHALE_URL`
| `'https://mizik.o-k-i.net'`
| Funkwhale instance URL

| `FUNKWHALE_TRACKS_COUNT`
| `10`
| Random local tracks displayed (50 cached)
|===

.Mastodon (timeline)
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `MASTODON_INSTANCE_URL`
| `'https://bokante.o-k-i.net'`
| Mastodon instance (or Pleroma, via the adapter)

| `MASTODON_URL`
| `'https://bokante.o-k-i.net/@cedric'`
| Public account URL ("See more" links, footer)

| `MASTODON_DATE_FORMAT`
| `'fr-FR'`
| Post date locale

| `MASTODON_BTN_SEE_MORE` / `MASTODON_BTN_RELOAD`
| `'Voir plus de post'` / `'Rafraichir'`
| Button labels

| `MASTODON_MAX_POST_FETCH` / `MASTODON_MAX_POST_SHOW`
| `'10'` / `'10'`
| Posts fetched / displayed

| `MASTODON_S3_MEDIA_URL`
| _(undefined)_
| S3 media origin (added to the CSP `media-src`)
|===

NOTE: The timeline has no enable flag: it is always displayed on the homepage.

.WordPress (optional)
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `WORDPRESS_ENABLED`
| `false`
| Enable the posts section

| `WORDPRESS_URL`
| `''`
| WordPress site URL (no trailing `/`)

| `WORDPRESS_POSTS_COUNT`
| `6`
| Number of posts (REST API `/wp-json/wp/v2/`)
|===

.Donations
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `DONATIONS_ENABLED`
| `true`
| Enable the system (heart icon, "Support" link, `/dons.php` page)

| `LIBERAPAY_URL` / `KOFI_URL`
| `'https://liberapay.com/OKI/donate'` / `''`
| External platform URLs. By default the LiberaPay link points to OKI, the application maintainer.

| `STRIPE_ENABLED`
| `false`
| Enable the Stripe interface (one-time / monthly tabs)

| `STRIPE_ONE_TIME_LINKS`
| `[1, 5, 10, 20, 50, 'custom'] => ''`
| Stripe payment links per amount

| `STRIPE_MONTHLY_LINKS`
| `[1, 5, 10, 20, 50] => ''`
| Stripe monthly subscription links

| `DONATION_AMOUNTS`
| `[1, 5, 10, 20, 50]`
| Suggested amounts

| `DONATION_CURRENCY`
| `'EUR'`
| Currency

| `DONATIONS_OKI_DISCLAIMER`
| Explanatory text
| Message shown on `/dons.php` when donation links point to a third-party organization (OKI by default). Leave empty to hide.
|===

NOTE: At least one platform (LiberaPay, Ko-fi or Stripe) must be configured for `dons.php` to render, otherwise the page returns a 500 error. By default only OKI's LiberaPay account is set.

TIP: If you deploy an independent instance, replace `LIBERAPAY_URL` with your own link and update or clear `DONATIONS_OKI_DISCLAIMER`.

.Countdown (maintenance / launch)
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `COUNTDOWN_ENABLED`
| `false`
| ⚠️ When `true`, *every* page redirects to `countdown.php`

| `COUNTDOWN_TARGET_DATE`
| `'2025-10-11 00:00:00'`
| Target date (`Y-m-d H:i:s`); on completion, redirects to `/`

| `COUNTDOWN_TIMEZONES`
| 5 territories
| `[Label => PHP timezone]` shown on the page
|===

.Next-live announcement
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `NEXT_LIVE_ENABLED`
| `false`
| Show the announcement when no live stream is running

| `NEXT_LIVE_TITLE` / `NEXT_LIVE_DESCRIPTION`
| `'Prochain live'` / `'Rejoignez-nous…'`
| Texts (date and time appended automatically)

| `NEXT_LIVE_DATE`
| `''`
| Live date (`Y-m-d H:i:s`)

| `NEXT_LIVE_IMAGE`
| `'uploads/next-live.jpg'`
| Announcement image (used only if the file exists)
|===

.Site, social networks and contact
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `SITE_NAME`
| `'ANNU KUTE CED'`
| Site name (titles, meta)

| `SITE_DESCRIPTION`
| `'Hub multimédia du podcast ANNU KUTE CED'`
| SEO description

| `SITE_LOGO` / `SITE_FAVICON`
| `'img/logo.png'` / `'img/favicon.png'`
| Image paths

| `FACEBOOK_URL`, `X_URL`, `INSTAGRAM_URL`, `YOUTUBE_URL`, `TIKTOK_URL`
| `'#'`
| `'#'` or empty = icon hidden

| `CONTACT_EMAIL`
| `'kontak@o-k-i.net'`
| Contact e-mail (footer)
|===

.Legal notice
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `LEGAL_COPYRIGHT`
| `'ANNU KUTE CED'`
| Copyright holder

| `LEGAL_WEBMASTER_NAME` / `LEGAL_WEBMASTER_EMAIL`
| `'Cédric Famibelle-Pronzola'` / `'contact@cedric-pronzola.dev'`
| Webmaster

| `LEGAL_HOST_NAME`, `LEGAL_HOST_COMPANY`, `LEGAL_HOST_RCS`, `LEGAL_HOST_ADDRESS`
| o2Switch values
| Hosting provider details

| `LEGAL_CONTACT_EMAIL`
| `'kontak@o-k-i.net'`
| Legal contact

| `LEGAL_LICENSE` / `LEGAL_LICENSE_URL`
| AGPL-V3 + GNU URL
| Code license

| `LEGAL_SOURCE_CODE_URL`
| `'https://labola.o-k-i.net/cedric/annu-kute-ced'`
| Source code repository (required by the AGPL for a network service)

| `LEGAL_SERVICE_DESCRIPTION`
| `'est le hub multimédia du podcast ANNU KUTE CED.'`
| Service description sentence
|===

.Tags and filters
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `SHORTS_MAX_DURATION`
| `180`
| Max short duration (s); portrait ratio (`aspectRatio ≤ 1`) also required

| `IMPORTANT_TAGS`
| `['ANNUKUTECED']`
| Hashtags in the sidebar, footer and mobile menu

| `POPULAR_TAGS`
| `['ANNUKUTECED']`
| Hashtags displayed on the homepage
|===

.Pagination and display volumes
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `VIDEOS_PER_PAGE`
| `12`
| Videos per page (search)

| `COUNT_VIDEO_SEARCH`
| `20`
| Results fetched per search

| `RECENT_VIDEOS_COUNT` / `TRENDING_VIDEOS_COUNT` / `CATEGORY_VIDEOS_COUNT`
| `6`
| Homepage section sizes

| `SHORTS_COUNT` / `SHORTS_COUNT_SEARCH`
| `6` / `100`
| Shorts displayed / search pool

| `LOAD_MORE_COUNT`
| `6`
| Videos loaded per "Load more" click

| `FEATURED_VIDEOS_COUNT`
| `6`
| Reserved (section not currently displayed)
|===

.Cache
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `CACHE_ENABLED`
| `true`
| Enable caching for *Castopod and Funkwhale*. Essential against the Castopod RSS rate limit; empty results (error, 429) are never cached

| `CACHE_DURATION`
| `3600`
| Cache lifetime (s)
|===

NOTE: The PeerTube API and WordPress cache (`includes/simple-cache.php`) is *always on*, regardless of `CACHE_ENABLED`, with per-endpoint TTLs (categories 1 h, videos/search 10 min, WP posts 15 min, accounts 5 min). Files are stored in `cache/api/`.

TIP: To prevent the first visitor from paying the cost of API calls on a cold cache, warm the cache via cron: `php /var/www/annu-kute-ced/scripts/warm-cache.php` (every 5 minutes for example).

.Security
[cols="2,2,3",options="header"]
|===
| Constant | Default | Description

| `CSRF_SECRET`
| `'change-me-in-config-local-php'`
| Secret key used to sign stateless CSRF tokens. Must be replaced in `config.local.php` with a random value (`php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"`)
|===

."About" block (no default values — block hidden until defined)
[cols="2,3",options="header"]
|===
| Constant | Description

| `MOVEMENT_TITLE`
| Block title (template fallback: "À propos")

| `MOVEMENT_DESCRIPTION`
| First paragraph; *commenting this line hides the whole block*

| `MOVEMENT_DESCRIPTION_2`
| Second paragraph (optional)

| `MOVEMENT_IMAGE` / `MOVEMENT_IMAGE_ALT` / `MOVEMENT_CAPTION`
| Image (`img/movement_presentation.png`, not versioned), alt text and caption (simple HTML allowed)
|===

==== Complete `config.local.php` example

[source,php]
----
<?php
define('APP_HOST_NAME', 'example.com');

// PeerTube (the podcast's GADE channel)
define('PEERTUBE_URL', 'https://gade.o-k-i.net');
define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');

// Hero: channel live stream, otherwise next-live announcement
define('HERO_TYPE', 'live');
define('LIVE_ACCOUNT_NAME', 'annu_kute_ced');
define('NEXT_LIVE_ENABLED', true);
define('NEXT_LIVE_TITLE', 'Prochain live');
define('NEXT_LIVE_DESCRIPTION', 'Recording the next episode!');
define('NEXT_LIVE_DATE', '2025-10-11 10:00:00');

// Castopod (the podcast's KUTE account)
define('CASTOPOD_ENABLED', true);
define('CASTOPOD_URL', 'https://kute.o-k-i.net');
define('CASTOPOD_PODCAST_SLUGS', ['annu_kute_cedric']);
define('CASTOPOD_EPISODES_COUNT', 5);

// Mastodon (BOKANTE account)
define('MASTODON_INSTANCE_URL', 'https://bokante.o-k-i.net');
define('MASTODON_URL', 'https://bokante.o-k-i.net/@cedric');

// Cache: essential for Castopod (RSS rate limit)
define('CACHE_ENABLED', true);
define('CACHE_DURATION', 3600);
----

=== 🎨 Appearance customization

. 🖼️ Replace `img/logo.png` with your logo (and the favicons in `img/`)
. 🎨 Edit the colors in `css/styles.css`
. 📝 Most texts are set through `config.local.php` constants ("About" block, Mastodon labels, etc.)

==== Instance files to customize

The `sitemap.xml`, `robots.txt`, `site.webmanifest`, `mentions-legales.php` and `dons.php` files are Git-ignored: copy the `.sample` files then adapt them. The provided samples use the generic `example.com` domain: replace it with your real domain. In `mentions-legales.php`, also replace the `VOTRE-DATE-MAJ` placeholder.

=== 🧭 Homepage section order

The homepage assembles sections in this order, each gated by its configuration:

. *Hero* — if `HERO_TYPE` ≠ `'none'` (live, video or playlist)
. *Castopod podcasts* — if `CASTOPOD_ENABLED` and `CASTOPOD_URL` is not empty
. *Funkwhale tracks* — if `FUNKWHALE_ENABLED` and `FUNKWHALE_URL` is not empty
. *Mastodon timeline* — always displayed
. *WordPress posts* — if `WORDPRESS_ENABLED` and `WORDPRESS_URL` is not empty
. *Popular hashtags* (`POPULAR_TAGS`)
. *Shorts* — carousel
. *Latest videos* + "Load more" button
. *Trending* + "Load more"
. *One section per priority category* (`PRIORITY_CATEGORIES`, only when it has videos) + "Load more"
. *"About" block* (if `MOVEMENT_DESCRIPTION` is defined) + hashtags aside

Desktop layout: hero/Castopod/Funkwhale + Mastodon + WordPress are arranged in columns (1fr 2fr, or 1fr 2fr 1fr with WordPress); stacked vertically on mobile.

[[en-securite]]
=== 🛡️ Security

==== What the application already enforces

- *Dynamic CSP*: `default-src 'self'` with a unique per-request *nonce* for inline scripts/styles; PeerTube/Mastodon/CDN origins added automatically; `localhost` relaxations for development
- *Headers*: `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `X-XSS-Protection`, `Referrer-Policy: strict-origin-when-cross-origin`, restrictive `Permissions-Policy`, HSTS (HTTPS only), CORP/COOP `same-origin`
+
NOTE: The COEP header was *deliberately removed* (commit `62d4d99`): `require-corp` broke cross-origin embeds from PeerTube, Mastodon, Castopod and Funkwhale.
- *CSRF*: stateless HMAC-SHA256 token (timestamp + signature, 1 h validity) injected as a `<meta>` tag, verified with `hash_equals` on the AJAX endpoint; `Origin` or `Referer` header verification
- *Anti-SSRF*: instance URL validation (http/https scheme, blocking private IPs and `localhost`), allowlist of PeerTube API endpoints, cURL without redirects
- *Anti-XSS*: systematic output escaping; input validation (video UUID, search query ≤ 200 chars, page numbers, category ID 1–20)

==== Server configuration (Nginx, recommended)

The `conf/nginx.conf.sample` file is the complete Nginx equivalent of the `.htaccess`: same protections (configuration and `.sample` files, `/includes/`, `/cache/`, `/docs/`, `/conf/` directories, hidden files, no directory listing), `.php` extension masking (clean URLs `/video` instead of `/video.php`), `no-cache` for `sw.js` and `site.webmanifest`, plus asset caching and gzip. The `:80` block serves the site immediately (no certificate required); the `443` block and the HTTPS redirect are generated by `certbot --nginx` (see link:DEPLOY.adoc[DEPLOY.adoc]).

[source,bash]
----
# Adjust the paths in conf/nginx.conf.sample, then:
sudo cp conf/nginx.conf.sample /etc/nginx/sites-available/example.com
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
----

==== Server configuration (Apache, alternative)

[source,bash]
----
cp conf/.htaccess.sample .htaccess
----

Included protections: blocking of configuration and `.sample` files, of the `/includes/`, `/cache/`, `/docs/`, `/conf/` directories; no directory listing; hidden files blocked; HTTPS enforced; `.php` extension masking.

WARNING: This configuration is *essential*: without it, your configuration files are exposed to visitors.

=== 📱 Progressive Web App (PWA)

==== Features

- 📲 *Native installation*: an "Install" button in the header on first visit
- 🌐 *Offline mode*: visited pages and static assets cached
- 📡 *Connection detection*: visual indicator when the network is lost
- 🔄 *Frictionless updates*: when a new version is deployed, a styled modal offers it; on accept, the user gets it immediately — *no manual browser-cache purge needed*

==== Service Worker caching strategies (`sw.js`)

[cols="1,2,3",options="header"]
|===
| Request type | Strategy | Detail

| Static assets (`/css/`, `/js/`, `/img/`, cdnjs CDN)
| *Cache First*
| Cached at install time and on first access; falls back to `img/logo.png` if an image fails

| Pages (`/`, `*.php`…)
| *Network First*
| Network first, cache fallback when offline

| API and AJAX (`/ajax/`, `mastodon-config.php`…)
| *Network Only*
| Never cached; synthetic 503 JSON response when offline
|===

==== Automatic update cycle

This mechanism (`js/pwa-update.js` + `sw.js`) ensures visitors receive new versions *without manually purging their browser cache*:

. When deploying, *bump* the version suffix of the cache names in `sw.js` (format `DDMMYYYY-HHMM`, e.g. `24072026-0612`)
. On page load, the browser detects the new `sw.js` and installs the new Service Worker in the background; it stays *waiting* (no automatic `skipWaiting()`)
. A *modal* offers the update to the user:
  * *"Mettre à jour"* → the waiting SW receives `SKIP_WAITING`, purges old caches on activation, takes control (`controllerchange`) and the page reloads onto the new version
  * *"Plus tard"* → the modal closes; the update is offered again on the next page load
. An update already installed during a previous visit but never activated is also reported

WARNING: For detection to work, `sw.js` must *not* be cached for long by the server: the provided configurations (`conf/nginx.conf.sample`, `conf/.htaccess.sample`) serve it with `no-cache` (as well as `site.webmanifest`). If you use your own server configuration, reproduce this rule.

==== Installation by visitors

- 🌐 *Chrome/Edge*: menu → "Install example.com"
- 🍎 *Safari iOS*: Share → "Add to Home Screen"
- 🦊 *Firefox Android*: menu → "Install"

[[en-analytics]]
=== 📊 Analytics (Plausible)

The site is *pre-configured* for https://plausible.io[Plausible Analytics^] (cookie-free, GDPR-compliant, anonymous data), but the script is *disabled by default*.

To enable it:

. Open `index.php` and *uncomment* the Plausible snippet (the `<script defer data-domain=… src="https://plausible.io/js/script…js">` tag and its `window.plausible` shim).
. That's it: the CSP already allows `https://plausible.io` in `script-src` and `connect-src`, and the `data-domain` automatically uses the current domain.

Data then collected (anonymous): visited pages, country of origin (IP not stored), device type, browser, time spent.

=== 🚀 Deploying to shared hosting

. 🐘 Check that the host provides PHP 7.4+ and the required extensions
. 🔒 Enable HTTPS (mandatory for the PWA)
. 📤 Upload the files via FTP to the site root
. 🔧 Permissions: `644` for files, `755` for directories; `cache/` must be writable
. 📄 Create the instance files from the `.sample` files (config, manifest, robots, sitemap, legal notice, donations)
. 🛡️ Copy `conf/.htaccess.sample` to `.htaccess` (Apache)
. 🌐 Point your domain (e.g. `example.com`) to the installation directory
. 🧪 Test the PWA via the browser developer tools (Application tab)

=== 👨‍💻 Development and contributing

. 🌿 Create a branch: `git checkout -b my-feature`
. 💾 Commit with *atomic, English messages*, in conventional format (< 70 characters):
  * `feat: add …`, `fix: prevent …`, `docs: update …`, `refactor: …`, `typo: …`
. 📤 Push: `git push origin my-feature`
. 🔀 Open a pull request

Repository best practices:

- Bug reports and suggestions are welcome on the fork's repository; generic improvements can be proposed upstream (FEDIVERSE OKI)
- Never version `config.local.php` or files derived from the `.sample` files
- A few constants are defined but unused by the current code (`ENABLE_SEARCH`, `ENABLE_COMMENTS`, `ENABLE_USER_ACCOUNTS`, `TAG_SHORT`): do not rely on them as extension points

==== 📄 Exporting the documentation (HTML / PDF)

This README can be exported to HTML and PDF with a single command (any `.adoc` file can be passed as argument):

[source,bash]
----
docs/generate-readme-pdf.sh                    # README.html + README.pdf
docs/generate-readme-pdf.sh --html             # HTML only
docs/generate-readme-pdf.sh --pdf              # PDF only
docs/generate-readme-pdf.sh DEPLOY.adoc        # DEPLOY.html + DEPLOY.pdf
docs/generate-readme-pdf.sh DEPLOY.adoc --pdf  # DEPLOY.pdf only
----

Files are produced in the project root (named after the source) and are Git-ignored (`.gitignore`).

Requirements: `asciidoctor`, `chromium` (headless rendering) and the `fonts-noto-color-emoji` package (color emoji in the PDF). The `docs/docinfo.html` stylesheet (emoji fallback + print rules) is automatically injected through Asciidoctor's `docinfo` mechanism.

NOTE: `asciidoctor-pdf` (used by VSCodium's AsciiDoc extension) cannot be used here: its Prawn engine cannot embed color-emoji fonts (CBDT/COLR), so emoji disappear from the PDF. Chromium handles them natively.

==== ✅ Local checks and CI/CD

Before pushing, run the full local checks:

[source,bash]
----
scripts/check.sh
----

This script runs: PHP lint (`php -l` on every file, samples included), JS lint (`node --check`), AsciiDoc rendering, JSON (`site.webmanifest.sample`) and XML (`sitemap.xml.sample`, `browserconfig.xml`) validation, shellcheck. A missing tool is reported and its check skipped.

Gitea Actions pipelines (`.gitea/workflows/`):

- *PR check* (`check-pr.yml`): blocking PHP and JS lint plus PHP and JS unit tests on every pull request to `main`
- *PROD deployment* (`deploy-prod.yml`): PHP and JS lint plus PHP and JS unit tests on push to `main`, then SSH deployment to the server (`git pull --ff-only` + Service Worker version bump, which triggers the update modal for visitors)

AsciDoc, JSON, XML and shellcheck validation are not run in CI: they must be run locally with `scripts/check.sh`.

The full production server setup (clone, instance files, SSH keys, Gitea secrets) is documented in link:DEPLOY.adoc[DEPLOY.adoc].

==== 🧪 Tests

The project has three levels of tests, all *without heavy frameworks* (no dependencies committed to the repo):

- *PHP unit tests* (`tests/php/`): native PHP script (`php tests/php/run.php`). Covers validators (`security.php`), cache (`simple-cache.php`), formatters (`config.php`) and Markdown rendering.
- *JS unit tests* (`tests/js/`): native `node:test` (`node tests/js/run.js`). Covers `CountdownTimer` and the Pleroma → Mastodon adapter.
- *E2E tests* (`tests/e2e/`): Playwright (Python), installed locally only. Covers the homepage, video page and the "Load more" AJAX endpoint.

[source,bash]
----
# PHP and JS unit tests (also run by scripts/check.sh)
php tests/php/run.php
node tests/js/run.js

# E2E tests (requires Playwright installed outside the repo)
python3 -m venv /tmp/test-venv
/tmp/test-venv/bin/pip install playwright pytest
/tmp/test-venv/bin/playwright install chromium
/tmp/test-venv/bin/python -m pytest tests/e2e/
----

NOTE: E2E tests start a local PHP server (`php -S`) and rely on the default configuration. Network calls to PeerTube are skipped gracefully if the instance is unreachable.

==== ⏱️ Performance benchmark

The `scripts/benchmark.php` script measures the TTFB (_time to first byte_) of key pages — homepage (`/`), video page (`/video.php?id=…`) and category page (`/categories.php?id=…`) — under two conditions:

- *cold*: API cache emptied (the first visitor pays the cost of external API calls);
- *warm*: API cache filled (average over 3 requests).

It starts a local PHP server (`php -S`), automatically discovers a real video and category through the PeerTube API (the corresponding pages are skipped if the instance is unreachable) and reports the API cache size before and after the measurements.

[source,bash]
----
php scripts/benchmark.php
----

NOTE: The script purges the API cache (`cache/api`): use it outside production, or follow it with a cache warmup (`scripts/warm-cache.php`).

=== 📜 License

Copyright (C) 2025 Cédric Famibelle-Pronzola & *ORGANISATION KA INTERNATIONALE*

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the https://www.gnu.org/licenses/agpl-3.0.html[GNU Affero General Public License^] for more details (full text in `LICENSE`).

=== 📞 Contact

For any question or suggestion about this platform: mailto:kontak@o-k-i.net[kontak@o-k-i.net]

<<top,⬆ Back to top>>
