PrestaShop : images produits et miniatures absentes après une migration

Vous migrez de serveur, ou vous montez de version, et voilà vos fiches produits qui s’ouvrent, vides de tout visuel. En base, le catalogue n’a rien perdu. Sur le disque, bien souvent, les fichiers n’ont pas bougé non plus. Et le navigateur, lui, ne reçoit rien. Ce décalage, neuf fois sur dix, se loge dans le chemin que PrestaShop calcule avant d’aller chercher le fichier. Je vous montre ma façon de le contrôler, table après table et dossier après dossier, aussi bien en 1.7 qu’en 8.x ou en 9.x.

Où PrestaShop va réellement chercher le fichier

C’est Image::getImgFolderStatic() qui fabrique le chemin à partir du seul identifiant de l’image, en éclatant bêtement les chiffres les uns après les autres, et la base, elle, n’en garde pas la moindre trace. Prenez l’image 345 : elle habite img/p/3/4/5/345.jpg. Juste à côté dorment les miniatures, suffixées du nom de format, du genre 345-home_default.jpg.

Et puis une bascule retourne tout, la clé PS_LEGACY_IMAGES côté configuration. Posée à 1, elle expédie getExistingImgPath() fouiller d’abord du côté d’un fichier à plat baptisé idProduit-idImage.jpg, déposé tout en haut de img/p. Migrez votre boutique en laissant l’option allumée alors qu’on a réorganisé les fichiers, et chaque recherche tombera à côté (pendant que vous cherchez l’explication ailleurs).

Quatre colonnes, pas une de plus, dans la table ps_image : identifiant, produit, position, couverture. Pas de nom de fichier là-dedans, pas d’extension, pas de chemin. Autant dire que je n’y traque jamais une colonne abîmée. Ce qui cloche se tient donc ailleurs, et il n’existe que trois endroits possibles : le chemin mal calculé, le disque lui-même, ou l’association à la boutique.

Droits et propriétaire du dossier img

Une archive décompressée ou un transfert FTP rétablissent rarement le propriétaire qu’il faut. Résultat, votre serveur web n’a plus le droit d’écrire dans img/p, et comme PrestaShop fabrique les miniatures manquantes au moment où on les demande, l’échec passe inaperçu. Rien dans les erreurs. Juste une image qui n’arrive pas. Cherchez donc sous quel utilisateur PHP s’exécute sur cette machine (rarement celui que vous imaginez).

Rien d’exotique dans les valeurs, du 755 côté répertoires et du 644 côté fichiers, seulement mon premier regard va au propriétaire plutôt qu’au mode. Détail à retenir : Image::createImgFolder() crée ses dossiers avec un 0775, ce qui oblige l’utilisateur PHP à faire partie du groupe propriétaire. Mélangez des dossiers nés sous root et d’autres nés sous le serveur web, vous obtenez une arborescence inscriptible à moitié (le scénario le plus pénible, puisque ça fonctionne à demi).

cd /var/www/boutique

# Qui fait tourner PHP sur cette machine ?
ps -eo user,comm | grep -E 'php-fpm|apache2|nginx' | sort -u

# Le serveur web doit pouvoir ecrire dans img : PrestaShop y cree
# les sous-dossiers et les miniatures a la volee.
chown -R www-data:www-data img
find img -type d -exec chmod 755 {} +
find img -type f -exec chmod 644 {} +

# Controle sur une arborescence produit : img/p/3/4/5/ pour id_image 345.
ls -la img/p/3/4/5/ 2>/dev/null | head

Dans le journal du serveur, je lis le 403 comme une affaire de droits et le 404 comme un fichier bel et bien absent (deux chantiers sans rapport).

Comparer la base et le disque

Deux tables commandent la visibilité d’une image. ps_image abrite l’enregistrement, pendant que ps_image_shop l’accroche à une boutique et porte l’indicateur de couverture. Qu’une ligne existe du premier côté sans exister du second, et votre image ne s’affichera jamais en front-office, pas même sur une installation mono-boutique. En général, j’y reconnais la signature d’un import inachevé ou d’une restauration menée table après table. C’est encore lui qui porte le mécanisme d’une image produit différente selon la catégorie.

Profitez-en pour ouvrir la table ps_image_type. Du coup, toute ligne portant 1 dans sa colonne products déclenche un fichier par image produit : dix mille images au catalogue et six formats déclarés, ça vous fait soixante mille miniatures (d’où ces régénérations qui ne se terminent jamais).

-- Volume a controler.
SELECT COUNT(*) AS images, COUNT(DISTINCT id_product) AS produits FROM ps_image;

-- Images presentes dans ps_image mais absentes de ps_image_shop :
-- le front-office ne les affichera sur aucune boutique.
SELECT i.id_image, i.id_product
FROM ps_image i
LEFT JOIN ps_image_shop ish ON ish.id_image = i.id_image
WHERE ish.id_image IS NULL
ORDER BY i.id_product;

