<?php
/**
* UpdaterPlugin for phplist.
*
* This file is a part of UpdaterPlugin.
*
* This plugin is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This plugin is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* @category phplist
*
* @author Duncan Cameron
* @copyright 2023 Duncan Cameron
* @license http://www.gnu.org/licenses/gpl.html GNU General Public License, Version 3
*/
namespace phpList\plugin\UpdaterPlugin;
use Exception;
use FilesystemIterator;
use PharData;
use phpList\plugin\Common\Logger;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Symfony\Component\Filesystem\Filesystem;
use ZipArchive;
class MD5Exception extends Exception
{
}
class ZipExtractor
{
public function extract($file, $dir)
{
$zip = new ZipArchive();
if (true !== ($error = $zip->open($file))) {
throw new Exception(s('Unable to open zip file, %s', $error));
}
if (!$zip->extractTo($dir)) {
throw new Exception(s('Unable to extract zip file %s to %s', $file, $dir));
}
$zip->close();
}
}
class TgzExtractor
{
public function extract($file, $dir)
{
$phar = new PharData($file);
$phar->extractTo($dir);
}
}
class Updater
{
private $archiveFile;
private $archiveUrl;
private $basename;
private $distributionDir;
private $distributionArchive;
private $extractor;
private $logger;
private $md5Url;
private $timeout;
private $workDir;
public function __construct($version)
{
global $updaterConfig;
$this->basename = sprintf('phplist-%s', $version);
$archiveExtension = $updaterConfig['archive_extension'] ?? 'zip';
$this->extractor = $archiveExtension == 'tgz' ? new TgzExtractor() : new ZipExtractor();
$this->archiveFile = sprintf('%s.%s', $this->basename, $archiveExtension);
$urlTemplate = false === strpos($version, 'RC')
? 'https://sourceforge.net/projects/phplist/files/phplist/%s/%s/download'
: 'https://sourceforge.net/projects/phplist/files/phplist-development/%s/%s/download';
$this->archiveUrl = sprintf($urlTemplate, $version, $this->archiveFile);
$this->md5Url = sprintf($urlTemplate, $version, $this->basename . '.md5');
$this->workDir = $updaterConfig['work'];
$this->distributionDir = "$this->workDir/dist";
$this->distributionArchive = "$this->workDir/$this->archiveFile";
$this->logger = Logger::instance();
$this->timeout = $updaterConfig['timeout'] ?? 60;
if (isset($updaterConfig['memory_limit'])) {
$memoryLimit = $updaterConfig['memory_limit'];
$oldValue = ini_set('memory_limit', $memoryLimit);
if ($oldValue === false) {
$this->logger->debug('Unable to change memory_limit');
} else {
$this->logger->debug("Changed memory_limit from $oldValue to $memoryLimit");
}
}
if (isset($updaterConfig['max_execution_time'])) {
$maxExecutionTime = $updaterConfig['max_execution_time'];
$oldValue = ini_set('max_execution_time', $maxExecutionTime);
if ($oldValue === false) {
$this->logger->debug('Unable to change max_execution_time');
} else {
$this->logger->debug("Changed max_execution_time from $oldValue to $maxExecutionTime");
}
}
}
public function downloadZipFile()
{
$this->logger->debug("Fetching MD5 file $this->md5Url");
$md5Contents = fetchUrlDirect($this->md5Url);
if ($md5Contents != '') {
$filesMd5 = $this->parseMd5Contents($md5Contents);
$expectedMd5 = $filesMd5[$this->archiveFile];
// Use existing file if the MD5 is correct
if (file_exists($this->distributionArchive)) {
$actualMd5 = md5_file($this->distributionArchive);
$this->logger->debug(sprintf('Expected md5 %s actual md5 %s', $expectedMd5, $actualMd5));
if ($actualMd5 == $expectedMd5) {
$this->logger->debug(sprintf('Using existing archive file %s', $this->distributionArchive));
return;
}
}
}
$this->logger->debug(sprintf('Downloading %s', $this->archiveUrl));
$archiveContents = fetchUrlDirect($this->archiveUrl, ['timeout' => $this->timeout]);
if (!$archiveContents) {
throw new Exception(s('Download of %s failed', $this->archiveUrl));
}
$r = file_put_contents($this->distributionArchive, $archiveContents);
if (!$r) {
throw new Exception(s('Unable to save archive file %s', $this->distributionArchive));
}
$this->logger->debug('Stored download');
if ($md5Contents == '') {
throw new MD5Exception(s('Unable to verify MD5, file "%s" does not exist', $this->md5Url));
}
$actualMd5 = md5($archiveContents);
$this->logger->debug(sprintf('Expected md5 %s actual md5 %s', $expectedMd5, $actualMd5));
if ($actualMd5 != $expectedMd5) {
throw new MD5Exception(s('MD5 verification failed, expected %s actual %s', $expectedMd5, $actualMd5));
}
$this->logger->debug(sprintf('peak memory usage %s %s', formatBytes(memory_get_peak_usage()), formatBytes(memory_get_peak_usage(true))));
}
public function extractZipFile()
{
$fs = new Filesystem();
if (file_exists($this->distributionDir)) {
$fs->remove($this->distributionDir);
}
$this->logger->debug('Extracting archive');
$this->extractor->extract($this->distributionArchive, $this->distributionDir);
$this->logger->debug('Archive extracted');
$this->logger->debug(sprintf('peak memory usage %s %s', formatBytes(memory_get_peak_usage()), formatBytes(memory_get_peak_usage(true))));
}
public function replaceFiles($listsDir)
{
global $updaterConfig, $configfile;
$backupDir = sprintf('%s/phplist_backup_%s_%s', $this->workDir, VERSION, date('YmdHis'));
// find the "lists" directory within the distribution
$exists = false;
$it = new RecursiveDirectoryIterator(
$this->distributionDir,
FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS
);
foreach (new RecursiveIteratorIterator($it, RecursiveIteratorIterator::SELF_FIRST) as $path => $fileinfo) {
if ($fileinfo->isDir() && $fileinfo->getFileName() == 'lists') {
$distListsDir = $path;
$this->logger->debug("Using $distListsDir as lists directory");
$exists = true;
break;
}
}
if (!$exists) {
throw new Exception('Unable to find top level directory of distribution file');
}
// create set of specific files and directories to be copied from the backup
$additionalFiles = [];
if (realpath($configfile) == realpath("$listsDir/config/config.php")) {
// config file is in the default location, restore config.php and any additional files
$additionalFiles[] = 'config/config.php';
$additional = array_diff(scandir("$listsDir/config"), scandir("$distListsDir/config"));
foreach ($additional as $file) {
$additionalFiles[] = "config/$file";
}
}
if (PLUGIN_ROOTDIR == 'plugins' || realpath(PLUGIN_ROOTDIR) == realpath('plugins')) {
// plugins are in the default location, restore additional files and directories
$distPlugins = scandir("$distListsDir/admin/plugins");
$installedPlugins = scandir("$listsDir/admin/plugins");
$additional = array_diff($installedPlugins, $distPlugins);
foreach ($additional as $file) {
$additionalFiles[] = "admin/plugins/$file";
}
}
if (isset($updaterConfig['files'])) {
$additionalFiles = array_merge($additionalFiles, $updaterConfig['files']);
}
$fs = new Filesystem();
$fs->mkdir($backupDir, 0755);
// backup and move the files and directories in the distribution /lists directory
$doNotInstall = $updaterConfig['do_not_install'] ?? [];
foreach (scandir($distListsDir) as $file) {
if ($file == '.' || $file == '..') {
continue;
}
$sourceName = $distListsDir . '/' . $file;
$targetName = $listsDir . '/' . $file;
$backupName = $backupDir . '/' . $file;
if (file_exists($targetName)) {
$fs->rename($targetName, $backupName);
$this->logger->debug("Renamed $targetName");
}
if (in_array($file, $doNotInstall)) {
$this->logger->debug("Not installing $targetName");
} else {
$fs->rename($sourceName, $targetName);
$this->logger->debug("Installed $targetName");
}
}
// remove files and directories not be installed
foreach ($doNotInstall as $relativePath) {
$file = "$listsDir/$relativePath";
if (file_exists($file)) {
$fs->remove($file);
$this->logger->debug("Removed $relativePath");
}
}
// restore additional files and directories from the backup
foreach ($additionalFiles as $relativePath) {
$sourceName = "$backupDir/$relativePath";
$targetName = "$listsDir/$relativePath";
if (file_exists($sourceName)) {
if (is_dir($sourceName)) {
$fs->mkdir($targetName, 0755);
$fs->mirror($sourceName, $targetName, null, ['override' => true]);
} else {
$fs->copy($sourceName, $targetName, true);
}
$this->logger->debug("Restored $relativePath");
}
}
// tidy-up
$fs->remove($this->distributionDir);
$this->logger->debug('Deleted distribution directory');
if ($updaterConfig['delete_archive'] ?? true) {
$fs->remove($this->distributionArchive);
$this->logger->debug('Deleted distribution archive');
}
// purge old backups
if (isset($updaterConfig['keep_backups'])
&& is_int($updaterConfig['keep_backups'])
&& $updaterConfig['keep_backups'] > 0) {
$keep = $updaterConfig['keep_backups'];
$backups = array_filter(
scandir($this->workDir),
function ($file) {
return preg_match('/^phplist_backup_.+\d{14}$/', $file);
}
);
rsort($backups);
$this->logger->debug(print_r($backups, true));
if (count($backups) > $keep) {
foreach (array_slice($backups, $keep) as $backup) {
$backupPath = "$this->workDir/$backup";
$fs->remove($backupPath);
$this->logger->debug("Deleted backup $backupPath");
}
} else {
$this->logger->debug('No backups to delete');
}
}
$this->logger->debug(sprintf('peak memory usage %s %s', formatBytes(memory_get_peak_usage()), formatBytes(memory_get_peak_usage(true))));
}
private function parseMd5Contents($md5Contents)
{
$md5 = [];
foreach (explode("\n", trim($md5Contents)) as $line) {
list($hash, $file) = preg_split('/\s+/', $line);
$md5[$file] = $hash;
}
$this->logger->debug(print_r($md5, true));
return $md5;
}
}