18. Archives

Ces archives sont une réimplémentation de HyperKitty, l’archiveur de GNU Mailman 3, dont elles suivent le modèle de données, les règles de constitution des fils, le hachage de Message-ID et le schéma d’URL. HyperKitty est la propriété intellectuelle de la Free Software Foundation et de ses contributeurs, et la documentation d’amont décrit les mêmes notions ; la compatibilité est délibérée et porteuse, non accessoire. Un site qui migre depuis Mailman conserve ses liens d’archives, et importer ses archives s’apparente à une copie de table.

Ce qui diffère, c’est l’endroit où les archives résident : dans PostgreSQL, à côté de la file et de la liste des membres, plutôt que dans un index de recherche doublé d’un magasin de documents. C’est ce qui rend possible ce par quoi ce chapitre commence.

Les archives sont une moitié d’un sous-système ; l’autre, ce sont les listes elles-mêmes. Listes de diffusion est le chapitre des listes, des membres, de la modération et des résumés, et Comment le sous-système est agencé y donne la carte de l’ensemble – d’où les archives sont écrites (le gestionnaire to-archive de pepsi-stage-list-post) et qui d’autre les lit. L’outil de l’exploitant est pepsi-archive ; la surface de navigation est La console d’administration ; importer des archives existantes fait partie d’une migration, voir donc Installation ; les normes sont rassemblées dans Index des RFC (en particulier Archived-At, RFC 5064) ; et Fonctionnalités prises en charge résume ce qui est implémenté et ce qui ne l’est pas. Tout ce que l’API REST dit de l’archivage relève de L’API REST de GNU Mailman 3.

GNU Mailman lui-même est reconnu plus longuement dans Listes de diffusion, qui nomme le projet, la Free Software Foundation et la GNU General Public License sous laquelle le matériel repris demeure.

18.1. Supprimer un message

Supprimer un message d’archives publiques tient en une commande.

# pepsi-archive purge --list announce@lists.example.org \
    'CAFxyz...@mail.example.org'
purged VHBHHV4YTF64QIJBGZ6XWA57YD6VRIZA from announce@lists.example.org;
tombstoned for 90 days so a resend or a re-run import cannot bring it back

Le message, ses pièces jointes et ses votes ont disparu ; le fil dont il faisait partie est renuméroté ; les compteurs sont réparés ; et un enregistrement event_log consigne qui a supprimé quoi et quand.

La Message-ID n’est pas supprimée. Une pierre tombale la conserve pendant [pepsi-list] TOMBSTONE_RETENTION jours (90 par défaut), et tant qu’elle se dresse, les archives refusent de stocker ce message à nouveau. Il n’y a exactement que deux façons pour un message supprimé de revenir – quelqu’un le remet, ou un importeur est relancé – et les deux sont des accidents, non des décisions. purge --forget saute la pierre tombale quand la décision est délibérée.

C’est un écart par rapport à « supprimer veut dire disparu », et il est énoncé ici plutôt qu’en note : pendant TOMBSTONE_RETENTION jours après une suppression, les archives détiennent encore l’identifiant de ce message, sa date de suppression et le nom de qui l’a supprimé.

18.2. Ce qui est archivé, et ce qui ne l’est pas

La archive_policy d’une liste décide qui peut lire les archives : public, private (membres seulement) ou never (rien n’est stocké du tout). Les deux premières sont appliquées à la lecture, ce qui permet à un propriétaire de faire passer une liste de privée à publique sans réingestion – et fait de purge la seule chose qui retire jamais du contenu.

Un expéditeur peut soustraire un message donné avec un en-tête X-No-Archive: (quelle que soit sa valeur) ou X-Archive: no.

Les archives conservent la véritable adresse de l’expéditeur. L’obscurcir est une décision de rendu : un lecteur anonyme voit une forme obscurcie, un membre connecté voit l’adresse. Amont publie les adresses telles qu’envoyées ; nous les stockons en entier et décidons à la sortie, parce que l’importeur, l’exporteur et l’aller-retour mbox ont tous besoin de la valeur réelle – et parce qu’un réglage qui détruit des données à l”entrée ne peut pas être annulé.

18.3. Rechercher

Deux mécanismes, délibérément les deux, car aucun n’englobe l’autre.

