* @copyright Copyright (c) 2016 Morris Jobke * @copyright Copyright (c) 2018 Jonas Sulzer * * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * */ namespace NC\Updater; class Updater { /** @var string */ private $baseDir; /** @var array */ private $configValues = []; /** @var string */ private $currentVersion = 'unknown'; /** @var string */ private $buildTime; /** @var bool */ private $updateAvailable = false; /** @var string */ private $requestID = null; /** * Updater constructor * @param $baseDir string the absolute path to the /updater/ directory in the Nextcloud root * @throws \Exception */ public function __construct($baseDir) { $this->baseDir = $baseDir; if($dir = getenv('NEXTCLOUD_CONFIG_DIR')) { $configFileName = rtrim($dir, '/') . '/config.php'; } else { $configFileName = $this->baseDir . '/../config/config.php'; } if (!file_exists($configFileName)) { throw new \Exception('Could not find config.php. Is this file in the "updater" subfolder of Nextcloud?'); } /** @var array $CONFIG */ require_once $configFileName; $this->configValues = $CONFIG; $dataDir = $this->getDataDirectoryLocation(); if(empty($dataDir) || !is_string($dataDir)) { throw new \Exception('Could not read data directory from config.php.'); } $versionFileName = $this->baseDir . '/../version.php'; if (!file_exists($versionFileName)) { // fallback to version in config.php $version = $this->getConfigOption('version'); $buildTime = ''; } else { /** @var string $OC_VersionString */ /** @var string $OC_Build */ require_once $versionFileName; $version = $OC_VersionString; $buildTime = $OC_Build; } if($version === null) { return; } if($buildTime === null) { return; } // normalize version to 3 digits $splittedVersion = explode('.', $version); if(sizeof($splittedVersion) >= 3) { $splittedVersion = array_slice($splittedVersion, 0, 3); } $this->currentVersion = implode('.', $splittedVersion); $this->buildTime = $buildTime; } /** * Returns current version or "unknown" if this could not be determined. * * @return string */ public function getCurrentVersion() { return $this->currentVersion; } /** * Returns currently used release channel * * @return string */ private function getCurrentReleaseChannel() { return !is_null($this->getConfigOption('updater.release.channel')) ? $this->getConfigOption('updater.release.channel') : 'stable'; } /** * @return string * @throws \Exception */ public function checkForUpdate() { $response = $this->getUpdateServerResponse(); $this->silentLog('[info] checkForUpdate() ' . print_r($response, true)); $version = isset($response['version']) ? $response['version'] : ''; $versionString = isset($response['versionstring']) ? $response['versionstring'] : ''; if ($version !== '' && $version !== $this->currentVersion) { $this->updateAvailable = true; $releaseChannel = $this->getCurrentReleaseChannel(); $updateText = 'Update to ' . htmlentities($versionString) . ' available. (channel: "' . htmlentities($releaseChannel) . '")
Following file will be downloaded automatically: ' . $response['url'] . ''; // only show changelog link for stable releases (non-RC & non-beta) if (!preg_match('!(rc|beta)!i', $versionString)) { $changelogURL = $this->getChangelogURL(substr($version, 0, strrpos($version, '.'))); $updateText .= '
Open changelog ↗'; } } else { $updateText = 'No update available.'; } if ($this->updateAvailable && isset($response['autoupdater']) && !($response['autoupdater'] === 1 || $response['autoupdater'] === '1')) { $this->updateAvailable = false; $updateText .= '
The updater is disabled for this update - please update manually.'; } $this->silentLog('[info] end of checkForUpdate() ' . $updateText); return $updateText; } /** * Returns bool whether update is available or not * * @return bool */ public function updateAvailable() { return $this->updateAvailable; } /** * Returns the specified config options * * @param string $key * @return mixed|null Null if the entry is not found */ public function getConfigOption($key) { return isset($this->configValues[$key]) ? $this->configValues[$key] : null; } /** * Gets the data directory location on the local filesystem * * @return string */ private function getDataDirectoryLocation() { return $this->configValues['datadirectory']; } /** * Returns the expected files and folders as array * * @return array */ private function getExpectedElementsList() { $expected = [ // Generic '.', '..', // Folders '.well-known', '3rdparty', 'apps', 'config', 'core', 'data', 'l10n', 'lib', 'ocs', 'ocs-provider', 'ocm-provider', 'resources', 'settings', 'themes', 'updater', // Files 'index.html', 'indie.json', '.user.ini', 'console.php', 'cron.php', 'index.php', 'public.php', 'remote.php', 'status.php', 'version.php', 'robots.txt', '.htaccess', 'AUTHORS', 'CHANGELOG.md', 'COPYING', 'COPYING-AGPL', 'occ', 'db_structure.xml', ]; return array_merge($expected, $this->getAppDirectories()); } /** * Returns app directories specified in config.php * * @return array */ private function getAppDirectories() { $expected = []; if($appsPaths = $this->getConfigOption('apps_paths')) { foreach ($appsPaths as $appsPath) { $parentDir = realpath($this->baseDir . '/../'); $appDir = basename($appsPath['path']); if(strpos($appsPath['path'], $parentDir) === 0 && $appDir !== 'apps') { $expected[] = $appDir; } } } return $expected; } /** * Gets the recursive directory iterator over the Nextcloud folder * * @param string $folder * @return \RecursiveIteratorIterator */ private function getRecursiveDirectoryIterator($folder = null) { if ($folder === null) { $folder = $this->baseDir . '/../'; } return new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($folder, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); } /** * Checks for files that are unexpected. */ public function checkForExpectedFilesAndFolders() { $this->silentLog('[info] checkForExpectedFilesAndFolders()'); $expectedElements = $this->getExpectedElementsList(); $unexpectedElements = []; foreach (new \DirectoryIterator($this->baseDir . '/../') as $fileInfo) { if(array_search($fileInfo->getFilename(), $expectedElements) === false) { $unexpectedElements[] = $fileInfo->getFilename(); } } if (count($unexpectedElements) !== 0) { throw new UpdateException($unexpectedElements); } $this->silentLog('[info] end of checkForExpectedFilesAndFolders()'); } /** * Checks for files that are not writable */ public function checkWritePermissions() { $this->silentLog('[info] checkWritePermissions()'); $notWritablePaths = array(); $dir = new \RecursiveDirectoryIterator($this->baseDir . '/../'); $filter = new RecursiveDirectoryIteratorWithoutData($dir); $it = new \RecursiveIteratorIterator($filter); foreach ($it as $path => $dir) { if(!is_writable($path)) { $notWritablePaths[] = $path; } } if(count($notWritablePaths) > 0) { throw new UpdateException($notWritablePaths); } $this->silentLog('[info] end of checkWritePermissions()'); } /** * Sets the maintenance mode to the defined value * * @param bool $state * @throws \Exception when config.php can't be written */ public function setMaintenanceMode($state) { $this->silentLog('[info] setMaintenanceMode("' . ($state ? 'true' : 'false') . '")'); if($dir = getenv('NEXTCLOUD_CONFIG_DIR')) { $configFileName = rtrim($dir, '/') . '/config.php'; } else { $configFileName = $this->baseDir . '/../config/config.php'; } $this->silentLog('[info] configFileName ' . $configFileName); // usually is already tested in the constructor but just to be on the safe side if (!file_exists($configFileName)) { throw new \Exception('Could not find config.php.'); } /** @var array $CONFIG */ require $configFileName; $CONFIG['maintenance'] = $state; $content = "silentLog('[info] end of setMaintenanceMode()'); } /** * Creates a backup of all files and moves it into data/updater-$instanceid/backups/nextcloud-X-Y-Z/ * * @throws \Exception */ public function createBackup() { $this->silentLog('[info] createBackup()'); $excludedElements = [ '.well-known', 'data', ]; // Create new folder for the backup $backupFolderLocation = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid').'/backups/nextcloud-'.$this->getConfigOption('version') . '/'; if(file_exists($backupFolderLocation)) { $this->silentLog('[info] backup folder location exists'); $this->recursiveDelete($backupFolderLocation); } $state = mkdir($backupFolderLocation, 0750, true); if($state === false) { throw new \Exception('Could not create backup folder location'); } // Copy the backup files $currentDir = $this->baseDir . '/../'; /** * @var string $path * @var \SplFileInfo $fileInfo */ foreach ($this->getRecursiveDirectoryIterator($currentDir) as $path => $fileInfo) { $fileName = explode($currentDir, $path)[1]; $folderStructure = explode('/', $fileName, -1); // Exclude the exclusions if(isset($folderStructure[0])) { if(array_search($folderStructure[0], $excludedElements) !== false) { continue; } } else { if(array_search($fileName, $excludedElements) !== false) { continue; } } // Create folder if it doesn't exist if(!file_exists($backupFolderLocation . '/' . dirname($fileName))) { $state = mkdir($backupFolderLocation . '/' . dirname($fileName), 0750, true); if($state === false) { throw new \Exception('Could not create folder: '.$backupFolderLocation.'/'.dirname($fileName)); } } // If it is a file copy it if($fileInfo->isFile()) { $state = copy($fileInfo->getRealPath(), $backupFolderLocation . $fileName); if($state === false) { $message = sprintf( 'Could not copy "%s" to "%s"', $fileInfo->getRealPath(), $backupFolderLocation . $fileName ); if(is_readable($fileInfo->getRealPath()) === false) { $message = sprintf( '%s. Source %s is not readable', $message, $fileInfo->getRealPath() ); } if(is_writable($backupFolderLocation . $fileName) === false) { $message = sprintf( '%s. Destination %s is not writable', $message, $backupFolderLocation . $fileName ); } throw new \Exception($message); } } } $this->silentLog('[info] end of createBackup()'); } private function getChangelogURL($versionString) { $this->silentLog('[info] getChangelogURL()'); $changelogWebsite = 'https://nextcloud.com/changelog/'; $changelogURL = $changelogWebsite . '#' . str_replace('.', '-', $versionString); return $changelogURL; } /** * @return array * @throws \Exception */ private function getUpdateServerResponse() { $this->silentLog('[info] getUpdateServerResponse()'); $updaterServer = $this->getConfigOption('updater.server.url'); if($updaterServer === null) { // FIXME: used deployed URL $updaterServer = 'https://updates.nextcloud.com/updater_server/'; } $this->silentLog('[info] updaterServer: ' . $updaterServer); $releaseChannel = $this->getCurrentReleaseChannel(); $this->silentLog('[info] releaseChannel: ' . $releaseChannel); $this->silentLog('[info] internal version: ' . $this->getConfigOption('version')); $updateURL = $updaterServer . '?version='. str_replace('.', 'x', $this->getConfigOption('version')) .'xxx'.$releaseChannel.'xx'.urlencode($this->buildTime).'x'.PHP_MAJOR_VERSION.'x'.PHP_MINOR_VERSION.'x'.PHP_RELEASE_VERSION; $this->silentLog('[info] updateURL: ' . $updateURL); // Download update response $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_RETURNTRANSFER => 1, CURLOPT_URL => $updateURL, CURLOPT_USERAGENT => 'Nextcloud Updater', ]); if ($this->getConfigOption('proxy') !== null) { curl_setopt_array($curl, [ CURLOPT_PROXY => $this->getConfigOption('proxy'), CURLOPT_PROXYUSERPWD => $this->getConfigOption('proxyuserpwd'), CURLOPT_HTTPPROXYTUNNEL => $this->getConfigOption('proxy') ? 1 : 0, ]); } $response = curl_exec($curl); if($response === false) { throw new \Exception('Could not do request to updater server: '.curl_error($curl)); } curl_close($curl); // Response can be empty when no update is available if($response === '') { return []; } $xml = simplexml_load_string($response); if($xml === false) { throw new \Exception('Could not parse updater server XML response'); } $json = json_encode($xml); if($json === false) { throw new \Exception('Could not JSON encode updater server response'); } $response = json_decode($json, true); if($response === null) { throw new \Exception('Could not JSON decode updater server response.'); } $this->silentLog('[info] getUpdateServerResponse response: ' . print_r($response, true)); return $response; } /** * Downloads the nextcloud folder to $DATADIR/updater-$instanceid/downloads/$filename * * @throws \Exception */ public function downloadUpdate() { $this->silentLog('[info] downloadUpdate()'); $response = $this->getUpdateServerResponse(); $storageLocation = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/downloads/'; if(file_exists($storageLocation)) { $this->silentLog('[info] storage location exists'); $this->recursiveDelete($storageLocation); } $state = mkdir($storageLocation, 0750, true); if($state === false) { throw new \Exception('Could not mkdir storage location'); } $fp = fopen($storageLocation . basename($response['url']), 'w+'); $ch = curl_init($response['url']); curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_USERAGENT => 'Nextcloud Updater', ]); if ($this->getConfigOption('proxy') !== null) { curl_setopt_array($ch, [ CURLOPT_PROXY => $this->getConfigOption('proxy'), CURLOPT_PROXYUSERPWD => $this->getConfigOption('proxyuserpwd'), CURLOPT_HTTPPROXYTUNNEL => $this->getConfigOption('proxy') ? 1 : 0, ]); } if(curl_exec($ch) === false) { throw new \Exception('Curl error: ' . curl_error($ch)); } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if($httpCode !== 200) { $statusCodes = [ 400 => 'Bad request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', ]; $message = 'Download failed'; if(isset($statusCodes[$httpCode])) { $message .= ' - ' . $statusCodes[$httpCode] . ' (HTTP ' . $httpCode . ')'; } else { $message .= ' - HTTP status code: ' . $httpCode; } $curlErrorMessage = curl_error($ch); if(!empty($curlErrorMessage)) { $message .= ' - curl error message: ' . $curlErrorMessage; } $message .= ' - URL: ' . htmlentities($response['url']); throw new \Exception($message); } curl_close($ch); fclose($fp); $this->silentLog('[info] end of downloadUpdate()'); } /** * @return string * @throws \Exception */ private function getDownloadedFilePath() { $storageLocation = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/downloads/'; $this->silentLog('[info] storage location: ' . $storageLocation); $files = scandir($storageLocation); // ., .. and downloaded zip archive if(count($files) !== 3) { throw new \Exception('Not exact 3 files existent in folder'); } return $storageLocation . '/' . $files[2]; } /** * Verifies the integrity of the downloaded file * * @throws \Exception */ public function verifyIntegrity() { $this->silentLog('[info] verifyIntegrity()'); if($this->getCurrentReleaseChannel() === 'daily') { $this->silentLog('[info] current channel is "daily" which is not signed. Skipping verification.'); return; } $response = $this->getUpdateServerResponse(); if(!isset($response['signature'])) { throw new \Exception('No signature specified for defined update'); } $certificate = <<getDownloadedFilePath()), base64_decode($response['signature']), $certificate, OPENSSL_ALGO_SHA512 ); if($validSignature === false) { throw new \Exception('Signature of update is not valid'); } $this->silentLog('[info] end of verifyIntegrity()'); } /** * Gets the version as declared in $versionFile * * @param string $versionFile * @return string * @throws \Exception If $OC_Version is not defined in $versionFile */ private function getVersionByVersionFile($versionFile) { require $versionFile; if(isset($OC_Version)) { /** @var array $OC_Version */ return implode('.', $OC_Version); } throw new \Exception("OC_Version not found in $versionFile"); } /** * Extracts the download * * @throws \Exception */ public function extractDownload() { $this->silentLog('[info] extractDownload()'); $downloadedFilePath = $this->getDownloadedFilePath(); $zip = new \ZipArchive; $zipState = $zip->open($downloadedFilePath); if ($zipState === true) { $extraction = $zip->extractTo(dirname($downloadedFilePath)); if($extraction === false) { throw new \Exception('Error during unpacking zipfile: '.($zip->getStatusString())); } $zip->close(); $state = unlink($downloadedFilePath); if($state === false) { throw new \Exception("Can't unlink ". $downloadedFilePath); } } else { throw new \Exception("Can't handle ZIP file. Error code is: ".$zipState); } // Ensure that the downloaded version is not lower $downloadedVersion = $this->getVersionByVersionFile(dirname($downloadedFilePath) . '/nextcloud/version.php'); $currentVersion = $this->getVersionByVersionFile($this->baseDir . '/../version.php'); if(version_compare($downloadedVersion, $currentVersion, '<')) { throw new \Exception('Downloaded version is lower than installed version'); } $this->silentLog('[info] end of extractDownload()'); } /** * Replaces the entry point files with files that only return a 503 * * @throws \Exception */ public function replaceEntryPoints() { $this->silentLog('[info] replaceEntryPoints()'); $filesToReplace = [ 'index.php', 'status.php', 'remote.php', 'public.php', 'ocs/v1.php', 'ocs/v2.php', ]; $content = "silentLog('[info] replace ' . $file); $parentDir = dirname($this->baseDir . '/../' . $file); if(!file_exists($parentDir)) { $r = mkdir($parentDir); if($r !== true) { throw new \Exception('Can\'t create parent directory for entry point: ' . $file); } } $state = file_put_contents($this->baseDir . '/../' . $file, $content); if($state === false) { throw new \Exception('Can\'t replace entry point: '.$file); } } $this->silentLog('[info] end of replaceEntryPoints()'); } /** * Recursively deletes the specified folder from the system * * @param string $folder * @throws \Exception */ private function recursiveDelete($folder) { if(!file_exists($folder)) { return; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($folder, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); $directories = array(); $files = array(); foreach ($iterator as $fileInfo) { if ($fileInfo->isDir()) { $directories[] = $fileInfo->getRealPath(); } else { if ($fileInfo->isLink()) { $files[] = $fileInfo->getPathName(); } else { $files[] = $fileInfo->getRealPath(); } } } foreach ($files as $file) { unlink($file); } foreach ($directories as $dir) { rmdir($dir); } $state = rmdir($folder); if($state === false) { throw new \Exception('Could not rmdir ' . $folder); } } /** * Delete old files from the system as much as possible * * @throws \Exception */ public function deleteOldFiles() { $this->silentLog('[info] deleteOldFiles()'); $shippedAppsFile = $this->baseDir . '/../core/shipped.json'; if(!file_exists($shippedAppsFile)) { throw new \Exception('core/shipped.json is not available'); } // Delete shipped apps $shippedApps = json_decode(file_get_contents($shippedAppsFile), true); foreach($shippedApps['shippedApps'] as $app) { $this->recursiveDelete($this->baseDir . '/../apps/' . $app); } $configSampleFile = $this->baseDir . '/../config/config.sample.php'; if(file_exists($configSampleFile)) { $this->silentLog('[info] config sample exists'); // Delete example config $state = unlink($configSampleFile); if ($state === false) { throw new \Exception('Could not unlink sample config'); } } $themesReadme = $this->baseDir . '/../themes/README'; if(file_exists($themesReadme)) { $this->silentLog('[info] themes README exists'); // Delete themes $state = unlink($themesReadme); if ($state === false) { throw new \Exception('Could not delete themes README'); } } $this->recursiveDelete($this->baseDir . '/../themes/example/'); // Delete the rest $excludedElements = [ '.well-known', 'data', 'index.php', 'status.php', 'remote.php', 'public.php', 'ocs/v1.php', 'ocs/v2.php', 'config', 'themes', 'apps', 'updater', ]; $excludedElements = array_merge($excludedElements, $this->getAppDirectories()); /** * @var string $path * @var \SplFileInfo $fileInfo */ foreach ($this->getRecursiveDirectoryIterator() as $path => $fileInfo) { $currentDir = $this->baseDir . '/../'; $fileName = explode($currentDir, $path)[1]; $folderStructure = explode('/', $fileName, -1); // Exclude the exclusions if(isset($folderStructure[0])) { if(array_search($folderStructure[0], $excludedElements) !== false) { continue; } } else { if(array_search($fileName, $excludedElements) !== false) { continue; } } if($fileInfo->isFile() || $fileInfo->isLink()) { $state = unlink($path); if($state === false) { throw new \Exception('Could not unlink: '.$path); } } elseif($fileInfo->isDir()) { $state = rmdir($path); if($state === false) { throw new \Exception('Could not rmdir: '.$path); } } } $this->silentLog('[info] end of deleteOldFiles()'); } /** * Moves the specified filed except the excluded elements to the correct position * * @param string $dataLocation * @param array $excludedElements * @throws \Exception */ private function moveWithExclusions($dataLocation, array $excludedElements) { /** * @var \SplFileInfo $fileInfo */ foreach ($this->getRecursiveDirectoryIterator($dataLocation) as $path => $fileInfo) { $fileName = explode($dataLocation, $path)[1]; $folderStructure = explode('/', $fileName, -1); // Exclude the exclusions if (isset($folderStructure[0])) { if (array_search($folderStructure[0], $excludedElements) !== false) { continue; } } else { if (array_search($fileName, $excludedElements) !== false) { continue; } } if($fileInfo->isFile()) { if(!file_exists($this->baseDir . '/../' . dirname($fileName))) { $state = mkdir($this->baseDir . '/../' . dirname($fileName), 0755, true); if($state === false) { throw new \Exception('Could not mkdir ' . $this->baseDir . '/../' . dirname($fileName)); } } $state = rename($path, $this->baseDir . '/../' . $fileName); if($state === false) { throw new \Exception( sprintf( 'Could not rename %s to %s', $path, $this->baseDir . '/../' . $fileName ) ); } } if($fileInfo->isDir()) { $state = rmdir($path); if($state === false) { throw new \Exception('Could not rmdir ' . $path); } } } } /** * Moves the newly downloaded files into place * * @throws \Exception */ public function moveNewVersionInPlace() { $this->silentLog('[info] moveNewVersionInPlace()'); // Rename everything else except the entry and updater files $excludedElements = [ 'updater', 'index.php', 'status.php', 'remote.php', 'public.php', 'ocs/v1.php', 'ocs/v2.php', ]; $storageLocation = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/downloads/nextcloud/'; $this->silentLog('[info] storage location: ' . $storageLocation); $this->moveWithExclusions($storageLocation, $excludedElements); // Rename everything except the updater files $this->moveWithExclusions($storageLocation, ['updater']); $this->silentLog('[info] end of moveNewVersionInPlace()'); } /** * Finalize and cleanup the updater by finally replacing the updater script */ public function finalize() { $this->silentLog('[info] finalize()'); $storageLocation = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/downloads/nextcloud/'; $this->silentLog('[info] storage location: ' . $storageLocation); $this->moveWithExclusions($storageLocation, []); $state = rmdir($storageLocation); if($state === false) { throw new \Exception('Could not rmdir $storagelocation'); } $state = unlink($this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/.step'); if($state === false) { throw new \Exception('Could not rmdir .step'); } if (function_exists('opcache_reset')) { $this->silentLog('[info] call opcache_reset()'); opcache_reset(); } $this->silentLog('[info] end of finalize()'); } /** * @param string $state * @param int $step * @throws \Exception */ private function writeStep($state, $step) { $updaterDir = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid'); if(!file_exists($updaterDir . '/.step')) { if(!file_exists($updaterDir)) { $result = mkdir($updaterDir); if ($result === false) { throw new \Exception('Could not create $updaterDir'); } } $result = touch($updaterDir . '/.step'); if($result === false) { throw new \Exception('Could not create .step'); } } $result = file_put_contents($updaterDir . '/.step', json_encode(['state' => $state, 'step' => $step])); if($result === false) { throw new \Exception('Could not write to .step'); } } /** * @param int $step * @throws \Exception */ public function startStep($step) { $this->silentLog('[info] startStep("' . $step . '")'); $this->writeStep('start', $step); } /** * @param int $step * @throws \Exception */ public function endStep($step) { $this->silentLog('[info] endStep("' . $step . '")'); $this->writeStep('end', $step); } /** * @return string * @throws \Exception */ public function currentStep() { $this->silentLog('[info] currentStep()'); $updaterDir = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid'); $jsonData = []; if(file_exists($updaterDir. '/.step')) { $state = file_get_contents($updaterDir . '/.step'); if ($state === false) { throw new \Exception('Could not read from .step'); } $jsonData = json_decode($state, true); if (!is_array($jsonData)) { throw new \Exception('Can\'t decode .step JSON data'); } } return $jsonData; } /** * Rollback the changes if $step has failed * * @param int $step * @throws \Exception */ public function rollbackChanges($step) { $this->silentLog('[info] rollbackChanges("' . $step . '")'); $updaterDir = $this->getDataDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid'); if(file_exists($updaterDir . '/.step')) { $this->silentLog('[info] unlink .step'); $state = unlink($updaterDir . '/.step'); if ($state === false) { throw new \Exception('Could not delete .step'); } } if($step >= 7) { $this->silentLog('[info] rollbackChanges - step >= 7'); // TODO: If it fails after step 7: Rollback } $this->silentLog('[info] end of rollbackChanges()'); } /** * Logs an exception with current datetime prepended to updater.log * * @param \Exception $e * @throws LogException */ public function logException(\Exception $e) { $message = '[error] '; $message .= 'Exception: ' . get_class($e) . PHP_EOL; $message .= 'Message: ' . $e->getMessage() . PHP_EOL; $message .= 'Code:' . $e->getCode() . PHP_EOL; $message .= 'Trace:' . PHP_EOL . $e->getTraceAsString() . PHP_EOL; $message .= 'File:' . $e->getFile() . PHP_EOL; $message .= 'Line:' . $e->getLine() . PHP_EOL; if($e instanceof UpdateException) { $message .= 'Data:' . PHP_EOL . print_r($e->getData(), true) . PHP_EOL; } $this->log($message); } /** * Logs a message with current datetime prepended to updater.log * * @param string $message * @throws LogException */ public function log($message) { $updaterLogPath = $this->getDataDirectoryLocation() . '/updater.log'; $fh = fopen($updaterLogPath, 'a'); if($fh === false) { throw new LogException('Could not open updater.log'); } if($this->requestID === null) { $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $charactersLength = strlen($characters); $randomString = ''; for ($i = 0; $i < 10; $i++) { $randomString .= $characters[rand(0, $charactersLength - 1)]; } $this->requestID = $randomString; } $logLine = date(\DateTime::ISO8601) . ' ' . $this->requestID . ' ' . $message . PHP_EOL; $result = fwrite($fh, $logLine); if($result === false) { throw new LogException('Could not write to updater.log'); } fclose($fh); } /** * Logs a message with current datetime prepended to updater.log but drops possible LogException * * @param string $message */ public function silentLog($message) { try { $this->log($message); } catch (LogException $logE) { /* ignore log exception here (already detected later anyways) */ } } /** * Logs current version * */ public function logVersion() { $this->silentLog('[info] current version: ' . $this->currentVersion . ' build time: ' . $this->buildTime); } }