1<?php 2/** 3 * @copyright Copyright (c) 2016, ownCloud, Inc. 4 * @copyright Copyright (c) 2016, Lukas Reschke <lukas@statuscode.ch> 5 * 6 * @author acsfer <carlos@reendex.com> 7 * @author Arthur Schiwon <blizzz@arthur-schiwon.de> 8 * @author Brice Maron <brice@bmaron.net> 9 * @author Christoph Wurst <christoph@winzerhof-wurst.at> 10 * @author Daniel Kesselberg <mail@danielkesselberg.de> 11 * @author Frank Karlitschek <frank@karlitschek.de> 12 * @author Georg Ehrke <oc.list@georgehrke.com> 13 * @author Joas Schilling <coding@schilljs.com> 14 * @author John Molakvoæ <skjnldsv@protonmail.com> 15 * @author Julius Härtl <jus@bitgrid.net> 16 * @author Kamil Domanski <kdomanski@kdemail.net> 17 * @author Lukas Reschke <lukas@statuscode.ch> 18 * @author Morris Jobke <hey@morrisjobke.de> 19 * @author Robin Appelman <robin@icewind.nl> 20 * @author Roeland Jago Douma <roeland@famdouma.nl> 21 * @author root "root@oc.(none)" 22 * @author Thomas Müller <thomas.mueller@tmit.eu> 23 * @author Thomas Tanghus <thomas@tanghus.net> 24 * 25 * @license AGPL-3.0 26 * 27 * This code is free software: you can redistribute it and/or modify 28 * it under the terms of the GNU Affero General Public License, version 3, 29 * as published by the Free Software Foundation. 30 * 31 * This program is distributed in the hope that it will be useful, 32 * but WITHOUT ANY WARRANTY; without even the implied warranty of 33 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 34 * GNU Affero General Public License for more details. 35 * 36 * You should have received a copy of the GNU Affero General Public License, version 3, 37 * along with this program. If not, see <http://www.gnu.org/licenses/> 38 * 39 */ 40namespace OC; 41 42use Doctrine\DBAL\Exception\TableExistsException; 43use OC\App\AppStore\Bundles\Bundle; 44use OC\App\AppStore\Fetcher\AppFetcher; 45use OC\AppFramework\Bootstrap\Coordinator; 46use OC\Archive\TAR; 47use OC\DB\Connection; 48use OC\DB\MigrationService; 49use OC_App; 50use OC_Helper; 51use OCP\HintException; 52use OCP\Http\Client\IClientService; 53use OCP\IConfig; 54use OCP\ILogger; 55use OCP\ITempManager; 56use phpseclib\File\X509; 57use Psr\Log\LoggerInterface; 58 59/** 60 * This class provides the functionality needed to install, update and remove apps 61 */ 62class Installer { 63 /** @var AppFetcher */ 64 private $appFetcher; 65 /** @var IClientService */ 66 private $clientService; 67 /** @var ITempManager */ 68 private $tempManager; 69 /** @var LoggerInterface */ 70 private $logger; 71 /** @var IConfig */ 72 private $config; 73 /** @var array - for caching the result of app fetcher */ 74 private $apps = null; 75 /** @var bool|null - for caching the result of the ready status */ 76 private $isInstanceReadyForUpdates = null; 77 /** @var bool */ 78 private $isCLI; 79 80 public function __construct( 81 AppFetcher $appFetcher, 82 IClientService $clientService, 83 ITempManager $tempManager, 84 LoggerInterface $logger, 85 IConfig $config, 86 bool $isCLI 87 ) { 88 $this->appFetcher = $appFetcher; 89 $this->clientService = $clientService; 90 $this->tempManager = $tempManager; 91 $this->logger = $logger; 92 $this->config = $config; 93 $this->isCLI = $isCLI; 94 } 95 96 /** 97 * Installs an app that is located in one of the app folders already 98 * 99 * @param string $appId App to install 100 * @param bool $forceEnable 101 * @throws \Exception 102 * @return string app ID 103 */ 104 public function installApp(string $appId, bool $forceEnable = false): string { 105 $app = \OC_App::findAppInDirectories($appId); 106 if ($app === false) { 107 throw new \Exception('App not found in any app directory'); 108 } 109 110 $basedir = $app['path'].'/'.$appId; 111 112 if (is_file($basedir . '/appinfo/database.xml')) { 113 throw new \Exception('The appinfo/database.xml file is not longer supported. Used in ' . $appId); 114 } 115 116 $l = \OC::$server->getL10N('core'); 117 $info = OC_App::getAppInfo($basedir.'/appinfo/info.xml', true, $l->getLanguageCode()); 118 119 if (!is_array($info)) { 120 throw new \Exception( 121 $l->t('App "%s" cannot be installed because appinfo file cannot be read.', 122 [$appId] 123 ) 124 ); 125 } 126 127 $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []); 128 $ignoreMax = $forceEnable || in_array($appId, $ignoreMaxApps, true); 129 130 $version = implode('.', \OCP\Util::getVersion()); 131 if (!\OC_App::isAppCompatible($version, $info, $ignoreMax)) { 132 throw new \Exception( 133 // TODO $l 134 $l->t('App "%s" cannot be installed because it is not compatible with this version of the server.', 135 [$info['name']] 136 ) 137 ); 138 } 139 140 // check for required dependencies 141 \OC_App::checkAppDependencies($this->config, $l, $info, $ignoreMax); 142 /** @var Coordinator $coordinator */ 143 $coordinator = \OC::$server->get(Coordinator::class); 144 $coordinator->runLazyRegistration($appId); 145 \OC_App::registerAutoloading($appId, $basedir); 146 147 $previousVersion = $this->config->getAppValue($info['id'], 'installed_version', false); 148 if ($previousVersion) { 149 OC_App::executeRepairSteps($appId, $info['repair-steps']['pre-migration']); 150 } 151 152 //install the database 153 $ms = new MigrationService($info['id'], \OC::$server->get(Connection::class)); 154 $ms->migrate('latest', true); 155 156 if ($previousVersion) { 157 OC_App::executeRepairSteps($appId, $info['repair-steps']['post-migration']); 158 } 159 160 \OC_App::setupBackgroundJobs($info['background-jobs']); 161 162 //run appinfo/install.php 163 self::includeAppScript($basedir . '/appinfo/install.php'); 164 165 OC_App::executeRepairSteps($appId, $info['repair-steps']['install']); 166 167 //set the installed version 168 \OC::$server->getConfig()->setAppValue($info['id'], 'installed_version', OC_App::getAppVersion($info['id'], false)); 169 \OC::$server->getConfig()->setAppValue($info['id'], 'enabled', 'no'); 170 171 //set remote/public handlers 172 foreach ($info['remote'] as $name => $path) { 173 \OC::$server->getConfig()->setAppValue('core', 'remote_'.$name, $info['id'].'/'.$path); 174 } 175 foreach ($info['public'] as $name => $path) { 176 \OC::$server->getConfig()->setAppValue('core', 'public_'.$name, $info['id'].'/'.$path); 177 } 178 179 OC_App::setAppTypes($info['id']); 180 181 return $info['id']; 182 } 183 184 /** 185 * Updates the specified app from the appstore 186 * 187 * @param string $appId 188 * @param bool [$allowUnstable] Allow unstable releases 189 * @return bool 190 */ 191 public function updateAppstoreApp($appId, $allowUnstable = false) { 192 if ($this->isUpdateAvailable($appId, $allowUnstable)) { 193 try { 194 $this->downloadApp($appId, $allowUnstable); 195 } catch (\Exception $e) { 196 $this->logger->error($e->getMessage(), [ 197 'exception' => $e, 198 ]); 199 return false; 200 } 201 return OC_App::updateApp($appId); 202 } 203 204 return false; 205 } 206 207 /** 208 * Split the certificate file in individual certs 209 * 210 * @param string $cert 211 * @return string[] 212 */ 213 private function splitCerts(string $cert): array { 214 preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches); 215 216 return $matches[0]; 217 } 218 219 /** 220 * Downloads an app and puts it into the app directory 221 * 222 * @param string $appId 223 * @param bool [$allowUnstable] 224 * 225 * @throws \Exception If the installation was not successful 226 */ 227 public function downloadApp($appId, $allowUnstable = false) { 228 $appId = strtolower($appId); 229 230 $apps = $this->appFetcher->get($allowUnstable); 231 foreach ($apps as $app) { 232 if ($app['id'] === $appId) { 233 // Load the certificate 234 $certificate = new X509(); 235 $rootCrt = file_get_contents(__DIR__ . '/../../resources/codesigning/root.crt'); 236 $rootCrts = $this->splitCerts($rootCrt); 237 foreach ($rootCrts as $rootCrt) { 238 $certificate->loadCA($rootCrt); 239 } 240 $loadedCertificate = $certificate->loadX509($app['certificate']); 241 242 // Verify if the certificate has been revoked 243 $crl = new X509(); 244 foreach ($rootCrts as $rootCrt) { 245 $crl->loadCA($rootCrt); 246 } 247 $crl->loadCRL(file_get_contents(__DIR__ . '/../../resources/codesigning/root.crl')); 248 if ($crl->validateSignature() !== true) { 249 throw new \Exception('Could not validate CRL signature'); 250 } 251 $csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString(); 252 $revoked = $crl->getRevoked($csn); 253 if ($revoked !== false) { 254 throw new \Exception( 255 sprintf( 256 'Certificate "%s" has been revoked', 257 $csn 258 ) 259 ); 260 } 261 262 // Verify if the certificate has been issued by the Nextcloud Code Authority CA 263 if ($certificate->validateSignature() !== true) { 264 throw new \Exception( 265 sprintf( 266 'App with id %s has a certificate not issued by a trusted Code Signing Authority', 267 $appId 268 ) 269 ); 270 } 271 272 // Verify if the certificate is issued for the requested app id 273 $certInfo = openssl_x509_parse($app['certificate']); 274 if (!isset($certInfo['subject']['CN'])) { 275 throw new \Exception( 276 sprintf( 277 'App with id %s has a cert with no CN', 278 $appId 279 ) 280 ); 281 } 282 if ($certInfo['subject']['CN'] !== $appId) { 283 throw new \Exception( 284 sprintf( 285 'App with id %s has a cert issued to %s', 286 $appId, 287 $certInfo['subject']['CN'] 288 ) 289 ); 290 } 291 292 // Download the release 293 $tempFile = $this->tempManager->getTemporaryFile('.tar.gz'); 294 $timeout = $this->isCLI ? 0 : 120; 295 $client = $this->clientService->newClient(); 296 $client->get($app['releases'][0]['download'], ['sink' => $tempFile, 'timeout' => $timeout]); 297 298 // Check if the signature actually matches the downloaded content 299 $certificate = openssl_get_publickey($app['certificate']); 300 $verified = (bool)openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512); 301 // PHP 8+ deprecates openssl_free_key and automatically destroys the key instance when it goes out of scope 302 if ((PHP_VERSION_ID < 80000)) { 303 openssl_free_key($certificate); 304 } 305 306 if ($verified === true) { 307 // Seems to match, let's proceed 308 $extractDir = $this->tempManager->getTemporaryFolder(); 309 $archive = new TAR($tempFile); 310 311 if ($archive) { 312 if (!$archive->extract($extractDir)) { 313 $errorMessage = 'Could not extract app ' . $appId; 314 315 $archiveError = $archive->getError(); 316 if ($archiveError instanceof \PEAR_Error) { 317 $errorMessage .= ': ' . $archiveError->getMessage(); 318 } 319 320 throw new \Exception($errorMessage); 321 } 322 $allFiles = scandir($extractDir); 323 $folders = array_diff($allFiles, ['.', '..']); 324 $folders = array_values($folders); 325 326 if (count($folders) > 1) { 327 throw new \Exception( 328 sprintf( 329 'Extracted app %s has more than 1 folder', 330 $appId 331 ) 332 ); 333 } 334 335 // Check if appinfo/info.xml has the same app ID as well 336 if ((PHP_VERSION_ID < 80000)) { 337 $loadEntities = libxml_disable_entity_loader(false); 338 $xml = simplexml_load_file($extractDir . '/' . $folders[0] . '/appinfo/info.xml'); 339 libxml_disable_entity_loader($loadEntities); 340 } else { 341 $xml = simplexml_load_file($extractDir . '/' . $folders[0] . '/appinfo/info.xml'); 342 } 343 if ((string)$xml->id !== $appId) { 344 throw new \Exception( 345 sprintf( 346 'App for id %s has a wrong app ID in info.xml: %s', 347 $appId, 348 (string)$xml->id 349 ) 350 ); 351 } 352 353 // Check if the version is lower than before 354 $currentVersion = OC_App::getAppVersion($appId); 355 $newVersion = (string)$xml->version; 356 if (version_compare($currentVersion, $newVersion) === 1) { 357 throw new \Exception( 358 sprintf( 359 'App for id %s has version %s and tried to update to lower version %s', 360 $appId, 361 $currentVersion, 362 $newVersion 363 ) 364 ); 365 } 366 367 $baseDir = OC_App::getInstallPath() . '/' . $appId; 368 // Remove old app with the ID if existent 369 OC_Helper::rmdirr($baseDir); 370 // Move to app folder 371 if (@mkdir($baseDir)) { 372 $extractDir .= '/' . $folders[0]; 373 OC_Helper::copyr($extractDir, $baseDir); 374 } 375 OC_Helper::copyr($extractDir, $baseDir); 376 OC_Helper::rmdirr($extractDir); 377 return; 378 } else { 379 throw new \Exception( 380 sprintf( 381 'Could not extract app with ID %s to %s', 382 $appId, 383 $extractDir 384 ) 385 ); 386 } 387 } else { 388 // Signature does not match 389 throw new \Exception( 390 sprintf( 391 'App with id %s has invalid signature', 392 $appId 393 ) 394 ); 395 } 396 } 397 } 398 399 throw new \Exception( 400 sprintf( 401 'Could not download app %s', 402 $appId 403 ) 404 ); 405 } 406 407 /** 408 * Check if an update for the app is available 409 * 410 * @param string $appId 411 * @param bool $allowUnstable 412 * @return string|false false or the version number of the update 413 */ 414 public function isUpdateAvailable($appId, $allowUnstable = false) { 415 if ($this->isInstanceReadyForUpdates === null) { 416 $installPath = OC_App::getInstallPath(); 417 if ($installPath === false || $installPath === null) { 418 $this->isInstanceReadyForUpdates = false; 419 } else { 420 $this->isInstanceReadyForUpdates = true; 421 } 422 } 423 424 if ($this->isInstanceReadyForUpdates === false) { 425 return false; 426 } 427 428 if ($this->isInstalledFromGit($appId) === true) { 429 return false; 430 } 431 432 if ($this->apps === null) { 433 $this->apps = $this->appFetcher->get($allowUnstable); 434 } 435 436 foreach ($this->apps as $app) { 437 if ($app['id'] === $appId) { 438 $currentVersion = OC_App::getAppVersion($appId); 439 440 if (!isset($app['releases'][0]['version'])) { 441 return false; 442 } 443 $newestVersion = $app['releases'][0]['version']; 444 if ($currentVersion !== '0' && version_compare($newestVersion, $currentVersion, '>')) { 445 return $newestVersion; 446 } else { 447 return false; 448 } 449 } 450 } 451 452 return false; 453 } 454 455 /** 456 * Check if app has been installed from git 457 * @param string $name name of the application to remove 458 * @return boolean 459 * 460 * The function will check if the path contains a .git folder 461 */ 462 private function isInstalledFromGit($appId) { 463 $app = \OC_App::findAppInDirectories($appId); 464 if ($app === false) { 465 return false; 466 } 467 $basedir = $app['path'].'/'.$appId; 468 return file_exists($basedir.'/.git/'); 469 } 470 471 /** 472 * Check if app is already downloaded 473 * @param string $name name of the application to remove 474 * @return boolean 475 * 476 * The function will check if the app is already downloaded in the apps repository 477 */ 478 public function isDownloaded($name) { 479 foreach (\OC::$APPSROOTS as $dir) { 480 $dirToTest = $dir['path']; 481 $dirToTest .= '/'; 482 $dirToTest .= $name; 483 $dirToTest .= '/'; 484 485 if (is_dir($dirToTest)) { 486 return true; 487 } 488 } 489 490 return false; 491 } 492 493 /** 494 * Removes an app 495 * @param string $appId ID of the application to remove 496 * @return boolean 497 * 498 * 499 * This function works as follows 500 * -# call uninstall repair steps 501 * -# removing the files 502 * 503 * The function will not delete preferences, tables and the configuration, 504 * this has to be done by the function oc_app_uninstall(). 505 */ 506 public function removeApp($appId) { 507 if ($this->isDownloaded($appId)) { 508 if (\OC::$server->getAppManager()->isShipped($appId)) { 509 return false; 510 } 511 $appDir = OC_App::getInstallPath() . '/' . $appId; 512 OC_Helper::rmdirr($appDir); 513 return true; 514 } else { 515 \OCP\Util::writeLog('core', 'can\'t remove app '.$appId.'. It is not installed.', ILogger::ERROR); 516 517 return false; 518 } 519 } 520 521 /** 522 * Installs the app within the bundle and marks the bundle as installed 523 * 524 * @param Bundle $bundle 525 * @throws \Exception If app could not get installed 526 */ 527 public function installAppBundle(Bundle $bundle) { 528 $appIds = $bundle->getAppIdentifiers(); 529 foreach ($appIds as $appId) { 530 if (!$this->isDownloaded($appId)) { 531 $this->downloadApp($appId); 532 } 533 $this->installApp($appId); 534 $app = new OC_App(); 535 $app->enable($appId); 536 } 537 $bundles = json_decode($this->config->getAppValue('core', 'installed.bundles', json_encode([])), true); 538 $bundles[] = $bundle->getIdentifier(); 539 $this->config->setAppValue('core', 'installed.bundles', json_encode($bundles)); 540 } 541 542 /** 543 * Installs shipped apps 544 * 545 * This function installs all apps found in the 'apps' directory that should be enabled by default; 546 * @param bool $softErrors When updating we ignore errors and simply log them, better to have a 547 * working ownCloud at the end instead of an aborted update. 548 * @return array Array of error messages (appid => Exception) 549 */ 550 public static function installShippedApps($softErrors = false) { 551 $appManager = \OC::$server->getAppManager(); 552 $config = \OC::$server->getConfig(); 553 $errors = []; 554 foreach (\OC::$APPSROOTS as $app_dir) { 555 if ($dir = opendir($app_dir['path'])) { 556 while (false !== ($filename = readdir($dir))) { 557 if ($filename[0] !== '.' and is_dir($app_dir['path']."/$filename")) { 558 if (file_exists($app_dir['path']."/$filename/appinfo/info.xml")) { 559 if ($config->getAppValue($filename, "installed_version", null) === null) { 560 $info = OC_App::getAppInfo($filename); 561 $enabled = isset($info['default_enable']); 562 if (($enabled || in_array($filename, $appManager->getAlwaysEnabledApps())) 563 && $config->getAppValue($filename, 'enabled') !== 'no') { 564 if ($softErrors) { 565 try { 566 Installer::installShippedApp($filename); 567 } catch (HintException $e) { 568 if ($e->getPrevious() instanceof TableExistsException) { 569 $errors[$filename] = $e; 570 continue; 571 } 572 throw $e; 573 } 574 } else { 575 Installer::installShippedApp($filename); 576 } 577 $config->setAppValue($filename, 'enabled', 'yes'); 578 } 579 } 580 } 581 } 582 } 583 closedir($dir); 584 } 585 } 586 587 return $errors; 588 } 589 590 /** 591 * install an app already placed in the app folder 592 * @param string $app id of the app to install 593 * @return integer 594 */ 595 public static function installShippedApp($app) { 596 //install the database 597 $appPath = OC_App::getAppPath($app); 598 \OC_App::registerAutoloading($app, $appPath); 599 600 $ms = new MigrationService($app, \OC::$server->get(Connection::class)); 601 $ms->migrate('latest', true); 602 603 //run appinfo/install.php 604 self::includeAppScript("$appPath/appinfo/install.php"); 605 606 $info = OC_App::getAppInfo($app); 607 if (is_null($info)) { 608 return false; 609 } 610 \OC_App::setupBackgroundJobs($info['background-jobs']); 611 612 OC_App::executeRepairSteps($app, $info['repair-steps']['install']); 613 614 $config = \OC::$server->getConfig(); 615 616 $config->setAppValue($app, 'installed_version', OC_App::getAppVersion($app)); 617 if (array_key_exists('ocsid', $info)) { 618 $config->setAppValue($app, 'ocsid', $info['ocsid']); 619 } 620 621 //set remote/public handlers 622 foreach ($info['remote'] as $name => $path) { 623 $config->setAppValue('core', 'remote_'.$name, $app.'/'.$path); 624 } 625 foreach ($info['public'] as $name => $path) { 626 $config->setAppValue('core', 'public_'.$name, $app.'/'.$path); 627 } 628 629 OC_App::setAppTypes($info['id']); 630 631 return $info['id']; 632 } 633 634 /** 635 * @param string $script 636 */ 637 private static function includeAppScript($script) { 638 if (file_exists($script)) { 639 include $script; 640 } 641 } 642} 643