*/ class UpdateException extends \Exception { } class updater { /** @var bool */ private $availableUpdate = false; const DOWNLOAD_PATH = '../tmp_uploaded_update'; const ELIGIBLE_SESSION_KEY = 'phplist_updater_eligible'; const CONFIG_FILE = __DIR__ . '/../config/config.php'; private $excludedFiles = array( 'dl.php', 'index.php', 'index.html', 'lt.php', 'ut.php', 'api.php', ); public function isAuthenticated() { session_start(); if (isset($_SESSION[self::ELIGIBLE_SESSION_KEY]) && $_SESSION[self::ELIGIBLE_SESSION_KEY] === true) { return true; } return false; } public function deauthUpdaterSession() { unset($_SESSION[self::ELIGIBLE_SESSION_KEY]); unlink(__DIR__ . '/../config/actions.txt'); } /** * Return true if there is an update available * @return bool */ public function availableUpdate() { return $this->availableUpdate; } /** * Returns current version of phpList. * * @return string * @throws UpdateException */ public function getCurrentVersion() { $version = file_get_contents('../admin/init.php'); $matches = array(); preg_match_all('/define\(\"VERSION\",\"(.*)\"\);/', $version, $matches); if (isset($matches[1][0])) { return $matches[1][0]; } throw new UpdateException('No production version found.'); } /** * Checks if there is an Update Available * @return string * @throws \Exception */ function checkIfThereIsAnUpdate() { $serverResponse = $this->getResponseFromServer(); $version = isset($serverResponse['version']) ? $serverResponse['version'] : ''; $versionString = isset($serverResponse['versionstring']) ? $serverResponse['versionstring'] : ''; if ($version !== '' && $version !== $this->getCurrentVersion() && version_compare($this->getCurrentVersion(), $version)) { $this->availableUpdate = true; $updateMessage = 'Update to ' . htmlentities($versionString) . ' is available. '; } else { $updateMessage = 'phpList is up-to-date.'; } if ($this->availableUpdate && isset($serverResponse['autoupdater']) && !($serverResponse['autoupdater'] === 1 || $serverResponse['autoupdater'] === '1')) { $this->availableUpdate = false; $updateMessage .= '
The automatic updater is disabled for this update.'; } return $updateMessage; } /** * Return version data from server * @return array * @throws \Exception */ private function getResponseFromServer() { $serverUrl = "https://download.phplist.org/version.json"; $updateUrl = $serverUrl . '?version=' . $this->getCurrentVersion(); // create a new cURL resource $ch = curl_init(); // set URL and other appropriate options // Disable SSL verification curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Will return the response, if false it print the response curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Set the url curl_setopt($ch, CURLOPT_URL, $updateUrl); // Execute $responseFromServer = curl_exec($ch); // Closing curl_close($ch); // decode json $responseFromServer = json_decode($responseFromServer, true); return $responseFromServer; } private function getDownloadUrl() { // todo: error handling $response = $this->getResponseFromServer(); if (isset($response['url'])) { return $response['url']; } // todo error handling } /** * Checks write permissions and returns files that are not writable * @return array */ function checkWritePermissions() { $directory = new \RecursiveDirectoryIterator(__DIR__ . '/../', \RecursiveDirectoryIterator::SKIP_DOTS); // Exclude dot files /** @var SplFileInfo[] $iterator */ $iterator = new \RecursiveIteratorIterator($directory, RecursiveIteratorIterator::CHILD_FIRST); $files = array(); foreach ($iterator as $info) { if (!is_writable($info->getRealPath())) { $files[] = $info->getRealPath(); } } return $files; } /** * @return array */ function checkRequiredFiles() { $expectedFiles = array( '.' => 1, '..' => 1, 'admin' => 1, 'config' => 1, 'images' => 1, 'js' => 1, 'styles' => 1, 'texts' => 1, '.htaccess' => 1, 'dl.php' => 1, 'index.html' => 1, 'index.php' => 1, 'lt.php' => 1, 'ut.php' => 1, 'updater' => 1, 'base' => 1, 'api.php' =>1, ); $existingFiles = scandir(__DIR__ . '/../'); foreach ($existingFiles as $fileName) { if (isset($expectedFiles[$fileName])) { unset($expectedFiles[$fileName]); } else { $expectedFiles[$fileName] = 1; } } return $expectedFiles; } /** * * Recursively delete a directory and all of it's contents * * @param string $dir absolute path to directory to delete * @return bool * @throws UpdateException */ private function rmdir_recursive($dir) { if (false === file_exists($dir)) { throw new \UpdateException("$dir doesn't exist."); } /** @var SplFileInfo[] $files */ $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($files as $fileinfo) { if ($fileinfo->isDir()) { if (false === rmdir($fileinfo->getRealPath())) { throw new \UpdateException("Could not delete $fileinfo"); } } else { if (false === unlink($fileinfo->getRealPath())) { throw new \UpdateException("Could not delete $fileinfo"); } } } return rmdir($dir); } /** * Delete dirs/files except config and other files that we want to keep * @throws UpdateException */ function deleteFiles() { $excludedFolders = array( 'config', 'tmp_uploaded_update', 'updater', '.', '..', ); $filesTodelete = scandir(__DIR__ . '/../'); foreach ($filesTodelete as $fileName) { $absolutePath = __DIR__ . '/../' . $fileName; $is_dir = false; if (is_dir($absolutePath)) { $is_dir = true; if (in_array($fileName, $excludedFolders)) { continue; } } else if (is_file($absolutePath)) { if (in_array($fileName, $this->excludedFiles)) { continue; } } if ($is_dir) { $this->rmdir_recursive($absolutePath); } else { unlink($absolutePath); } } } /** * Get a PDO connection * @return PDO * @throws UpdateException */ function getConnection() { $standardConfig = self::CONFIG_FILE; if (isset($_SERVER['ConfigFile']) && is_file($_SERVER['ConfigFile'])) { include $_SERVER['ConfigFile']; } elseif (file_exists($standardConfig)) { include $standardConfig; } else { throw new \UpdateException("Error: Cannot find config file"); } $charset = 'utf8mb4'; /** @var string $database_host * @var string $database_name * @var string $database_user * @var string $database_password */ $dsn = "mysql:host=$database_host;dbname=$database_name;charset=$charset"; $options = array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ); try { $pdo = new PDO($dsn, $database_user, $database_password, $options); } catch (\PDOException $e) { throw new \PDOException($e->getMessage(), (int)$e->getCode()); } return $pdo; } /** *Set the maintenance mode * @return bool true - maintenance mode is set; false - maintenance mode could not be set because an update is already running * @throws UpdateException */ function addMaintenanceMode() { $standardConfig = self::CONFIG_FILE; if (isset($_SERVER['ConfigFile']) && is_file($_SERVER['ConfigFile'])) { include $_SERVER['ConfigFile']; } elseif (file_exists($standardConfig)) { include $standardConfig; } else { throw new \UpdateException("Error: Cannot find config file"); } if (isset($table_prefix)) { $table_name = $table_prefix . 'config'; } else { $table_name = 'phplist_config'; } $prepStmt = $this->getConnection()->prepare("SELECT * FROM {$table_name} WHERE item=?"); $prepStmt->execute(array('update_in_progress')); $result = $prepStmt->fetch(PDO::FETCH_ASSOC); if ($result === false) { // the row does not exist => no update running $this->getConnection() ->prepare("INSERT INTO {$table_name}(`item`,`editable`,`value`) VALUES (?,0,?)") ->execute(array('update_in_progress', 1)); } if ($result['update_in_progress'] == 0) { $this->getConnection() ->prepare("UPDATE {$table_name} SET `value`=? WHERE `item`=?") ->execute(array(1, 'update_in_progress')); } else { // the row exists and is not 0 => there is an update running return false; } $name = 'maintenancemode'; $value = "Update process"; $sql = "UPDATE {$table_name} SET value =?, editable =? where item =? "; $this->getConnection()->prepare($sql)->execute(array($value, 0, $name)); } /** *Clear the maintenance mode and remove the update_in_progress lock * @throws UpdateException */ function removeMaintenanceMode() { $standardConfig = self::CONFIG_FILE; if (isset($_SERVER['ConfigFile']) && is_file($_SERVER['ConfigFile'])) { include $_SERVER['ConfigFile']; } elseif (file_exists($standardConfig)) { include $standardConfig; } else { throw new \UpdateException("Error: Cannot find config file"); } if (isset($table_prefix)) { $table_name = $table_prefix . 'config'; } else { $table_name = 'phplist_config'; } $name = 'maintenancemode'; $value = ''; $sql = "UPDATE {$table_name} SET value =?, editable =? where item =? "; $this->getConnection()->prepare($sql)->execute(array($value, 0, $name)); $this->getConnection() ->prepare("UPDATE {$table_name} SET `value`=? WHERE `item`=?") ->execute(array(0, "update_in_progress")); } /** * Download and unzip phpList from remote server * * @throws UpdateException */ function downloadUpdate() { /** @var string $url */ $url = $this->getDownloadUrl(); $zipFile = tempnam(sys_get_temp_dir(), 'phplist-update'); if ($zipFile === false) { throw new UpdateException("Error: Temporary file cannot be created"); } // Get The Zip File From Server $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_FAILONERROR, true); curl_setopt($ch, CURLOPT_HEADER, 0); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_AUTOREFERER, true); curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); curl_setopt($ch, CURLOPT_FILE, fopen($zipFile, 'w+')); $page = curl_exec($ch); if (!$page) { echo "Error :- " . curl_error($ch); } curl_close($ch); // extract files $this->unZipFiles($zipFile, self::DOWNLOAD_PATH); } /** * Creates temporary dir * @throws UpdateException */ function temp_dir() { $tempdir = mkdir(self::DOWNLOAD_PATH, 0700); if ($tempdir === false) { throw new UpdateException("Error: Could not create temporary file"); } } function cleanUp() { if (function_exists('opcache_reset')) { opcache_reset(); } } /** * @throws UpdateException */ function replacePHPEntryPoints() { $entryPoints = array( 'dl.php', 'index.html', 'index.php', 'lt.php', 'ut.php', ); foreach ($entryPoints as $key => $fileName) { $current = "Update in progress \n"; $content = file_put_contents(__DIR__ . '/../' . $fileName, $current); if ($content === FALSE) { throw new UpdateException("Error: Could not write to the $fileName"); } } } /** * Returns true if the file/dir is excluded otherwise false. * @param $file * @return bool */ function isExcluded($file) { $excludedFolders = array( 'config', 'tmp_uploaded_update', 'updater', '.', '..', ); if (in_array($file, $excludedFolders)) { return true; } else if (in_array($file, $this->excludedFiles)) { return true; } return false; } /** * Move new files in place. * @throws UpdateException */ function moveNewFiles() { $rootDir = __DIR__ . '/../tmp_uploaded_update/phplist/public_html/lists'; $downloadedFiles = scandir($rootDir); if (count($downloadedFiles) <= 2) { throw new UpdateException("Error: Download folder is empty!"); } foreach ($downloadedFiles as $fileName) { if ($this->isExcluded($fileName)) { continue; } $oldFile = $rootDir . '/' . $fileName; $newFile = __DIR__ . '/../' . $fileName; $state = rename($oldFile, $newFile); if ($state === false) { throw new UpdateException("Error: Could not move new files"); } } } /** * Move entry points in place. */ function moveEntryPHPpoints() { $rootDir = __DIR__ . '/../tmp_uploaded_update/phplist/public_html/lists'; $downloadedFiles = scandir($rootDir); foreach ($downloadedFiles as $filename) { $oldFile = $rootDir . '/' . $filename; $newFile = __DIR__ . '/../' . $filename; if (in_array($filename, $this->excludedFiles)) { rename($oldFile, $newFile); } } } /** * Back up old files to the location specified by the user. * @param $destination 'path' to backup zip * @throws UpdateException */ function backUpFiles($destination) { $iterator = new \RecursiveDirectoryIterator(realpath(__DIR__ . '/../'), FilesystemIterator::SKIP_DOTS); /** @var SplFileInfo[] $iterator */ /** @var $iterator */ $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::SELF_FIRST); $zip = new ZipArchive(); $resZip = $zip->open($destination, ZipArchive::CREATE); if ($resZip === false) { throw new \UpdateException("Error: Could not create back up of phpList directory. Please make sure that the argument is valid or writable and try again without reloading the page."); } $zip->addEmptyDir('lists'); foreach ($iterator as $file) { $prefix = realpath(__DIR__ . '/../'); $name = 'lists/' . substr($file->getRealPath(), strlen($prefix) + 1); if ($file->isDir()) { $zip->addEmptyDir($name); continue; } if ($file->isFile()) { $zip->addFromString($name, file_get_contents($file->getRealPath())); continue; } } $state = $zip->close(); if ($state === false) { throw new UpdateException('Error: Could not create back up of phpList directory. Please make sure that the argument is valid or is writable and try again without reloading the page.'); } } /** * Extract Zip Files * @param string $toBeExtracted * @param string $extractPath * @throws UpdateException */ function unZipFiles($toBeExtracted, $extractPath) { $zip = new ZipArchive; /* Open the Zip file */ if ($zip->open($toBeExtracted) !== true) { throw new \UpdateException("Error: Unable to open the Zip File"); } /* Extract Zip File */ $zip->extractTo($extractPath); $zip->close(); } /** * Delete temporary downloaded files * @throws UpdateException */ function deleteTemporaryFiles() { $isTempDirDeleted = $this->rmdir_recursive(self::DOWNLOAD_PATH); if ($isTempDirDeleted === false) { throw new \UpdateException("Error: Could not delete temporary files!"); } } /** * @throws UpdateException */ function recoverFiles() { $this->unZipFiles('backup.zip', self::DOWNLOAD_PATH); } /** * @param int $action * @throws UpdateException */ function writeActions($action) { $actionsdir = __DIR__ . '/../config/actions.txt'; if (!file_exists($actionsdir)) { $actionsFile = fopen($actionsdir, "w+"); if ($actionsFile === false) { throw new \UpdateException("Error: Could not create actions file in the config directory, please change permissions"); } } $written = file_put_contents($actionsdir, json_encode(array('continue' => false, 'step' => $action))); if ($written === false) { throw new \UpdateException("Error: Could not write on $actionsdir"); } } /** * Return the current step * @return mixed array of json data * @throws UpdateException */ function currentUpdateStep() { $actionsdir = __DIR__ . '/../config/actions.txt'; if (file_exists($actionsdir)) { $status = file_get_contents($actionsdir); if ($status === false) { throw new \UpdateException("Cannot read content from $actionsdir"); } $decodedJson = json_decode($status, true); if (!is_array($decodedJson)) { throw new \UpdateException('JSON data cannot be decoded!'); } } else { return array('step' => 0, 'continue' => true); } return $decodedJson; } /** * Check if config folder is writable. Required to be writable in order to write steps. */ function checkConfig() { $configdir = __DIR__ . '/../config/'; if (!is_dir($configdir) || !is_writable($configdir)) { die("Cannot update because config directory is not writable."); } } /** * Check if required php modules are installed. */ function checkphpmodules() { $phpmodules = array('curl', 'pdo', 'zip'); $notinstalled = array(); foreach ($phpmodules as $value) { if (!extension_loaded($value)) { array_push($notinstalled, $value); } } if (count($notinstalled) > 0) { $message = "The following php modules are required. Please install them to continue." . '
'; foreach ($notinstalled as $value) { $message .= $value . '
'; } die($message); } } /** * Move plugins in temporary folder to prevent them from being overwritten. * @throws UpdateException */ function movePluginsInTempFolder() { $oldDir = __DIR__ . '/../admin/plugins'; $newDir = __DIR__ . '/../tmp_uploaded_update/tempplugins'; $state = rename($oldDir, $newDir); if ($state === false) { throw new UpdateException("Could not move plugins directory"); } } /** * Move plugins back in admin directory. * @throws UpdateException */ function movePluginsInPlace() { $oldDir = realpath(__DIR__ . '/../tmp_uploaded_update/tempplugins'); $newDir = realpath(__DIR__ . '/../admin/plugins'); $this->rmdir_recursive($newDir); $state = rename($oldDir, $newDir); if ($state === false) { throw new UpdateException("Could not move plugins directory to admin folder."); } } /** * Update updater to a new location before temp folder is deleted! * @throws UpdateException */ function moveUpdater() { $rootDir = __DIR__ . '/../tmp_uploaded_update/phplist/public_html/lists'; $oldFile = $rootDir . '/updater'; $newFile = __DIR__ . '/../tempupdater'; $state = rename($oldFile, $newFile); if ($state === false) { throw new UpdateException("Could not move updater"); } } /** * Replace new updater as the final step * @throws UpdateException */ function replaceNewUpdater() { $newUpdater = realpath(__DIR__ . '/../tempupdater'); $oldUpdater = realpath(__DIR__ . '/../updater'); $this->rmdir_recursive($oldUpdater); $state = rename($newUpdater, $oldUpdater); if ($state === false) { throw new UpdateException("Could not move the new updater in place"); } } } try { $update = new updater(); if (!$update->isAuthenticated()) { die('No permission to access updater.'); } $update->checkConfig(); $update->checkphpmodules(); } catch (\UpdateException $e) { throw $e; } /** * * * */ if (isset($_POST['action'])) { set_time_limit(0); //ensure that $action is integer $action = (int)$_POST['action']; header('Content-Type: application/json'); $writeStep = true; switch ($action) { case 0: $statusJson = $update->currentUpdateStep(); echo json_encode(array('status' => $statusJson, 'autocontinue' => true)); break; case 1: $currentVersion = $update->getCurrentVersion(); $updateMessage = $update->checkIfThereIsAnUpdate(); $isThereAnUpdate = $update->availableUpdate(); if ($isThereAnUpdate === false) { echo(json_encode(array('continue' => false, 'response' => $updateMessage))); } else { echo(json_encode(array('continue' => true, 'response' => $updateMessage))); } break; case 2: echo(json_encode(array('continue' => true, 'autocontinue' => true, 'response' => 'Starting integrity check'))); break; case 3: $unexpectedFiles = $update->checkRequiredFiles(); if (count($unexpectedFiles) !== 0) { $elements = "Error: The following files are not expected or required. To continue please move or delete them. \n";; foreach ($unexpectedFiles as $key => $fileName) { $elements .= $key . "\n"; } echo(json_encode(array('retry' => true, 'continue' => false, 'response' => $elements))); } else { echo(json_encode(array('continue' => true, 'response' => 'Integrity check successful', 'autocontinue' => true))); } break; case 4: $notWriteableFiles = $update->checkWritePermissions(); if (count($notWriteableFiles) !== 0) { $notWriteableElements = "Error: No write permission for the following files: \n";; foreach ($notWriteableFiles as $key => $fileName) { $notWriteableElements .= $fileName . "\n"; } echo(json_encode(array('retry' => true, 'continue' => false, 'response' => $notWriteableElements))); } else { echo(json_encode(array('continue' => true, 'response' => 'Write check successful.', 'autocontinue' => true))); } break; case 5: echo(json_encode(array('continue' => true, 'response' => 'Do you want a backup?
Yes
No
'))); break; case 6: $createBackup = $_POST['create_backup']; if ($createBackup === 'true') { echo(json_encode(array('continue' => true, 'response' => 'Choose location where to backup the /lists directory. Please make sure to choose a location outside the web root:
'))); } else { echo(json_encode(array('continue' => true, 'response' => '', 'autocontinue' => true))); } break; case 7: $createBackup = $_POST['create_backup']; if ($createBackup === 'true') { $backupLocation = realpath(dirname($_POST['backup_location'])); $phplistRootFolder = realpath(__DIR__ . '/../../'); if (strpos($backupLocation, $phplistRootFolder) === 0) { echo(json_encode(array('retry' => true, 'continue' => false, 'response' => 'Error: Please choose a folder outside of your phpList installation.'))); break; } if (!preg_match("/^.*\.(zip)$/i", $_POST['backup_location'])) { echo(json_encode(array('retry' => true, 'continue' => false, 'response' => 'Error: Please add .zip extension.'))); break; } try { $update->backUpFiles($_POST['backup_location']); echo(json_encode(array('continue' => true, 'response' => 'Backup has been created'))); } catch (\Exception $e) { echo(json_encode(array('retry' => true, 'continue' => false, 'response' => $e->getMessage()))); break; } } else { echo(json_encode(array('continue' => true, 'response' => 'No back up created', 'autocontinue' => true))); } break; case 8: echo(json_encode(array('continue' => true, 'autocontinue' => true, 'response' => 'Download in progress'))); break; case 9: try { $update->downloadUpdate(); echo(json_encode(array('continue' => true, 'response' => 'The update has been downloaded!'))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 10: $on = $update->addMaintenanceMode(); if ($on === false) { echo(json_encode(array('continue' => false, 'response' => 'Cannot set the maintenance mode on!'))); } else { echo(json_encode(array('continue' => true, 'response' => 'Set maintenance mode on', 'autocontinue' => true))); } break; case 11: try { $update->replacePHPEntryPoints(); echo(json_encode(array('continue' => true, 'response' => 'Replaced entry points', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 12: try { $update->movePluginsInTempFolder(); echo(json_encode(array('continue' => true, 'response' => 'Backing up the plugins', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 13: try { $update->deleteFiles(); echo(json_encode(array('continue' => true, 'response' => 'Old files have been deleted!', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 14: try { $update->moveNewFiles(); echo(json_encode(array('continue' => true, 'response' => 'Moved new files in place!', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 15: try { $update->movePluginsInPlace(); echo(json_encode(array('continue' => true, 'response' => 'Moved plugins in place!', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 16: try { $update->moveEntryPHPpoints(); echo(json_encode(array('continue' => true, 'response' => 'Moved new entry points in place!', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 17: try { $update->moveUpdater(); echo(json_encode(array('continue' => true, 'response' => 'Moved new entry points in place!', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 18: try { $update->deleteTemporaryFiles(); echo(json_encode(array('continue' => true, 'response' => 'Deleted temporary files!', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 19: try { $update->removeMaintenanceMode(); echo(json_encode(array('continue' => true, 'response' => 'Removed maintenance mode', 'autocontinue' => true))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; case 20: $writeStep = false; try { $update->replaceNewUpdater(); $update->deauthUpdaterSession(); echo(json_encode(array('continue' => true, 'nextUrl' => '../admin/', 'response' => 'Updated successfully.'))); } catch (\Exception $e) { echo(json_encode(array('continue' => false, 'response' => $e->getMessage()))); } break; }; if ($writeStep) { try { $update->writeActions($action - 1); } catch (\Exception $e) { } } } else { ?>

Initialize


Back Up


Download

Perform update

Updater is loading.

  • The Final Upgrade?
  • Migrate to phpList.com and forget about the tech
  • Seamless background updating
  • Managed DMARC, SPF, and DKIM
  • Database import for existing data
  • Expert technical support
  • Scale up to 30 million messages per month
  • Custom domains and unlimited users

Happy with your existing installation? Paid support by independent consultants here.