La recherche de mots classée applique une racinisation : chercher upgrade trouve upgraded. Elle utilise le dictionnaire de langue propre à la liste et, là où PostgreSQL n’en a pas, elle annonce simple, qui trouve encore – simplement sans raciniser. Un résultat dans l’objet devance un résultat dans le corps, et le nom de l’expéditeur est cherchable.

La recherche de sous-chaîne et approchée ne racinise pas, et c’est précisément son objet : un nom d’hôte, une clé de configuration, un nom de fonction, une ligne de trace d’exécution, un nom mal orthographié. Sur une liste technique, cela représente une grande part de ce que les gens cherchent réellement, et un dictionnaire de racinisation jette exactement cette matière.

Le champ de recherche décide laquelle employer. Un mot ou une expression entre guillemets donne une recherche de mots ; un fragment portant de la ponctuation (lists.example.org, --no-certbot, self.assertEqual) ou un *joker* explicite donne une recherche de sous-chaîne ; et le résultat indique laquelle a répondu.

18.3.1. Le palier de trigrammes

La recherche de sous-chaîne coûte de l’espace d’index : la part d’une liste qui y est indexée est donc un choix – off, short ou full :

Palier

Ce qui est indexé pour la recherche de sous-chaîne

off

Rien. La colonne vaut NULL, et l’index GIN de PostgreSQL n’indexe pas les NULL – une liste qui se retire ne coûte donc exactement rien, ce qui a l’air d’un oubli et constitue tout le mécanisme.

short

L’objet, l’objet du fil, le nom et l’adresse de l’expéditeur. Le défaut : assez pour « ce message d’Alice à propos des versions ».

full

Ce qui précède, plus le corps du message. Le palier coûteux, et le seul qui trouve un fragment de trace d’exécution.

[pepsi-list] SEARCH_TRIGRAM est un plafond du serveur, non une valeur par défaut ; une liste choisit jusqu’à lui avec pepsi-list list set-ext <list> search_trigram <tier>. Le palier effectif est le plus bas des deux.

Le changer est une réindexation, non une migration :

# pepsi-archive reindex --list announce@lists.example.org
announce.lists.example.org: reindexed 12043 message(s) at tier full with the
english dictionary

Abaisser le plafond du serveur réduit donc l’index à la prochaine réindexation, au lieu de seulement interdire de nouveaux enregistrements.

Une liste au palier ``short`` le dit. Une recherche de fragment de corps y répond « cette liste n’indexe pas le corps des messages pour la recherche de sous-chaîne » plutôt que rien du tout : une recherche qui regarde discrètement moins que le lecteur ne le croit est pire qu’une recherche qui l’avoue.

Une recherche sur tout le serveur lit chaque partition (les archives sont partitionnées par liste) : c’est donc le choix le plus lent, et il est explicite ; une recherche limitée à une liste élague vers une seule partition et emploie le dictionnaire de cette liste.

18.4. Les URL qu’une migration conserve

Chaque message archivé a un hachage de Message-ID : le base32 du SHA-1 de sa Message-ID. C’est celui d’amont, inchangé, parce que chaque en-tête Archived-At: qu’un déploiement HyperKitty a jamais émis pointe vers une URL qui en dérive, comme chaque lien dans chaque courrier que quiconque a conservé :

URL

Ce qu’elle désigne

/archives/list/<list@domain>/

les archives de la liste

/archives/list/<list@domain>/message/<HASH>/

un message

/archives/list/<list@domain>/thread/<HASH>/

un fil, par son message initial

L’identifiant d’un fil est le hachage de son message initial, et c’est pourquoi la constitution des fils doit correspondre à celle d’amont et pas seulement être raisonnable : un fil groupé autrement est un fil avec une autre URL.

18.5. Constitution des fils

Le parent d’un message est son In-Reply-To, à défaut la dernière entrée de References – le message auquel on a réellement répondu, et non la racine du fil. Si ce message n’est pas archivé sur cette liste, la réponse ouvre un fil à elle. C’est la règle d’amont et elle est conservée, afin qu’une archive importée constitue ses fils comme avant ; la réparation est une commande distincte et non une règle différente :

# pepsi-archive rebuild-threads --list announce@lists.example.org
announce.lists.example.org: reattached 37 orphaned repl(ies), renumbered
1284 thread(s)

