Au revoir GNU MediaGoblin

De la nécessité de passer à une solution low-tech pour l'hébergement d'images du blog.

Bonsoir,

Aujourd'hui un article d'adieu à GNU MediaGoblin, que j'ai utilisé pour héberger toutes les images de ce blog et sur lequel j'ai déjà écrit plusieurs articles (celui-ci et celui-là).

Les raisons de cet abandon sont les suivantes :

Cela s'est déjà manifesté à de nombreuses reprises. Par exemple, pour tout ce qui est gestion de version et partage de code entre mes différentes machines :

Récupération du contenu de GMG vers le blog

Depuis que les images sont stockées dans GMG, toutes leurs métadonnées se situent côté serveur. Ces dernières sont tirées pour le blog par un plugin pour Pelican. Celui-ci va créer les figures correspondantes en tapant dans une API REST avec OAuth 1.0. Un système de mise en cache utilisant SQLite a été mis en place, car l'API met beaucoup de temps à répondre vu le nombre d'images qui y sont stockées. On va utiliser cette base SQLite pour essayer de tout rappatrier en local.

J'ai donc écrit un script de migration implémentant l'algorithme suivant :

Pour chaque article dans articles:
    Pour chaque [gmg:<id>] dans article:
        Télécharger image <id> vers content/images/<article.slug>/
        Remplacer [gmg:<id>] par figure reST avec les bonnes métadonnées

Ça parait simple comme ça, mais je vous l'ai très fortement résumé. J'avais commencé à coder un prototype en Python bille en tête, mais ça n'a pas fonctionné. J'ai dû passer au combo papier/crayon pour trouver cet algorithme — ainsi que d'autres — afin de mener à bien la migration. J'ai pu ensuite implémenter ces idées en Python avec beaucoup plus de facilité.