-- Produits actifs sans image de couverture.
SELECT p.id_product, pl.name
FROM ps_product p
JOIN ps_product_lang pl
  ON pl.id_product = p.id_product AND pl.id_lang = 1 AND pl.id_shop = 1
LEFT JOIN ps_image_shop ish
  ON ish.id_product = p.id_product AND ish.id_shop = 1 AND ish.cover = 1
WHERE p.active = 1 AND ish.id_image IS NULL;

-- Formats declares : chaque ligne genere un fichier par image produit.
SELECT name, width, height FROM ps_image_type WHERE products = 1 ORDER BY width;

-- Ancien systeme de stockage a plat, a l'origine de chemins introuvables.
SELECT value FROM ps_configuration WHERE name = 'PS_LEGACY_IMAGES';

Ce que vous récupérez là, ce sont les anomalies de structure, relevées avant d’avoir posé un doigt sur les fichiers. Je les passe systématiquement en premier, et vous devriez en faire autant : réassocier une image en base coûte une requête, tandis qu’un fichier égaré vous envoie récupérer la source chez votre client ou au fond d’une archive de sauvegarde.

Contrôler les fichiers un par un

Confronter la base au disque en SQL, laissez tomber, puisque le chemin, c’est PHP qui le fabrique au moment du rendu et nulle part ailleurs. Il vous faut un utilitaire lancé depuis le terminal : il charge config/config.inc.php, déroule Image::getAllImages(), puis vérifie pour chacune que le fichier source est là et que les miniatures attendues le sont aussi.

Reconstituer le chemin à la main, très peu pour moi : getExistingImgPath() fait le travail, la bascule PS_LEGACY_IMAGES comprise, et vous restitue la route que votre boutique suit réellement. En sortie arrive un fichier séparé par des points-virgules, ouvrable tel quel dans un tableur, triable par produit en deux clics.

<?php
// verif-images.php, a deposer a la racine de la boutique.
// Lancement : php verif-images.php > controle-images.csv
require dirname(__FILE__) . '/config/config.inc.php';

$formats = ImageType::getImagesTypes('products');
$sourcesManquantes = 0;

echo "id_image;id_product;fichier;statut\n";

foreach (Image::getAllImages() as $ligne) {
    $image = new Image((int) $ligne['id_image']);

    // getExistingImgPath renvoie soit "12-345" (ancien systeme, option
    // PS_LEGACY_IMAGES), soit "3/4/5/345" (arborescence eclatee par chiffre).
    $base = _PS_PRODUCT_IMG_DIR_ . $image->getExistingImgPath();
    $source = $base . '.jpg';

    if (!file_exists($source) || !filesize($source)) {
        ++$sourcesManquantes;
        echo (int) $image->id . ';' . (int) $image->id_product . ';' . $source . ";source absente\n";
        continue;
    }

    foreach ($formats as $format) {
        $miniature = $base . '-' . $format['name'] . '.jpg';
        if (!file_exists($miniature)) {
            echo (int) $image->id . ';' . (int) $image->id_product . ';' . $miniature . ";miniature absente\n";
        }
    }
}

fwrite(STDERR, $sourcesManquantes . " fichier(s) source introuvable(s)\n");

Toute la suite, je l’appuie sur la distinction que pose ce script. Source manquante, et il faut repartir chercher l’original, soit dans une sauvegarde, soit du côté du client. Rien ne naît de rien. Miniature manquante, en revanche, et la source la reconstruit en quelques secondes. Saisissez l’occasion d’alléger les images sources du catalogue, parce que chacun des fichiers que la régénération produira ensuite portera exactement le poids que vous leur aurez laissé.

Régénérer les miniatures sans commande native

On écrit souvent le contraire, pourtant aucune commande bin/console livrée par PrestaShop ne régénère les miniatures. J’ai repris la liste entière des commandes fournies avec la 8.2.3 puis avec la 9.0.0, en les ouvrant toutes (une à une, sur chacune des deux branches). Elle ne se trouve que dans AdminImagesController, c’est-à-dire derrière le navigateur, avec l’expiration de délai que ça promet sitôt que le catalogue grossit.

Mon contournement tient dans un script qui rejoue la mécanique du contrôleur : pour chaque image, et pour chacun des formats marqués 1 dans la colonne products, un appel part vers ImageManager::resize(). Lancé depuis le terminal, aucune limite du serveur web ne l’arrête, et il peut travailler toute une nuit sans que personne le surveille (ayez tout de même sous la main de quoi le repartir).

