Des commentaires sur un blog statique, avec Mastodon
Mon blog n'a ni serveur ni base de données. Ses commentaires, ce sont donc les réponses au pouet qui annonce chaque article sur Mastodon. Comment ça marche, ce que j'ai dû sécuriser, et ce que ça ne fait pas.
Ce blog est un site statique : Astro fabrique des pages HTML, je les envoie sur mon hébergement par FTP, et c’est tout. Aucun programme ne tourne sur le serveur pour enregistrer quoi que ce soit. Pour avoir des commentaires, il fallait donc les confier à quelqu’un d’autre. J’ai choisi Mastodon : les commentaires d’un article sont les réponses au pouet qui l’annonce.
Pourquoi Mastodon
J’ai écarté les autres pistes une par une :
| Option | Ce qui coince (ou pas) |
|---|---|
| Disqus | La formule gratuite affiche de la publicité, et l’entreprise vit des données de ses visiteurs. Ma page À propos promet l’inverse. |
| Isso, Remark42 (auto-hébergés) | Il faut un programme qui tourne en continu sur le serveur. Mon hébergement OVH Starter ne sert que des fichiers. |
| Giscus (GitHub Discussions) | Très bien fait, mais il faut un compte GitHub pour commenter. Parfait pour du code, moins pour des figurinistes. |
| Mastodon | Aucun serveur à gérer personnellement, et pas de pub. |
J’ai hésité à mettre Giscus et Mastodon côte à côte. Mauvaise idée : la conversation se coupe en deux, et le lecteur ne sait plus où écrire. Ce sera Mastodon, et lui seul.
Le principe
- Je publie l’article.
- Je l’annonce sur Mastodon depuis mon compte.
- Je colle l’adresse de ce pouet dans l’en-tête de l’article :
mastodon: https://ludosphere.fr/@machinvorpal/117384341397858619
- Sous l’article, un bouton « Afficher les réponses » va chercher les réponses à ce pouet et les affiche. Pour commenter, on répond au pouet depuis n’importe quel compte du Fediverse.
Tout repose sur une adresse de l’API publique de Mastodon, qui renvoie le fil de discussion d’un pouet sans aucune connexion :
GET https://ludosphere.fr/api/v1/statuses/<identifiant>/context
La réponse contient descendants, la liste des réponses avec leur auteur, leur texte et à quel message elles répondent. Avant d’écrire la moindre ligne, j’ai vérifié deux choses depuis mon navigateur : que ludosphere.fr accepte ces demandes venues d’un autre site, et qu’il ne réclame pas de connexion. Les deux étaient bons. Ce n’est pas garanti sur toutes les instances : un administrateur peut fermer l’API aux visiteurs anonymes.
L’en-tête de l’article
Le champ est déclaré dans le schéma du contenu (src/content.config.ts), avec une vérification : si l’adresse ne se termine pas par un identifiant de pouet, npm run build s’arrête avec un message clair au lieu de publier un article aux commentaires cassés.
mastodon: z.string().url()
.regex(/\/\d+\/?$/, "adresse d'un pouet Mastodon attendue")
.optional(),
Le composant Comments.astro en tire l’instance et l’identifiant :
const url = new URL(toot);
const host = url.host; // ludosphere.fr
const id = url.pathname.match(/(\d+)\/?$/)?.[1]; // 117384341397858619
Sans ce champ, l’article affiche simplement une invitation à m’écrire sur Mastodon.
Rien n’est chargé sans clic
Les réponses ne sont pas chargées à l’ouverture de la page, mais seulement quand le lecteur clique sur « Afficher les réponses ». Tant qu’il ne clique pas, son navigateur ne contacte aucun autre site que le mien. Quand il clique, ludosphere.fr voit passer son adresse IP, comme pour n’importe quelle page web ; je l’ai écrit dans la page À propos.
const rep = await fetch(`https://${host}/api/v1/statuses/${id}/context`);
const { descendants } = await rep.json();
const visibles = descendants
.filter((r) => r.visibility === 'public' || r.visibility === 'unlisted')
.sort((a, b) => a.created_at.localeCompare(b.created_at));
Je garde les réponses publiques et « non listées », qui est le réglage par défaut de beaucoup de comptes pour répondre. Les messages privés, eux, ne sont jamais renvoyés à un visiteur anonyme. Tant mieux : je n’ai aucune envie d’afficher sous un article une conversation que quelqu’un croyait privée.
Chaque réponse a un champ in_reply_to_id. En parcourant la liste dans l’ordre chronologique, je range chaque réponse sous celle à laquelle elle répond, et on retrouve le fil de la discussion, avec ses retraits.
La partie sécurité
C’est la partie qui m’a pris le plus de temps. Le texte d’une réponse, c’est du HTML écrit par un inconnu et relayé par un serveur que je ne contrôle pas. Le coller tel quel avec innerHTML, c’est laisser n’importe qui exécuter du code sur mon site.
Je reconstruis donc chaque réponse élément par élément, en ne gardant que les paragraphes, les retours à la ligne, les liens et les span que Mastodon utilise pour raccourcir les adresses longues. Tout le reste est réduit à son texte, et les <script>, <style>, <iframe> disparaissent avec leur contenu :
const SURS = new Set(['P', 'BR', 'A', 'SPAN']);
const IGNORES = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'IFRAME', 'OBJECT', 'SVG']);
Ça ne suffisait pas, et je l’ai raté au premier jet : le lien vers le profil de l’auteur et le lien vers sa réponse viennent eux aussi d’ailleurs. Un serveur malveillant pourrait y mettre javascript:…, qui s’exécute au clic. Désormais, seules les adresses en http(s):// passent :
const sure = (url: string) => (/^https?:\/\//i.test(url) ? url : '#');
Le nom de l’auteur, lui, passe par textContent : il s’affiche comme du texte, quoi qu’il contienne.
Dernière ceinture de sécurité, côté serveur cette fois : mon .htaccess envoie un en-tête Content-Security-Policy qui dit au navigateur ce que la page a le droit de faire. Pour les commentaires, deux lignes comptent :
connect-src 'self' https://ludosphere.fr;
img-src 'self' data: https:;
La page ne peut interroger que ludosphere.fr. Les images peuvent venir d’ailleurs, parce que les avatars sont servis par le CDN de l’instance (cdn.masto.host pour ludosphere), et non par l’instance elle-même. Si un jour j’annonce un article depuis une autre instance, il faudra l’ajouter à connect-src, sinon le navigateur bloquera le chargement une fois le site en ligne. Et ça ne se verrait pas en local : npm run dev n’envoie pas cet en-tête.
Le d20 de ludosphere
Au premier test, mon nom s’affichait « Machin Vorpal :d20: ». Les instances ont leurs propres émojis, et l’API les envoie sous forme de code, avec à côté la liste des images correspondantes (emojis, pour le texte et pour le nom de l’auteur). Je remplace donc chaque :code: connu par sa petite image, et je laisse tel quel un code inconnu. Le petit dé s’affiche maintenant à côté de mon nom.
Tester sans déranger tout le monde
Pour le premier essai, je voulais une mention privée, histoire de ne pas inonder ludosphere de tests. Raté : un visiteur anonyme ne voit jamais les messages privés, donc la page non plus.
Ce qui marche :
- Ne rien publier. Mettre dans un brouillon l’adresse d’un pouet qui a déjà des réponses publiques, et regarder le résultat avec
npm run dev. - Un pouet « Public discret ». Il reste visible sur mon profil, mais n’apparaît ni dans le fil local, ni dans le fil fédéré, ni dans les hashtags : seuls mes abonnés le voient passer. Je m’y réponds à moi-même, et la réponse s’affiche sous l’article.
Les limites
- Il y a une étape manuelle à chaque article : annoncer, copier l’adresse, reconstruire, renvoyer le site.
- Pour commenter, il faut un compte sur le Fediverse. Pour ceux qui n’en ont pas, la page propose de copier l’adresse du pouet, mais ça ne remplace pas un formulaire.
- Sans JavaScript, pas de commentaires sur le site. Sans lui, ils restent lisibles sur Mastodon.
- La modération n’est pas chez moi : je ne peux pas supprimer la réponse de quelqu’un d’autre ; ce sont les règles de l’instance qui s’appliquent. Si un jour c’est nécessaire, j’ajouterai une liste de réponses à masquer dans l’en-tête de l’article.
- Si je supprime le pouet, l’article affiche « Impossible de charger les réponses ». Les commentaires vivent sur Mastodon, pas sur mon site.
Pour un blog qui démarre, je m’en accommode très bien. Je n’ai rien à maintenir, et chaque article fait un peu vivre mon compte Mastodon.
Commentaires
Pour réagir à cet article, écrivez-moi sur Mastodon : @machinvorpal@ludosphere.fr.