diff --git a/index.php b/index.php index 8f464b90..3468bdcf 100644 --- a/index.php +++ b/index.php @@ -27,6 +27,8 @@ class LogException extends \Exception { } +use CurlHandle; + class Updater { private string $nextcloudDir; private array $configValues = []; @@ -517,20 +519,7 @@ private function getUpdateServerResponse(): array { $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->getConfigOptionString('proxy'), - CURLOPT_PROXYUSERPWD => $this->getConfigOptionString('proxyuserpwd'), - CURLOPT_HTTPPROXYTUNNEL => $this->getConfigOption('proxy') ? 1 : 0, - ]); - } + $curl = $this->getCurl($updateURL); /** @var false|string $response */ $response = curl_exec($curl); @@ -568,16 +557,10 @@ private function getUpdateServerResponse(): array { public function downloadUpdate(): void { $this->silentLog('[info] downloadUpdate()'); - $response = $this->getUpdateServerResponse(); - if (!isset($response['url']) || !is_string($response['url'])) { - throw new \Exception('Response from update server is missing url'); - } + $downloadURLs = $this->getDownloadURLs(); + $this->silentLog('[info] will try to download archive from: ' . implode(', ', $downloadURLs)); $storageLocation = $this->getUpdateDirectoryLocation() . '/updater-' . $this->getConfigOptionMandatoryString('instanceid') . '/downloads/'; - $saveLocation = $storageLocation . basename($response['url']); - $this->previousProgress = 0; - - $ch = curl_init($response['url']); if (!file_exists($storageLocation)) { $state = mkdir($storageLocation, 0750, true); @@ -592,21 +575,61 @@ public function downloadUpdate(): void { $this->silentLog('[info] extracted Archive location exists'); $this->recursiveDelete($storageLocation . 'nextcloud/'); } - // see if there's an existing incomplete download to resume - if (is_file($saveLocation)) { - $size = filesize($saveLocation); - $range = $size . '-'; - curl_setopt($ch, CURLOPT_RANGE, $range); - $this->silentLog('[info] previous download found; resuming from ' . $this->formatBytes($size)); + } + + foreach ($downloadURLs as $url) { + $this->previousProgress = 0; + $saveLocation = $storageLocation . basename($url); + if ($this->downloadArchive($url, $saveLocation)) { + return; } } - $fp = fopen($saveLocation, 'a'); + throw new \Exception('All downloads failed. See updater logs for more information.'); + } + + private function getDownloadURLs(): array { + $response = $this->getUpdateServerResponse(); + $downloadURLs = []; + if (!isset($response['downloads']) || !is_array($response['downloads'])) { + if (isset($response['url']) && is_string($response['url'])) { + // Compatibility with previous verison of updater_server + $ext = pathinfo($response['url'], PATHINFO_EXTENSION); + $response['downloads'] = [ + $ext => [$response['url']] + ]; + } else { + throw new \Exception('Response from update server is missing download URLs'); + } + } + foreach ($response['downloads'] as $format => $urls) { + if (!$this->isAbleToDecompress($format)) { + continue; + } + foreach ($urls as $url) { + if (!is_string($url)) { + continue; + } + $downloadURLs[] = $url; + } + } + + if (empty($downloadURLs)) { + throw new \Exception('Your PHP install is not able to decompress any archive. Try to install modules like zip or bzip.'); + } + + return array_unique($downloadURLs); + + } + + private function getCurl(string $url): CurlHandle { + $ch = curl_init($url); + if ($ch === false) { + throw new \Exception('Fail to open cUrl handler'); + } + curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_NOPROGRESS => false, - CURLOPT_PROGRESSFUNCTION => [$this, 'downloadProgressCallback'], - CURLOPT_FILE => $fp, CURLOPT_USERAGENT => 'Nextcloud Updater', CURLOPT_FOLLOWLOCATION => 1, CURLOPT_MAXREDIRS => 2, @@ -620,47 +643,61 @@ public function downloadUpdate(): void { ]); } + return $ch; + } + + private function downloadArchive(string $fromUrl, string $toLocation): bool { + $ch = $this->getCurl($fromUrl); + + // see if there's an existing incomplete download to resume + if (is_file($toLocation)) { + $size = (int)filesize($toLocation); + $range = $size . '-'; + curl_setopt($ch, CURLOPT_RANGE, $range); + $this->silentLog('[info] previous download found; resuming from ' . $this->formatBytes($size)); + } + + $fp = fopen($toLocation, 'ab'); + if ($fp === false) { + throw new \Exception('Fail to open file in ' . $toLocation); + } + + curl_setopt_array($ch, [ + CURLOPT_NOPROGRESS => false, + CURLOPT_PROGRESSFUNCTION => [$this, 'downloadProgressCallback'], + CURLOPT_FILE => $fp, + ]); + if (curl_exec($ch) === false) { throw new \Exception('Curl error: ' . curl_error($ch)); } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($httpCode !== 200 && $httpCode !== 206) { - $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 (is_int($httpCode) && isset($statusCodes[$httpCode])) { - $message .= ' - ' . $statusCodes[$httpCode] . ' (HTTP ' . $httpCode . ')'; - } else { - $message .= ' - HTTP status code: ' . (string)$httpCode; - } - - $curlErrorMessage = curl_error($ch); - if (!empty($curlErrorMessage)) { - $message .= ' - curl error message: ' . $curlErrorMessage; - } + fclose($fp); + unlink($toLocation); + $this->silentLog('[warn] fail to download archive from ' . $fromUrl . '. Error: ' . $httpCode . ' ' . curl_error($ch)); + curl_close($ch); - $message .= ' - URL: ' . htmlentities($response['url']); - - throw new \Exception($message); - } else { - // download succeeded - $info = curl_getinfo($ch); - $this->silentLog('[info] download stats: size=' . $this->formatBytes((int)$info['size_download']) . ' bytes; total_time=' . round($info['total_time'], 2) . ' secs; avg speed=' . $this->formatBytes((int)$info['speed_download']) . '/sec'); + return false; } + // download succeeded + $info = curl_getinfo($ch); + $this->silentLog('[info] download stats: size=' . $this->formatBytes((int)$info['size_download']) . ' bytes; total_time=' . round($info['total_time'], 2) . ' secs; avg speed=' . $this->formatBytes((int)$info['speed_download']) . '/sec'); curl_close($ch); fclose($fp); $this->silentLog('[info] end of downloadUpdate()'); + return true; + } + + /** + * Check if PHP is able to decompress archive format + */ + private function isAbleToDecompress(string $ext): bool { + // Only zip is supported for now + return $ext === 'zip' && extension_loaded($ext); } private function downloadProgressCallback(\CurlHandle $resource, int $download_size, int $downloaded, int $upload_size, int $uploaded): void { @@ -677,7 +714,7 @@ private function downloadProgressCallback(\CurlHandle $resource, int $download_s } private function formatBytes(int $bytes, int $precision = 2): string { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); @@ -699,13 +736,14 @@ private function getDownloadedFilePath(): string { $filesInStorageLocation = scandir($storageLocation); $files = array_values(array_filter($filesInStorageLocation, function (string $path) { - return $path !== '.' && $path !== '..'; + // Match files with - in the name and extension (*-*.*) + return preg_match('/^.*-.*\..*$/i', $path); })); // only the downloaded archive if (count($files) !== 1) { throw new \Exception('There are more files than the downloaded archive in the downloads/ folder.'); } - return $storageLocation . '/' . $files[0]; + return $storageLocation . $files[0]; } /** diff --git a/lib/Updater.php b/lib/Updater.php index 6d9e4ca3..3ccb50a6 100644 --- a/lib/Updater.php +++ b/lib/Updater.php @@ -9,6 +9,8 @@ namespace NC\Updater; +use CurlHandle; + class Updater { private string $nextcloudDir; private array $configValues = []; @@ -499,20 +501,7 @@ private function getUpdateServerResponse(): array { $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->getConfigOptionString('proxy'), - CURLOPT_PROXYUSERPWD => $this->getConfigOptionString('proxyuserpwd'), - CURLOPT_HTTPPROXYTUNNEL => $this->getConfigOption('proxy') ? 1 : 0, - ]); - } + $curl = $this->getCurl($updateURL); /** @var false|string $response */ $response = curl_exec($curl); @@ -550,16 +539,10 @@ private function getUpdateServerResponse(): array { public function downloadUpdate(): void { $this->silentLog('[info] downloadUpdate()'); - $response = $this->getUpdateServerResponse(); - if (!isset($response['url']) || !is_string($response['url'])) { - throw new \Exception('Response from update server is missing url'); - } + $downloadURLs = $this->getDownloadURLs(); + $this->silentLog('[info] will try to download archive from: ' . implode(', ', $downloadURLs)); $storageLocation = $this->getUpdateDirectoryLocation() . '/updater-' . $this->getConfigOptionMandatoryString('instanceid') . '/downloads/'; - $saveLocation = $storageLocation . basename($response['url']); - $this->previousProgress = 0; - - $ch = curl_init($response['url']); if (!file_exists($storageLocation)) { $state = mkdir($storageLocation, 0750, true); @@ -574,21 +557,61 @@ public function downloadUpdate(): void { $this->silentLog('[info] extracted Archive location exists'); $this->recursiveDelete($storageLocation . 'nextcloud/'); } - // see if there's an existing incomplete download to resume - if (is_file($saveLocation)) { - $size = filesize($saveLocation); - $range = $size . '-'; - curl_setopt($ch, CURLOPT_RANGE, $range); - $this->silentLog('[info] previous download found; resuming from ' . $this->formatBytes($size)); + } + + foreach ($downloadURLs as $url) { + $this->previousProgress = 0; + $saveLocation = $storageLocation . basename($url); + if ($this->downloadArchive($url, $saveLocation)) { + return; } } - $fp = fopen($saveLocation, 'a'); + throw new \Exception('All downloads failed. See updater logs for more information.'); + } + + private function getDownloadURLs(): array { + $response = $this->getUpdateServerResponse(); + $downloadURLs = []; + if (!isset($response['downloads']) || !is_array($response['downloads'])) { + if (isset($response['url']) && is_string($response['url'])) { + // Compatibility with previous verison of updater_server + $ext = pathinfo($response['url'], PATHINFO_EXTENSION); + $response['downloads'] = [ + $ext => [$response['url']] + ]; + } else { + throw new \Exception('Response from update server is missing download URLs'); + } + } + foreach ($response['downloads'] as $format => $urls) { + if (!$this->isAbleToDecompress($format)) { + continue; + } + foreach ($urls as $url) { + if (!is_string($url)) { + continue; + } + $downloadURLs[] = $url; + } + } + + if (empty($downloadURLs)) { + throw new \Exception('Your PHP install is not able to decompress any archive. Try to install modules like zip or bzip.'); + } + + return array_unique($downloadURLs); + + } + + private function getCurl(string $url): CurlHandle { + $ch = curl_init($url); + if ($ch === false) { + throw new \Exception('Fail to open cUrl handler'); + } + curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_NOPROGRESS => false, - CURLOPT_PROGRESSFUNCTION => [$this, 'downloadProgressCallback'], - CURLOPT_FILE => $fp, CURLOPT_USERAGENT => 'Nextcloud Updater', CURLOPT_FOLLOWLOCATION => 1, CURLOPT_MAXREDIRS => 2, @@ -602,47 +625,61 @@ public function downloadUpdate(): void { ]); } + return $ch; + } + + private function downloadArchive(string $fromUrl, string $toLocation): bool { + $ch = $this->getCurl($fromUrl); + + // see if there's an existing incomplete download to resume + if (is_file($toLocation)) { + $size = (int)filesize($toLocation); + $range = $size . '-'; + curl_setopt($ch, CURLOPT_RANGE, $range); + $this->silentLog('[info] previous download found; resuming from ' . $this->formatBytes($size)); + } + + $fp = fopen($toLocation, 'ab'); + if ($fp === false) { + throw new \Exception('Fail to open file in ' . $toLocation); + } + + curl_setopt_array($ch, [ + CURLOPT_NOPROGRESS => false, + CURLOPT_PROGRESSFUNCTION => [$this, 'downloadProgressCallback'], + CURLOPT_FILE => $fp, + ]); + if (curl_exec($ch) === false) { throw new \Exception('Curl error: ' . curl_error($ch)); } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($httpCode !== 200 && $httpCode !== 206) { - $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 (is_int($httpCode) && isset($statusCodes[$httpCode])) { - $message .= ' - ' . $statusCodes[$httpCode] . ' (HTTP ' . $httpCode . ')'; - } else { - $message .= ' - HTTP status code: ' . (string)$httpCode; - } + fclose($fp); + unlink($toLocation); + $this->silentLog('[warn] fail to download archive from ' . $fromUrl . '. Error: ' . $httpCode . ' ' . curl_error($ch)); + curl_close($ch); - $curlErrorMessage = curl_error($ch); - if (!empty($curlErrorMessage)) { - $message .= ' - curl error message: ' . $curlErrorMessage; - } - - $message .= ' - URL: ' . htmlentities($response['url']); - - throw new \Exception($message); - } else { - // download succeeded - $info = curl_getinfo($ch); - $this->silentLog('[info] download stats: size=' . $this->formatBytes((int)$info['size_download']) . ' bytes; total_time=' . round($info['total_time'], 2) . ' secs; avg speed=' . $this->formatBytes((int)$info['speed_download']) . '/sec'); + return false; } + // download succeeded + $info = curl_getinfo($ch); + $this->silentLog('[info] download stats: size=' . $this->formatBytes((int)$info['size_download']) . ' bytes; total_time=' . round($info['total_time'], 2) . ' secs; avg speed=' . $this->formatBytes((int)$info['speed_download']) . '/sec'); curl_close($ch); fclose($fp); $this->silentLog('[info] end of downloadUpdate()'); + return true; + } + + /** + * Check if PHP is able to decompress archive format + */ + private function isAbleToDecompress(string $ext): bool { + // Only zip is supported for now + return $ext === 'zip' && extension_loaded($ext); } private function downloadProgressCallback(\CurlHandle $resource, int $download_size, int $downloaded, int $upload_size, int $uploaded): void { @@ -659,7 +696,7 @@ private function downloadProgressCallback(\CurlHandle $resource, int $download_s } private function formatBytes(int $bytes, int $precision = 2): string { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); @@ -681,13 +718,14 @@ private function getDownloadedFilePath(): string { $filesInStorageLocation = scandir($storageLocation); $files = array_values(array_filter($filesInStorageLocation, function (string $path) { - return $path !== '.' && $path !== '..'; + // Match files with - in the name and extension (*-*.*) + return preg_match('/^.*-.*\..*$/i', $path); })); // only the downloaded archive if (count($files) !== 1) { throw new \Exception('There are more files than the downloaded archive in the downloads/ folder.'); } - return $storageLocation . '/' . $files[0]; + return $storageLocation . $files[0]; } /** diff --git a/tests/features/cli.feature b/tests/features/cli.feature index d2b1623f..b1d880d9 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -28,7 +28,7 @@ Feature: CLI updater And there is an update to version 25.0.503 available When the CLI updater is run Then the return code should not be 0 - And the output should contain "Download failed - Not Found (HTTP 404)" + And the output should contain "All downloads failed." And the installed version should be 25.0.0 And maintenance mode should be off And upgrade is not required diff --git a/updater.phar b/updater.phar index 9da3ead1..4b8c7f4b 100755 Binary files a/updater.phar and b/updater.phar differ diff --git a/vendor/autoload.php b/vendor/autoload.php index c4a5cf9d..2c8feb0b 100644 --- a/vendor/autoload.php +++ b/vendor/autoload.php @@ -14,10 +14,7 @@ echo $err; } } - trigger_error( - $err, - E_USER_ERROR - ); + throw new RuntimeException($err); } require_once __DIR__ . '/composer/autoload_real.php';