Il reste un paramètre à surveiller. En sixième position, l’argument de resize() verrouille ce qui sortira. À false quand on veut du JPEG, il rend la main à la méthode, qui choisira elle-même le JPEG ou le PNG en fonction de la transparence qu’il trouve dans la source, tout comme le fait déjà le contrôleur natif.

<?php
// regen-miniatures.php, a deposer a la racine de la boutique.
// Lancement : php regen-miniatures.php delete=1
//
// Ni PrestaShop 8.2 ni PrestaShop 9.0 ne fournissent de commande bin/console
// pour cette tache : la regeneration ne vit que dans AdminImagesController.
require dirname(__FILE__) . '/config/config.inc.php';

@set_time_limit(0);
ini_set('memory_limit', '1024M');

$formats = ImageType::getImagesTypes('products');
$ecraser = (bool) Tools::getValue('delete');
$traitees = 0;
$erreurs = 0;

foreach (Image::getAllImages() as $ligne) {
    $image = new Image((int) $ligne['id_image']);
    $base = _PS_PRODUCT_IMG_DIR_ . $image->getExistingImgPath();
    $source = $base . '.jpg';

    if (!file_exists($source) || !filesize($source)) {
        ++$erreurs;
        echo 'Source absente : ' . $source . PHP_EOL;
        continue;
    }

    foreach ($formats as $format) {
        $cible = $base . '-' . $format['name'] . '.jpg';

        if ($ecraser && file_exists($cible)) {
            unlink($cible);
        }
        if (file_exists($cible)) {
            continue;
        }

        // ImageManager::resize choisit JPG ou PNG selon la transparence
        // de la source tant que le sixieme parametre reste a false.
        if (!ImageManager::resize($source, $cible, (int) $format['width'], (int) $format['height'], 'jpg')) {
            ++$erreurs;
            echo 'Echec de redimensionnement : ' . $cible . PHP_EOL;
        }
    }

    ++$traitees;
    if ($traitees % 200 === 0) {
        echo $traitees . ' images traitees' . PHP_EOL;
    }
}

echo $traitees . ' image(s) traitee(s), ' . $erreurs . ' erreur(s)' . PHP_EOL;
cd /var/www/boutique

# Aucune commande native ne regenere les miniatures : la liste le confirme.
php bin/console list prestashop

# Le script maison tourne hors du serveur web, donc sans timeout HTTP.
nohup php regen-miniatures.php delete=1 > /tmp/regen-miniatures.log 2>&1 &
tail -f /tmp/regen-miniatures.log

# Espace disque avant de lancer : chaque format declare produit un fichier.
du -sh img/p && df -h /var/www

Reconstruire l’arborescence img/p manquante

Interrompez un transfert en route, et voilà des dossiers intermédiaires qui manquent à l’appel. L’original dort peut-être quelque part dans une sauvegarde, seulement la régénération se casse la figure, privée du répertoire de destination où déposer son résultat. Or c’est exactement le travail de Image::createImgFolder(), qui monte l’arborescence en récursif, pose au passage les droits 0775 et recopie l’index.php qui traîne à la racine d’img/p.

Ce point-là compte plus qu’il n’y paraît. Si PrestaShop dissémine un index.php vide au fond de chaque dossier, c’est pour qu’Apache ne liste pas les répertoires : un script bricolé à la main qui s’arrête à un mkdir laisse tout ce que contient le dossier à la vue de tous (et ça finit toujours par vous retomber dessus).

<?php
// reconstruire-arborescence.php, a deposer a la racine de la boutique.
require dirname(__FILE__) . '/config/config.inc.php';

$crees = 0;
$echecs = 0;

foreach (Image::getAllImages() as $ligne) {
    $image = new Image((int) $ligne['id_image']);
    $dossier = _PS_PRODUCT_IMG_DIR_ . $image->getImgFolder();

    if (is_dir($dossier)) {
        continue;
    }

    // createImgFolder cree recursivement img/p/3/4/5/ avec les droits 0775
    // et y recopie l'index.php present a la racine de img/p.
    if ($image->createImgFolder()) {
        ++$crees;
    } else {
        ++$echecs;
        echo 'Creation impossible : ' . $dossier . PHP_EOL;
    }
}

echo $crees . ' dossier(s) cree(s), ' . $echecs . ' echec(s)' . PHP_EOL;

Des dossiers, rien que des dossiers : aucun fichier n’est déplacé par ce script. Il déblaie la place pour la régénération, à qui reviendra d’écrire les miniatures là où il faut. Si le chantier demande plus de temps que vous n’en avez, je prends le relais, la page contact existe pour ça.

Vérifier que la correction tient

Trois contrôles, et le sujet se referme. Le premier, relancer le diagnostic une fois la boutique corrigée : plus une ligne ne doit en sortir. Ensuite, gardez un œil sur le poids produit, parce qu’une régénération aux formats mal calibrés fait facilement doubler votre dossier img et alourdit l’affichage des pages catégories. Ces pages traînent encore alors que les visuels sont propres ? Je descends alors regarder les requêtes du filtre à facettes.