Une boucle de réponses – deux messages qui se réclament parents l’un de l’autre, ce que le courrier réel contient – perd une arête au lieu de boucler indéfiniment.

18.6. Les compteurs, et la seule façon de les casser

Les pages d’index lisent des colonnes de compteurs (messages par fil, dernière activité d’un fil) au lieu de compter un million d’enregistrements à chaque rendu. Elles sont tenues à jour par l’écrivain, ce qui implique une chose qu’un exploitant doit savoir :

Si vous écrivez dans les tables d’archives en dehors de ``pepsi-archive``, les compteurs mentent.

Ils sont réparables, et c’est justement pourquoi ce sont des colonnes et non un cache :

# pepsi-archive recount --list announce@lists.example.org

18.7. Conservation et export

pepsi-archive expire --list <addr> --before <date> supprime par lots tout ce qui précède une date – le prédicat est une date et le partitionnement se fait par liste, il ne peut donc pas élaguer, et une seule instruction sur une année d’une liste active tiendrait une transaction ouverte plusieurs minutes.

La même suppression s’exécute selon un calendrier. pepsi-list tasks --once, que l’unité pepsi-list-tasks.timer exécute quotidiennement, fait expirer les messages de chaque liste plus anciens que sa rétention : la propre valeur archive_retention_days d’une liste (posée avec pepsi-list list set-ext) l’emporte, 0 compris ; une liste qui n’en a pas emploie [pepsi-list] ARCHIVE_RETENTION, dont la valeur par défaut est elle-même 0 ; et 0 conserve tout. Une archive conserve donc tout à moins que quelqu’un n’en décide autrement, les fils et les compteurs sont réparés comme après un expire manuel, et une liste peut tout conserver sur un site qui fait expirer. Une valeur stockée qui n’est pas un nombre de jours est ignorée avec un avertissement plutôt que devinée, puisqu’une mauvaise supposition supprime du courrier.

pepsi-archive export --list <addr> écrit une mbox mboxrd sur la sortie standard, c’est-à-dire le format que tout autre archiveur lit. C’est mboxrd et non mboxo à dessein : une ligne de corps qui commençait déjà par >From `` reçoit un ``> de plus, ce qui rend l’export inversible – et un export inversible est la différence entre une sauvegarde et une approximation.

18.8. Pièces jointes

Une pièce jointe, ce sont des octets fournis par un utilisateur et servis depuis notre propre origine, soit la chose la plus dangereuse de ce sous-système. Quatre règles, appliquées par les archives et non par ce qui rend la page :

  • toujours Content-Disposition: attachment, jamais en ligne ;

  • une partie text/html stockée n’est jamais servie en text/html ;

  • le type de contenu stocké n’est qu’indicatif – la réponse emploie un type sûr pris dans une courte liste d’autorisation (texte brut, PNG, JPEG, GIF, WebP, PDF) et application/octet-stream pour tout le reste, de sorte qu’un type auquel personne n’a pensé est ennuyeux plutôt que dangereux ;

  • la réponse porte une Content-Security-Policy valant default-src 'none'; sandbox (plus base-uri, form-action et frame-ancestors, tous à 'none') et X-Content-Type-Options: nosniff.

18.9. Les parcourir

Les archives sont servies sur un listener marqué LISTS = yes (voir Trois drapeaux de listener, et pour deux d’entre eux le conseil est inverse), aux formes d’URL de HyperKitty :

URL

Page

/archives/list/<list@domain>/

Vue d’ensemble : chaque mois avec un décompte, et les fils les plus récemment actifs

/archives/list/<list@domain>/<year>/<month>/

Les fils d’un mois

/archives/list/<list@domain>/thread/<hash>/

Un fil entier, indenté selon la profondeur stockée

/archives/list/<list@domain>/message/<hash>/

Un message

/archives/list/<list@domain>/message/<hash>/attachment/<n>/<name>

Une pièce jointe

/archives/list/<list@domain>/search?q=…

Rechercher dans cette liste

Les formes sont préservées à dessein. <hash> est le même hachage de Message-ID, si bien que chaque en-tête Archived-At: qu’un déploiement migré a jamais émis se résout encore – y compris ceux qui dorment déjà dans les boîtes des abonnés et sont cités dans les archives d’autrui. Les conserver coûte une table de routes et vaut davantage que n’importe quelle amélioration qu’on leur apporterait.

