Prestashop 1.5 & PHP 7

On a tous un projet conçu sous PHP 5 et qui encore en production. Dans mon cas, c'est une boutique Prestashop version 1.5.5 qui fait le boulot depuis presque 10 ans maintenant et qui merriterai de passer sous PHP 7. A l'aide d'un outil d'analyse statique et de quelques tests "humains", on va essayer de faire tourner la boutique sous ce nouvel environnement.

La version 1.5.5 de Prestashop commence vraiment à dater (2012) et il n'est pas prévu de basculer sur une version supérieure car beaucoup de codes (modules/overrides/simplifications) a été développé spécifiquement pour cette boutique. On ne présente plus PHP 7 qui a clairement révolutionné le langage, mais au dela des performances, c'est surtout la sécurité et la possibilité de le faire tourner sur une configuration serveur standard qui me motive.

Avant toute chose, je conseille la lecture de la documentation PHP qui propose une section spécifique concernant la migration de la version 5.6 à 7 et qui fait un tour d'horizon des nouvelles/anciennes fonctionnalités.

Dans un premier temps, je vais utiliser des outils d'analyses statiques qui vont parser le code pour voir s'il y a des références à des fonctions dépréciées ou a une syntaxe (devenue) invalide.

Ensuite je vais simplement faire tourner la boutique sous PHP 7, tester quelques scénarios clients (commandes, envoi de mail, création de fichier PDF...) et voir un peu ce qu'il s'y passe.

Analyses statique du code avec PhpCodeFixer

L'analyse statique d'un programme permet d'identifier tout un tas de problèmes dans le code sans executer le programme. Ce qui m'interesses particulièrement, ça va être de trouver toutes les références à des fonctions PHP dépréciées sans m'amuser à tester chacune des fonctionnalités de la boutique. De plus, PHP appartient à la famille des languages interprétés, il ne subit pas d'étape de compilation, on ne se rend compte qu'à l'execution que le code n'est pas "valide".

Il existe beaucoup d'outils dans l'univers PHP qui ont étés développés pour faire de l'analyse statique du code. Mon choix s'est porté sur PhpCodeFixer qui semble être un projet :

Pour l'installation, il est possible de récupérer le binaire (.phar), de récupérer les sources (composer) ou comme moi de passer par l'excellente image Docker jakzal/phpqa qui est une toolbox très fournie pour PHP.