Mon troisième contrôle, je le réserve aux formats d’image récents. Depuis PrestaShop 8.1, une option que commandent la clé PS_IMAGE_FORMAT et un drapeau de fonctionnalité fait sortir de l’AVIF et du WebP à côté du JPEG. Avec l’option allumée, chacun de vos formats déclarés ressort en plusieurs fichiers, et le script qui n’en écrirait qu’un seul vous laisserait des trous un peu partout.

Une limite pour terminer. Rien de ce qui précède ne fait mieux que relever vos visuels en partant de ce qui subsiste. Sources envolées du disque et absentes de toute sauvegarde, rien ni personne ne les réinventera, et vous devrez les redemander une à une au client. Vous trouverez sur le blog d’autres arbitrages du même ordre, et cet outillage-là, je le construis dans ce que je propose en développement sur mesure.

Questions fréquentes

Pourquoi mes images produits ont-elles disparu après une migration ?

Parce qu’à chaque rendu, le chemin du fichier se recalcule. Image::getImgFolderStatic() découpe l’identifiant un chiffre après l’autre, si bien qu’on va chercher l’image 345 dans img/p/3/4/5/. Il suffit alors d’un transfert qui a rangé vos fichiers autre part, ou d’une option PS_LEGACY_IMAGES restée à 1 pendant que les fichiers changeaient de rangement, pour que tout s’évapore.

Deux autres causes, je les retrouve sans arrêt : des fichiers dont le propriétaire ne colle plus avec le serveur web, plus des lignes inscrites dans ps_image et introuvables dans ps_image_shop. Ce second cas fait disparaître vos images du front-office alors que, sur le disque, les fichiers sont tous à leur place.

Existe-t-il une commande CLI pour régénérer les miniatures ?

Non. J’ai épluché tout ce que livre src/PrestaShopBundle/Command, aussi bien en 8.2.3 qu’en 9.0.0, et pas une seule de ces commandes ne s’occupe des images. Celles qui existent visent les modules, les thèmes, la configuration, le débogage et les traductions (point final).

Nativement, la régénération ne se fait que depuis AdminImagesController, autrement dit depuis le navigateur, et elle reprend par lots sitôt que la durée d’exécution s’approche de la limite autorisée. Quand le catalogue devient gros, montez plutôt votre propre programme en ligne de commande, chargé d’appeler ImageManager::resize() format après format. Je n’ai rien trouvé de plus sûr.

Quelles permissions appliquer au dossier img ?

La référence ne bouge pas, 755 côté répertoires et 644 côté fichiers, sauf que le propriétaire l’emporte sur le mode. Votre dossier img doit appartenir au compte sous lequel PHP s’exécute, généralement www-data du côté de Debian et d’Ubuntu, parfois nginx, parfois encore un compte isolé quand vous êtes en mutualisé.

Un point à ne pas perdre de vue : les dossiers que taille Image::createImgFolder() sortent en 0775, d’où la nécessité d’un groupe partagé entre le compte PHP et le propriétaire des fichiers. Bâtissez votre arborescence moitié sous root et moitié sous le serveur web, vous récolterez des échecs d’écriture intermittents, du genre que personne ne reproduit jamais sur commande.

Comment corriger une arborescence de fichiers corrompue ?

Refaites les dossiers absents via Image::createImgFolder(), qui monte le chemin en entier avant d’y déposer l’index.php dont le rôle est d’empêcher qu’on liste le répertoire. Un mkdir solitaire oublie cette copie et expose tout ce que le dossier contient.

Le cœur vous tend Image::moveToNewFileSystem() pour quitter le vieux stockage à plat et rejoindre l’arborescence éclatée, méthode que le back-office range derrière son bouton de déplacement d’images. Comptez du temps, et sachez qu’une interruption laissera votre catalogue à cheval entre les deux. Prenez une sauvegarde du dossier img avant de cliquer.

Faut-il vider le cache après avoir restauré les images ?

Oui, mais probablement pas celui qui vous vient à l’esprit. Comme chaque rendu refabrique les chemins d’images, le cache applicatif n’y est presque jamais pour quelque chose. Ce qui s’accroche, c’est plutôt votre navigateur, et derrière lui un éventuel CDN ou un cache HTTP posté devant la boutique.

Chez PrestaShop, la purge qui sert vraiment vise les templates Smarty compilés, rangés dans var/cache/<environnement>/smarty. Testez une fois en navigation privée, une seconde fois depuis un autre accès réseau : vous verrez tout de suite si c’est un cache local ou un vrai souci côté serveur.

Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Un projet ? Parlons-en.

Devis gratuit et sans engagement. Je réponds dans les douze heures.