18.9.1. Qui peut lire quoi

Un visiteur anonyme ne voit les archives d’une liste que si sa archive_policy vaut public. Tout le reste est un 404, non un 403 : dire « interdit » confirmerait que la liste existe, et une liste non annoncée est joignable par URL précisément pour que cette différence compte.

Chaque page de parcours est limitée à une liste : la décision est donc prise une fois, avant qu’aucune requête ne s’exécute. La recherche est l’exception : elle traverse les listes et les classe, et un classement calculé sur des enregistrements que le lecteur ne peut pas voir révèle leur existence par l’ordre de ceux qu’il peut voir – la règle de visibilité y fait donc partie du WHERE et n’est pas un filtre appliqué ensuite.

18.9.2. Pièces jointes

Une pièce jointe est toujours servie en téléchargement, jamais sous le type que le message revendiquait pour elle, et sous une politique qui ne lui permet rien si un navigateur décide malgré tout de la rendre. C’est un seul utilitaire portant ces quatre règles, et les pages web y renvoient au lieu de servir quoi que ce soit elles-mêmes ; un test vérifie qu’aucun code de la surface publique ne fixe un type de contenu d’après des données stockées.

18.9.3. Deux différences documentées avec amont

Les deux sont visibles pour un lecteur et aucune n’est accidentelle :

  • Les adresses sont obscurcies pour les visiteurs anonymes (alice@…). Les archives conservent l’adresse réelle ; c’est une décision de rendu et non un contrôle de sécurité – voir La console d’administration.

  • ``archive_rendering_mode = markdown`` est stocké et non rendu. L’attribut existe parce que l’ensemble des attributs est le contrat de compatibilité, et les deux valeurs s’affichent en texte. Rendre du markdown fourni par un utilisateur dans notre propre origine est exactement ce que la Content-Security-Policy de ces pages doit rendre impossible.

18.9.4. Actions de lecteur qui exigent un compte

Deux choses, sur une page d’archives, appartiennent à une personne plutôt qu’à la page, et toutes deux exigent un compte de membre (voir Deux systèmes de comptes, et aucun n’accorde rien à l’autre) :

POST /archives/list/<list@domain>/message/<hash>/vote

Un vote de 1, -1 ou 0 pour le retirer. Clé (liste, message, membre), donc idempotent par construction – appuyer deux fois sur le bouton fait un vote – et la réponse est une redirection vers le message, si bien qu’un rechargement ne peut pas revoter non plus.

Les votes sont stockés par message et les compteurs vivent sur le fil, ce qui est l’agencement de HyperKitty : une liste de fils affiche un score, et le calculer à chaque chargement de page impliquerait une jointure sur les votes de chaque message. Les compteurs sont donc recalculés depuis les enregistrements de vote dans la même instruction que le vote, au lieu d’être incrémentés – un lecteur qui change un vote positif en vote négatif déplace le décompte de deux, et une incrémentation qui supposerait un déplacement de un dériverait.

POST /archives/list/<list@domain>/thread/<hash>/favourite

Une bascule, parce que le contrôle est un seul bouton et qu’un bouton n’a pas d’état à envoyer.

Les deux portent un jeton CSRF, et un lecteur anonyme qui en demande un est renvoyé au formulaire de connexion : les archives elles-mêmes restent lisibles sans compte, et seules ces écritures en exigent un.

Un membre connecté voit en outre l”adresse complète de l’expéditeur au lieu de la forme obscurcie. Ce n’est pas un affaiblissement de la règle ci-dessus – l’obscurcissement n’a jamais été un contrôle de sécurité, et quiconque est sur la liste a déjà l’adresse en tête de sa propre copie. Ce qu’il empêche, c’est la collecte en masse qui fait d’archives publiques une source de spam, et un collecteur ne crée pas un compte ni ne vérifie une adresse pour lire une page à la fois. La conséquence à connaître concerne le cache : une page dont le contenu dépend de qui la lit est servie en no-store, si bien que des archives lues par des membres connectés ne sont pas partageables par un cache frontal.

L’archive n’a ni étiquetage des fils ni catégories. pepsi-archive import lit des fichiers mbox avec les réparations dans le lecteur et reconstruit les fils ensuite ; voir pepsi-archive et Installation. La ligne de commande de pepsi-archive constitue la totalité de l’interface d’exploitation des archives.