Voici le contenu du script de migration, pour la beauté de vos yeux (non — c'est un script poubelle qui ne me sert qu'une fois, il est donc par définition écrit à l'arrache).

import os
import glob
import shutil
import logging
import re

import requests
import coloredlogs

from conf import GMG_REGEX
from models import db, Image


coloredlogs.install(level='INFO')
logger = logging.getLogger(__name__)

ARTICLES = glob.glob('content/**/*.rst')
MEDIA_DIR = "images/"


THUMB_REGEX = re.compile(':image: ([a-f0-9-]+)')


def download_file(url, destdir):
    logger.info(f"downloading {url}...")
    local_filename = url.split('/')[-1]
    dest = os.path.join(destdir, local_filename)

    if os.path.isfile(dest):
        return dest

    with requests.get(url, stream=True) as r:
        with open(dest, 'wb') as f:
            shutil.copyfileobj(r.raw, f)

    return dest


def handle_img(img):
    download_file(img.medium)
    print(img.public_id)


def gen_figure(img):
    return f"""
.. figure:: {img.medium}
    :alt: {img.name}
    :target: {img.url}

    {img.content}"""


def parse_article(article_path):

    article_slug = os.path.splitext(os.path.basename(article_path))[0]

    logger.info(f"parsing {article_path} ({article_slug}]")

    article_dir = os.path.join(MEDIA_DIR, article_slug)
    logger.info(f"mkdir {article_dir}")
    os.makedirs(article_dir, exist_ok=True)


    with open(article_path) as f:
        lines = f.readlines()

    if not lines:
        logger.warning(f'empty file {article_path}')
        return

    def convert_line(line):
        match = GMG_REGEX.match(line)
        if match:
            public_id = match.group(2)
            logger.debug(f'matched line, result: {public_id}')

            img = Image.get(Image.public_id == public_id)
            img.url = '/' + download_file(img.url, article_dir)
            img.medium = '/' + download_file(img.medium, article_dir)

            return gen_figure(img).splitlines()


        match = THUMB_REGEX.match(line)
        if match:
            public_id = match.group(1)
            img = Image.get(Image.public_id == public_id)
            url = download_file(img.url, article_dir)

            return (f":image: {url}",)


        return (line.strip('\n'),)

    new_lines = []
    for line in lines:
        new_lines.extend(convert_line(line))

    with open(article_path, 'w') as f:
        f.write('\n'.join(new_lines))
        f.write('\n')


def main():
    for article in ARTICLES:
        parse_article(article)


if __name__ == '__main__':
    main()

On s'en sort donc avec 110 lignes pour rapatrier toutes nos données localement, et utiliser un stockage statique des images plutôt que de passer par un service web dynamique de trop à maintenir.

Voici un exemple de diff que le script a généré sur un article :

diff --git a/content/article/application-gtk-rust-glade.rst b/content/article/application-gtk-rust-glade.rst
index 01d1cf2..175eee7 100644
--- a/content/article/application-gtk-rust-glade.rst
+++ b/content/article/application-gtk-rust-glade.rst
@@ -66,7 +66,12 @@ interface bidon dans Glade_ plutôt que de coder.

 Ce dernier est un éditeur d'interface graphique, comme QtCreator mais pour GTK.

-[gmg:id=92aa0233-77bc-41c2-956d-21a2b9113e6d]
+
+.. figure:: /images/application-gtk-rust-glade/glade.png
+    :alt: Capture d'écran de Glade
+    :target: /images/application-gtk-rust-glade/glade.png
+
+    Les widgets à gauche, les propriétés à droite, un classique.

On comprend bien mieux le fonctionnement avec cet exemple. Toutes les métadonnées sont de nouveau stockées dans l'article, et l'image est stockée localement plutôt que sur un serveur distant.

Mais le versionning dans tout ça ?

Si vous avez lu l'un des deux autres articles concernant GMG présentés en début d'article, vous aurez remarqué que mon principal problème qui m'a fait passer à GMG est que je n'arrivais pas à stocker mes images dans git. En effet, gitolite ne supporte pas git-lfs pour stocker des images, ce qui est utilisé dans toutes les implémentations avancées pour le stockage de gros fichiers binaires.

Cependant, j'ai depuis pensé à une solution un peu plus low-tech pour éviter de stocker mes images dans git : le dossier content/images que le script de migration a créé — et qui contient donc toutes les images — est ignoré du gestionnaire de version grâce au fichier .gitignore.

Du coup, pour télécharger les images depuis un nouveau git clone du blog, il faut faire un rsync du serveur vers le PC pour récupérer toutes les images existantes. Voici ce que je viens d'ajouter au fichier tasks.py (utilisant invoke) pour me permettre de télécharger toutes les images du blog après un nouveau git clone :

@task
def rsync_images(c):
    """Download images from the server to local directory."""
    os.makedirs('content/images', exist_ok=True)
    c.run('rsync --info=progress2 -az {production}:{dest_path}/images content'
          .format(**CONFIG))

Et ça s'appelle comme ça :

$ invoke rsync-images
 85,914,436 100%   13.47MB/s    0:00:06 (xfr#165, to-chk=0/192)

Pour publier de nouvelles images, on appelle également rsync, mais cette fois-ci dans l'autre sens. C'est exactement comme si on avait GMG, mais en beaucoup plus simple. Il n'y a plus besoin de faire tourner ce service — assez gourmand en mémoire et CPU comparé à mes standards — sur le VPS qui héberge ce blog.

Reste l'ergonomie qu'il faudra améliorer : pour ajouter des images à un article, il faut écrire toute la .. figure en reST, ainsi que de mettre l'image dans un dossier qui possède le même nom que le slug de l'article.

Cependant, on peut écrire un petit utilitaire en Python qui va automatiquement ajouter une image au bon endroit pour le dernier article ajouté, par exemple. On peut également écrire une macro Vim permanente qui permet d'ajouter facilement une nouvelle figure dans un article en nous épargnant la redondance que cela peut induire.

Cet article ne contenant pas d'images, je n'ai donc pas encore pris la peine d'implémenter ces idées prometteuses.

Conclusion

Merci MediaGoblin pour m'avoir hébergé mes images pendant quelques temps, mais il est temps de couper le service (bientôt, il faut que je retrouve ma configuration Ansible). De toute façon, le logiciel ne semble plus être vraiment développé (est-ce un mal ?), donc je ne serai pas victime des nouvelles fonctionnalités géniales qui n'arriveront jamais.

Je pense que cette aventure m'aura appris quelque chose. Quand on doit héberger des fichiers, on doit se poser la question suivante : est-ce que rsync ne peut pas, avec un serveur HTTP(S), simplement remplir le besoin, plutôt qu'un service web et la maintenance qui vient avec ?

En plus, maintenant je peux voir les images de mon blog même quand j'édite des articles dans le train, alors qu'avant une connexion à Internet était nécessaire pour pouvoir contacter GMG. Résilience maximale.

Je vous laisse méditer là dessus.

Bisous (KISSes).

Une question ou remarque ? N'hésitez pas à me contacter en envoyant un mail à microjoe, suivi d'un arobase, puis encore microjoe et enfin un « point org ».