Ensuite, on lance l'analyse sur le dossier entier de Prestashop pour découvrir l'étendu des dégats (paramètre -e pour exclure certains dossiers et -s 10mb pour repousser la limite de taille des fichiers.

max@laptop# phpcf -t 7.3 . -s 10mb -e ./cache,./data,./download,./files,./img,./js,./log,./upload,.composer

Il y'a presque 200 erreurs remontées, pour ségmenter et prioriser le travail, ma stratégie est de lancer l'analyse selon l'architecture de Prestashop :

Gros bémol, Prestashop se base sur le moteur smarty, qui certe semble compatible PHP 7 mais les fichiers de templates ne sont pas analysable alors qu'ils peuvent embarquer du code PHP.

Les erreurs

Je vais présenter les erreurs les plus communes ou les plus interessantes que j'ai rencontré.

Mysql

Evidemment, je m'attendais à avoir un paquet d'erreur dû à l'extension mysql (classes/db/MySQL.php) obsolète depuis PHP 7.0. C'est pas vraiment un problème car la boutique utilise le driver pdo (classes/db/DbPDO.php), ce code là ne sera jamais executé. Par contre je ne m'attendais pas à avoir autant de références à cette bibliothèque dans les modules et autres outils utilisés par Prestashop.

each()

La fonction each a été déprécié depuis PHP 7.2, elle est souvent associée à la fonction list dans un while pour faire une itération. On est là sur une vieille technique de parcours de tableau avant que le foreach ne devienne la norme.
Ces nombreux appels (près d'une vingtaine) sont facile à remplacer par un foreach, attention cependant à certains parcours qui modifient les données (utiliser alors le & pour un passage par référence) ou qui déplacent le pointeur sur le tableau.

Exemple de réécriture de la fonction each
while (list($key, $values) = each($input)) {
  // ...
}

foreach($input as $key => &$values) {
  // ...
}

mcrypt

La librairie mcrypt est utilisée dans le coeur de Prestashop (classe Rijndael.php) pour tout ce qui est opération chiffrement/déchiffrement et plus spécifiquement pour stoker le mot de passe des utilisateurs dans la base de données ou encore les cookies de sessions.

Classe classes/Rijndal.php
public function encrypt($plaintext) {
  $length = strlen($plaintext);
  if ($length >= 1048576) {
    return false;
  }
  return base64_encode(mcrypt_encrypt(MCRYPT_RIJNDAEL_128, $this->_key, $plaintext, MCRYPT_MODE_ECB, $this->_iv)).sprintf('%06d', $length);
}

public function decrypt($ciphertext) {
  $length = intval(substr($ciphertext, -6));
  $ciphertext = substr($ciphertext, 0, -6);
  return substr(mcrypt_decrypt(MCRYPT_RIJNDAEL_128, $this->_key, base64_decode($ciphertext), MCRYPT_MODE_ECB, $this->_iv), 0, $length);
}

On remarque alors :

Je souhaite réécrire cette classe pour utiliser la librairie standard openssl. C'est la petite partie retro-ingénierie de ce billet par ce que j'ai du faire quelques tests avant de comprendre :

Classe classes/Rijndal.php
public const OPEN_SSL_CIFER_EQUIVALENT_MCRYPT_RIJNDAEL_128_ECB = 'aes-256-ecb';

public function encrypt_openssl($plaintext) {
  $iv = "";   
  $length = strlen($plaintext);
  $encryptedMessage = openssl_encrypt($this->pad_zero($plaintext), self::OPEN_SSL_CIFER_EQUIVALENT_MCRYPT_RIJNDAEL_128_ECB, $this->_key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING , $iv);
  return base64_encode($encryptedMessage).sprintf('%06d', $length);
}
  
public function decrypt_openssl($ciphertext) {
  $iv = "";
  $length = intval(substr($ciphertext, -6));
  $ciphertext = substr($ciphertext, 0, -6);
  $raw = base64_decode($ciphertext);
  return openssl_decrypt($raw, self::OPEN_SSL_CIFER_EQUIVALENT_MCRYPT_RIJNDAEL_128_ECB, $this->_key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
  }

protected function pad_zero($data) {
  $len = 16;
  if (strlen($data) % $len) {
    $padLength = $len - strlen($data) % $len;
    $data .= str_repeat("\0", $padLength);
  }
  return $data;
}

On retrouve également un ensemble de référence à mcrypt dans le AdminPerformancesController.php, en effet on peut basculer sur une implémentation via la classe locale BlowFish (ce qui regnénérerait les cookies), j'ai personnellement désactivé cette option qui n'a pas vocation à être utilisée.

Visiblement TCPDF utilise aussi mcrypt dans des fonctions internes (_RCA & _AES) qui ne sont pas utilisé dans la génération PDF.

create_function

Il y a de nombreuse références à la fonction create_function. Elle se base sur eval pour créer des fonctions dynamiques à partir d'une chaine de caractères et sont souvent utilisés pour des opérations sur des tableaux, expressions régulières ou opérations de tris.
C'est clairement une pratique à éviter et qui a pour le coup était dépréciée depuis PHP 7.3.
On peut donc réécrire le code en remplaçant par une fonction lambda (anonyme) ou tout autre callable.

Exemple de réécriture de la fonction create_function
// classes/helper/HelperList.php
$positions = array_map(create_function('$elem', 'return (int)($elem[\'position\']);'), $this->_list);

$positions = array_map(function($elem) {
  return (int)($elem['position']);
}, $this->_list);
  

// tools/tcpdf/tcpdf.php
$pmid = preg_replace_callback("/\[\(([^\)]*)\)\]/x",
  create_function('$matches', 'global $spacew;
  $matches[1] = str_replace("#!#OP#!#", "(", $matches[1]);
  $matches[1] = str_replace("#!#CP#!#", ")", $matches[1]);
  return "[(".str_replace(chr(0).chr(32), ") ".sprintf("%F", $spacew)." (", $matches[1]).")]";'), $pmidtemp);*/

$pmid = preg_replace_callback("/\[\(([^\)]*)\)\]/x",
  function($matches) use($spacew) {
    $matches[1] = str_replace("#!#OP#!#", "(", $matches[1]);
    $matches[1] = str_replace("#!#CP#!#", ")", $matches[1]);
    return "[(".str_replace(chr(0).chr(32), ") ".sprintf("%F", $spacew)." (", $matches[1]).")]";
  }, $pmidtemp);
}

Constructeurs PHP 4

On retrouve la trace de quelques classes utilisant la vieille syntaxe pour déclarer les constructeurs, rien de bien sorcier là dedans, il suffit de remplacer par le nouveau formalisme __construct().

Éxecution en environnement réél PHP 7.3

Conscient des limites de mon analyse statique, je m'attends à avoir quelques désagréments en situation réélle. Je prévois de réaliser quelques scénarios clients basiques : navigation, page produit, tunnel de paiement, mails, et back office.

DbQueryCore

Cette classe est centrale pour Prestashop et offre la couche d'abstraction base de données pour permettre de forger des requêtes.
Visiblement, il y a un problème de déclaration/initialisation avec le tableau $query et plus spécifiquement l'index from qui est initialisé comme une chaine de caractère alors quelle est utilisé plus tard comme un tableau et PHP 7 n'aime pas ça.

Fichier de classe classes/db/DbQuery.php
protected $query = array(
  'select' => array(),
  // 'from' =>   '',
  'from' =>   array(),
  'join' =>   array(),
  // ...
 );

Count() sur un peu n'importe quoi

Depuis PHP 7 la fonction count retourne un avertissement lorsque elle est executé sur autre chose qu'un tableau.
À plusieurs endroits dans Prestashop (controlleurs/classes/modules), cette fonction est très souvent utilisée sur des variables non initialisées pour tester si un tableau possède des éléments.

Example d'utilisation de la fonction count sur une variable non initialisée.
if(count($this->errors)) {
  // traitement en cas d'erreur
} else {
  // traitement en cas de succès
}

Il est plus facile dans ce genre de cas de remplacer le count par !empty que de s'amuser à chercher à initialiser la variable. En effet, empty retourne vrai si le tableau est vide ou si la variable est NULL, 0, 0.0, "" (chaine vide).

Exemple de réécriture de la fonction count par empty.
if(!empty($this->errors)) {
  // traitement en cas d'erreur
} else {
  // traitement en cas de succès
}

Archive Tar & func_get_args

J'ai reperé une évolution sur la manière d'appelé la function func_get_args en faisant un tour dans la page de gestion des modules du back office Prestashop. Cette fonction est visiblement mal appelé dans la classe Archive_Tar (tools/tar/Archive_Tar.php), en PHP 7, il n'est plus nécessaire de mettre le & devant la fonction.

Conclusions

Après quelques jours de travail à corriger le code et à écrire cette article (car je fais les deux en même temps), je pense que les erreurs rencontrées en environnement réélles montre la limite de mon analyse statique. J'aurais pû la compléter avec d'autres outils bien plus complexe comme PHPStan ou phan ce qui m'aurait potentiellement permis de lever certaines erreurs supplémentaire.

Toutefois, je préfère utiliser ce temps pour mettre un place une couche de tests ciblés sur les principaux scénarios de la boutique. En tout cas, la boutique semble fonctionnelle sous PHP 7, je me laisse encore un peu de temps pour tester plus profondément mais il faudra bien à un moment pousser les corrections en production.

Retour aux billets Billet plus ancien : Développement de ce site : Étape 2 - Le rendu des pages avec le micro-framework Plates PHP Billet plus récent : Développement de ce site : Étape 3 - Analyse des URLs et Multi-pages