Completed
Push — master ( f530a6...d53776 )
by John
41:35 queued 17s
created
lib/private/Installer.php 1 patch
Indentation   +595 added lines, -595 removed lines patch added patch discarded remove patch
@@ -33,599 +33,599 @@
 block discarded – undo
33 33
  * This class provides the functionality needed to install, update and remove apps
34 34
  */
35 35
 class Installer {
36
-	private ?bool $isInstanceReadyForUpdates = null;
37
-	private ?array $apps = null;
38
-
39
-	public function __construct(
40
-		private AppFetcher $appFetcher,
41
-		private IClientService $clientService,
42
-		private ITempManager $tempManager,
43
-		private LoggerInterface $logger,
44
-		private IConfig $config,
45
-		private bool $isCLI,
46
-	) {
47
-	}
48
-
49
-	/**
50
-	 * Installs an app that is located in one of the app folders already
51
-	 *
52
-	 * @param string $appId App to install
53
-	 * @param bool $forceEnable
54
-	 * @throws \Exception
55
-	 * @return string app ID
56
-	 */
57
-	public function installApp(string $appId, bool $forceEnable = false): string {
58
-		$app = \OC_App::findAppInDirectories($appId);
59
-		if ($app === false) {
60
-			throw new \Exception('App not found in any app directory');
61
-		}
62
-
63
-		$basedir = $app['path'] . '/' . $appId;
64
-
65
-		if (is_file($basedir . '/appinfo/database.xml')) {
66
-			throw new \Exception('The appinfo/database.xml file is not longer supported. Used in ' . $appId);
67
-		}
68
-
69
-		$l = \OCP\Util::getL10N('core');
70
-		$info = \OCP\Server::get(IAppManager::class)->getAppInfoByPath($basedir . '/appinfo/info.xml', $l->getLanguageCode());
71
-
72
-		if (!is_array($info)) {
73
-			throw new \Exception(
74
-				$l->t('App "%s" cannot be installed because appinfo file cannot be read.',
75
-					[$appId]
76
-				)
77
-			);
78
-		}
79
-
80
-		$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
81
-		$ignoreMax = $forceEnable || in_array($appId, $ignoreMaxApps, true);
82
-
83
-		$version = implode('.', \OCP\Util::getVersion());
84
-		if (!\OC_App::isAppCompatible($version, $info, $ignoreMax)) {
85
-			throw new \Exception(
86
-				// TODO $l
87
-				$l->t('App "%s" cannot be installed because it is not compatible with this version of the server.',
88
-					[$info['name']]
89
-				)
90
-			);
91
-		}
92
-
93
-		// check for required dependencies
94
-		\OC_App::checkAppDependencies($this->config, $l, $info, $ignoreMax);
95
-		/** @var Coordinator $coordinator */
96
-		$coordinator = \OC::$server->get(Coordinator::class);
97
-		$coordinator->runLazyRegistration($appId);
98
-		\OC_App::registerAutoloading($appId, $basedir);
99
-
100
-		$previousVersion = $this->config->getAppValue($info['id'], 'installed_version', false);
101
-		if ($previousVersion) {
102
-			OC_App::executeRepairSteps($appId, $info['repair-steps']['pre-migration']);
103
-		}
104
-
105
-		//install the database
106
-		$ms = new MigrationService($info['id'], \OCP\Server::get(Connection::class));
107
-		$ms->migrate('latest', !$previousVersion);
108
-
109
-		if ($previousVersion) {
110
-			OC_App::executeRepairSteps($appId, $info['repair-steps']['post-migration']);
111
-		}
112
-
113
-		\OC_App::setupBackgroundJobs($info['background-jobs']);
114
-
115
-		//run appinfo/install.php
116
-		self::includeAppScript($basedir . '/appinfo/install.php');
117
-
118
-		OC_App::executeRepairSteps($appId, $info['repair-steps']['install']);
119
-
120
-		$config = \OCP\Server::get(IConfig::class);
121
-		//set the installed version
122
-		$config->setAppValue($info['id'], 'installed_version', \OCP\Server::get(IAppManager::class)->getAppVersion($info['id'], false));
123
-		$config->setAppValue($info['id'], 'enabled', 'no');
124
-
125
-		//set remote/public handlers
126
-		foreach ($info['remote'] as $name => $path) {
127
-			$config->setAppValue('core', 'remote_' . $name, $info['id'] . '/' . $path);
128
-		}
129
-		foreach ($info['public'] as $name => $path) {
130
-			$config->setAppValue('core', 'public_' . $name, $info['id'] . '/' . $path);
131
-		}
132
-
133
-		OC_App::setAppTypes($info['id']);
134
-
135
-		return $info['id'];
136
-	}
137
-
138
-	/**
139
-	 * Updates the specified app from the appstore
140
-	 *
141
-	 * @param bool $allowUnstable Allow unstable releases
142
-	 */
143
-	public function updateAppstoreApp(string $appId, bool $allowUnstable = false): bool {
144
-		if ($this->isUpdateAvailable($appId, $allowUnstable)) {
145
-			try {
146
-				$this->downloadApp($appId, $allowUnstable);
147
-			} catch (\Exception $e) {
148
-				$this->logger->error($e->getMessage(), [
149
-					'exception' => $e,
150
-				]);
151
-				return false;
152
-			}
153
-			return OC_App::updateApp($appId);
154
-		}
155
-
156
-		return false;
157
-	}
158
-
159
-	/**
160
-	 * Split the certificate file in individual certs
161
-	 *
162
-	 * @param string $cert
163
-	 * @return string[]
164
-	 */
165
-	private function splitCerts(string $cert): array {
166
-		preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
167
-
168
-		return $matches[0];
169
-	}
170
-
171
-	/**
172
-	 * Downloads an app and puts it into the app directory
173
-	 *
174
-	 * @param string $appId
175
-	 * @param bool [$allowUnstable]
176
-	 *
177
-	 * @throws \Exception If the installation was not successful
178
-	 */
179
-	public function downloadApp(string $appId, bool $allowUnstable = false): void {
180
-		$appId = strtolower($appId);
181
-
182
-		$apps = $this->appFetcher->get($allowUnstable);
183
-		foreach ($apps as $app) {
184
-			if ($app['id'] === $appId) {
185
-				// Load the certificate
186
-				$certificate = new X509();
187
-				$rootCrt = file_get_contents(__DIR__ . '/../../resources/codesigning/root.crt');
188
-				$rootCrts = $this->splitCerts($rootCrt);
189
-				foreach ($rootCrts as $rootCrt) {
190
-					$certificate->loadCA($rootCrt);
191
-				}
192
-				$loadedCertificate = $certificate->loadX509($app['certificate']);
193
-
194
-				// Verify if the certificate has been revoked
195
-				$crl = new X509();
196
-				foreach ($rootCrts as $rootCrt) {
197
-					$crl->loadCA($rootCrt);
198
-				}
199
-				$crl->loadCRL(file_get_contents(__DIR__ . '/../../resources/codesigning/root.crl'));
200
-				if ($crl->validateSignature() !== true) {
201
-					throw new \Exception('Could not validate CRL signature');
202
-				}
203
-				$csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString();
204
-				$revoked = $crl->getRevoked($csn);
205
-				if ($revoked !== false) {
206
-					throw new \Exception(
207
-						sprintf(
208
-							'Certificate "%s" has been revoked',
209
-							$csn
210
-						)
211
-					);
212
-				}
213
-
214
-				// Verify if the certificate has been issued by the Nextcloud Code Authority CA
215
-				if ($certificate->validateSignature() !== true) {
216
-					throw new \Exception(
217
-						sprintf(
218
-							'App with id %s has a certificate not issued by a trusted Code Signing Authority',
219
-							$appId
220
-						)
221
-					);
222
-				}
223
-
224
-				// Verify if the certificate is issued for the requested app id
225
-				$certInfo = openssl_x509_parse($app['certificate']);
226
-				if (!isset($certInfo['subject']['CN'])) {
227
-					throw new \Exception(
228
-						sprintf(
229
-							'App with id %s has a cert with no CN',
230
-							$appId
231
-						)
232
-					);
233
-				}
234
-				if ($certInfo['subject']['CN'] !== $appId) {
235
-					throw new \Exception(
236
-						sprintf(
237
-							'App with id %s has a cert issued to %s',
238
-							$appId,
239
-							$certInfo['subject']['CN']
240
-						)
241
-					);
242
-				}
243
-
244
-				// Download the release
245
-				$tempFile = $this->tempManager->getTemporaryFile('.tar.gz');
246
-				if ($tempFile === false) {
247
-					throw new \RuntimeException('Could not create temporary file for downloading app archive.');
248
-				}
249
-
250
-				$timeout = $this->isCLI ? 0 : 120;
251
-				$client = $this->clientService->newClient();
252
-				$client->get($app['releases'][0]['download'], ['sink' => $tempFile, 'timeout' => $timeout]);
253
-
254
-				// Check if the signature actually matches the downloaded content
255
-				$certificate = openssl_get_publickey($app['certificate']);
256
-				$verified = openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512) === 1;
257
-
258
-				if ($verified === true) {
259
-					// Seems to match, let's proceed
260
-					$extractDir = $this->tempManager->getTemporaryFolder();
261
-					if ($extractDir === false) {
262
-						throw new \RuntimeException('Could not create temporary directory for unpacking app.');
263
-					}
264
-
265
-					$archive = new TAR($tempFile);
266
-					if (!$archive->extract($extractDir)) {
267
-						$errorMessage = 'Could not extract app ' . $appId;
268
-
269
-						$archiveError = $archive->getError();
270
-						if ($archiveError instanceof \PEAR_Error) {
271
-							$errorMessage .= ': ' . $archiveError->getMessage();
272
-						}
273
-
274
-						throw new \Exception($errorMessage);
275
-					}
276
-					$allFiles = scandir($extractDir);
277
-					$folders = array_diff($allFiles, ['.', '..']);
278
-					$folders = array_values($folders);
279
-
280
-					if (count($folders) < 1) {
281
-						throw new \Exception(
282
-							sprintf(
283
-								'Extracted app %s has no folders',
284
-								$appId
285
-							)
286
-						);
287
-					}
288
-
289
-					if (count($folders) > 1) {
290
-						throw new \Exception(
291
-							sprintf(
292
-								'Extracted app %s has more than 1 folder',
293
-								$appId
294
-							)
295
-						);
296
-					}
297
-
298
-					// Check if appinfo/info.xml has the same app ID as well
299
-					$xml = simplexml_load_string(file_get_contents($extractDir . '/' . $folders[0] . '/appinfo/info.xml'));
300
-
301
-					if ($xml === false) {
302
-						throw new \Exception(
303
-							sprintf(
304
-								'Failed to load info.xml for app id %s',
305
-								$appId,
306
-							)
307
-						);
308
-					}
309
-
310
-					if ((string)$xml->id !== $appId) {
311
-						throw new \Exception(
312
-							sprintf(
313
-								'App for id %s has a wrong app ID in info.xml: %s',
314
-								$appId,
315
-								(string)$xml->id
316
-							)
317
-						);
318
-					}
319
-
320
-					// Check if the version is lower than before
321
-					$currentVersion = \OCP\Server::get(IAppManager::class)->getAppVersion($appId, true);
322
-					$newVersion = (string)$xml->version;
323
-					if (version_compare($currentVersion, $newVersion) === 1) {
324
-						throw new \Exception(
325
-							sprintf(
326
-								'App for id %s has version %s and tried to update to lower version %s',
327
-								$appId,
328
-								$currentVersion,
329
-								$newVersion
330
-							)
331
-						);
332
-					}
333
-
334
-					$baseDir = OC_App::getInstallPath() . '/' . $appId;
335
-					// Remove old app with the ID if existent
336
-					Files::rmdirr($baseDir);
337
-					// Move to app folder
338
-					if (@mkdir($baseDir)) {
339
-						$extractDir .= '/' . $folders[0];
340
-					}
341
-					// otherwise we just copy the outer directory
342
-					$this->copyRecursive($extractDir, $baseDir);
343
-					Files::rmdirr($extractDir);
344
-					if (function_exists('opcache_reset')) {
345
-						opcache_reset();
346
-					}
347
-					return;
348
-				}
349
-				// Signature does not match
350
-				throw new \Exception(
351
-					sprintf(
352
-						'App with id %s has invalid signature',
353
-						$appId
354
-					)
355
-				);
356
-			}
357
-		}
358
-
359
-		throw new \Exception(
360
-			sprintf(
361
-				'Could not download app %s',
362
-				$appId
363
-			)
364
-		);
365
-	}
366
-
367
-	/**
368
-	 * Check if an update for the app is available
369
-	 *
370
-	 * @param string $appId
371
-	 * @param bool $allowUnstable
372
-	 * @return string|false false or the version number of the update
373
-	 */
374
-	public function isUpdateAvailable($appId, $allowUnstable = false): string|false {
375
-		if ($this->isInstanceReadyForUpdates === null) {
376
-			$installPath = OC_App::getInstallPath();
377
-			if ($installPath === null) {
378
-				$this->isInstanceReadyForUpdates = false;
379
-			} else {
380
-				$this->isInstanceReadyForUpdates = true;
381
-			}
382
-		}
383
-
384
-		if ($this->isInstanceReadyForUpdates === false) {
385
-			return false;
386
-		}
387
-
388
-		if ($this->isInstalledFromGit($appId) === true) {
389
-			return false;
390
-		}
391
-
392
-		if ($this->apps === null) {
393
-			$this->apps = $this->appFetcher->get($allowUnstable);
394
-		}
395
-
396
-		foreach ($this->apps as $app) {
397
-			if ($app['id'] === $appId) {
398
-				$currentVersion = \OCP\Server::get(IAppManager::class)->getAppVersion($appId, true);
399
-
400
-				if (!isset($app['releases'][0]['version'])) {
401
-					return false;
402
-				}
403
-				$newestVersion = $app['releases'][0]['version'];
404
-				if ($currentVersion !== '0' && version_compare($newestVersion, $currentVersion, '>')) {
405
-					return $newestVersion;
406
-				} else {
407
-					return false;
408
-				}
409
-			}
410
-		}
411
-
412
-		return false;
413
-	}
414
-
415
-	/**
416
-	 * Check if app has been installed from git
417
-	 *
418
-	 * The function will check if the path contains a .git folder
419
-	 */
420
-	private function isInstalledFromGit(string $appId): bool {
421
-		$app = \OC_App::findAppInDirectories($appId);
422
-		if ($app === false) {
423
-			return false;
424
-		}
425
-		$basedir = $app['path'] . '/' . $appId;
426
-		return file_exists($basedir . '/.git/');
427
-	}
428
-
429
-	/**
430
-	 * Check if app is already downloaded
431
-	 *
432
-	 * The function will check if the app is already downloaded in the apps repository
433
-	 */
434
-	public function isDownloaded(string $name): bool {
435
-		foreach (\OC::$APPSROOTS as $dir) {
436
-			$dirToTest = $dir['path'];
437
-			$dirToTest .= '/';
438
-			$dirToTest .= $name;
439
-			$dirToTest .= '/';
440
-
441
-			if (is_dir($dirToTest)) {
442
-				return true;
443
-			}
444
-		}
445
-
446
-		return false;
447
-	}
448
-
449
-	/**
450
-	 * Removes an app
451
-	 *
452
-	 * This function works as follows
453
-	 *   -# call uninstall repair steps
454
-	 *   -# removing the files
455
-	 *
456
-	 * The function will not delete preferences, tables and the configuration,
457
-	 * this has to be done by the function oc_app_uninstall().
458
-	 */
459
-	public function removeApp(string $appId): bool {
460
-		if ($this->isDownloaded($appId)) {
461
-			if (\OCP\Server::get(IAppManager::class)->isShipped($appId)) {
462
-				return false;
463
-			}
464
-			$appDir = OC_App::getInstallPath() . '/' . $appId;
465
-			Files::rmdirr($appDir);
466
-			return true;
467
-		} else {
468
-			$this->logger->error('can\'t remove app ' . $appId . '. It is not installed.');
469
-
470
-			return false;
471
-		}
472
-	}
473
-
474
-	/**
475
-	 * Installs the app within the bundle and marks the bundle as installed
476
-	 *
477
-	 * @throws \Exception If app could not get installed
478
-	 */
479
-	public function installAppBundle(Bundle $bundle): void {
480
-		$appIds = $bundle->getAppIdentifiers();
481
-		foreach ($appIds as $appId) {
482
-			if (!$this->isDownloaded($appId)) {
483
-				$this->downloadApp($appId);
484
-			}
485
-			$this->installApp($appId);
486
-			$app = new OC_App();
487
-			$app->enable($appId);
488
-		}
489
-		$bundles = json_decode($this->config->getAppValue('core', 'installed.bundles', json_encode([])), true);
490
-		$bundles[] = $bundle->getIdentifier();
491
-		$this->config->setAppValue('core', 'installed.bundles', json_encode($bundles));
492
-	}
493
-
494
-	/**
495
-	 * Installs shipped apps
496
-	 *
497
-	 * This function installs all apps found in the 'apps' directory that should be enabled by default;
498
-	 * @param bool $softErrors When updating we ignore errors and simply log them, better to have a
499
-	 *                         working ownCloud at the end instead of an aborted update.
500
-	 * @return array Array of error messages (appid => Exception)
501
-	 */
502
-	public static function installShippedApps(bool $softErrors = false, ?IOutput $output = null): array {
503
-		if ($output instanceof IOutput) {
504
-			$output->debug('Installing shipped apps');
505
-		}
506
-		$appManager = \OCP\Server::get(IAppManager::class);
507
-		$config = \OCP\Server::get(IConfig::class);
508
-		$errors = [];
509
-		foreach (\OC::$APPSROOTS as $app_dir) {
510
-			if ($dir = opendir($app_dir['path'])) {
511
-				while (false !== ($filename = readdir($dir))) {
512
-					if ($filename[0] !== '.' and is_dir($app_dir['path'] . "/$filename")) {
513
-						if (file_exists($app_dir['path'] . "/$filename/appinfo/info.xml")) {
514
-							if ($config->getAppValue($filename, 'installed_version', null) === null) {
515
-								$enabled = $appManager->isDefaultEnabled($filename);
516
-								if (($enabled || in_array($filename, $appManager->getAlwaysEnabledApps()))
517
-									  && $config->getAppValue($filename, 'enabled') !== 'no') {
518
-									if ($softErrors) {
519
-										try {
520
-											Installer::installShippedApp($filename, $output);
521
-										} catch (HintException $e) {
522
-											if ($e->getPrevious() instanceof TableExistsException) {
523
-												$errors[$filename] = $e;
524
-												continue;
525
-											}
526
-											throw $e;
527
-										}
528
-									} else {
529
-										Installer::installShippedApp($filename, $output);
530
-									}
531
-									$config->setAppValue($filename, 'enabled', 'yes');
532
-								}
533
-							}
534
-						}
535
-					}
536
-				}
537
-				closedir($dir);
538
-			}
539
-		}
540
-
541
-		return $errors;
542
-	}
543
-
544
-	/**
545
-	 * install an app already placed in the app folder
546
-	 */
547
-	public static function installShippedApp(string $app, ?IOutput $output = null): string|false {
548
-		if ($output instanceof IOutput) {
549
-			$output->debug('Installing ' . $app);
550
-		}
551
-
552
-		$appManager = \OCP\Server::get(IAppManager::class);
553
-		$config = \OCP\Server::get(IConfig::class);
554
-
555
-		$appPath = $appManager->getAppPath($app);
556
-		\OC_App::registerAutoloading($app, $appPath);
557
-
558
-		$ms = new MigrationService($app, \OCP\Server::get(Connection::class));
559
-		if ($output instanceof IOutput) {
560
-			$ms->setOutput($output);
561
-		}
562
-		$previousVersion = $config->getAppValue($app, 'installed_version', false);
563
-		$ms->migrate('latest', !$previousVersion);
564
-
565
-		//run appinfo/install.php
566
-		self::includeAppScript("$appPath/appinfo/install.php");
567
-
568
-		$info = \OCP\Server::get(IAppManager::class)->getAppInfo($app);
569
-		if (is_null($info)) {
570
-			return false;
571
-		}
572
-		if ($output instanceof IOutput) {
573
-			$output->debug('Registering tasks of ' . $app);
574
-		}
575
-		\OC_App::setupBackgroundJobs($info['background-jobs']);
576
-
577
-		OC_App::executeRepairSteps($app, $info['repair-steps']['install']);
578
-
579
-		$config->setAppValue($app, 'installed_version', \OCP\Server::get(IAppManager::class)->getAppVersion($app));
580
-		if (array_key_exists('ocsid', $info)) {
581
-			$config->setAppValue($app, 'ocsid', $info['ocsid']);
582
-		}
583
-
584
-		//set remote/public handlers
585
-		foreach ($info['remote'] as $name => $path) {
586
-			$config->setAppValue('core', 'remote_' . $name, $app . '/' . $path);
587
-		}
588
-		foreach ($info['public'] as $name => $path) {
589
-			$config->setAppValue('core', 'public_' . $name, $app . '/' . $path);
590
-		}
591
-
592
-		OC_App::setAppTypes($info['id']);
593
-
594
-		return $info['id'];
595
-	}
596
-
597
-	private static function includeAppScript(string $script): void {
598
-		if (file_exists($script)) {
599
-			include $script;
600
-		}
601
-	}
602
-
603
-	/**
604
-	 * Recursive copying of local folders.
605
-	 *
606
-	 * @param string $src source folder
607
-	 * @param string $dest target folder
608
-	 */
609
-	private function copyRecursive(string $src, string $dest): void {
610
-		if (!file_exists($src)) {
611
-			return;
612
-		}
613
-
614
-		if (is_dir($src)) {
615
-			if (!is_dir($dest)) {
616
-				mkdir($dest);
617
-			}
618
-			$files = scandir($src);
619
-			foreach ($files as $file) {
620
-				if ($file != '.' && $file != '..') {
621
-					$this->copyRecursive("$src/$file", "$dest/$file");
622
-				}
623
-			}
624
-		} else {
625
-			$validator = Server::get(FilenameValidator::class);
626
-			if (!$validator->isForbidden($src)) {
627
-				copy($src, $dest);
628
-			}
629
-		}
630
-	}
36
+    private ?bool $isInstanceReadyForUpdates = null;
37
+    private ?array $apps = null;
38
+
39
+    public function __construct(
40
+        private AppFetcher $appFetcher,
41
+        private IClientService $clientService,
42
+        private ITempManager $tempManager,
43
+        private LoggerInterface $logger,
44
+        private IConfig $config,
45
+        private bool $isCLI,
46
+    ) {
47
+    }
48
+
49
+    /**
50
+     * Installs an app that is located in one of the app folders already
51
+     *
52
+     * @param string $appId App to install
53
+     * @param bool $forceEnable
54
+     * @throws \Exception
55
+     * @return string app ID
56
+     */
57
+    public function installApp(string $appId, bool $forceEnable = false): string {
58
+        $app = \OC_App::findAppInDirectories($appId);
59
+        if ($app === false) {
60
+            throw new \Exception('App not found in any app directory');
61
+        }
62
+
63
+        $basedir = $app['path'] . '/' . $appId;
64
+
65
+        if (is_file($basedir . '/appinfo/database.xml')) {
66
+            throw new \Exception('The appinfo/database.xml file is not longer supported. Used in ' . $appId);
67
+        }
68
+
69
+        $l = \OCP\Util::getL10N('core');
70
+        $info = \OCP\Server::get(IAppManager::class)->getAppInfoByPath($basedir . '/appinfo/info.xml', $l->getLanguageCode());
71
+
72
+        if (!is_array($info)) {
73
+            throw new \Exception(
74
+                $l->t('App "%s" cannot be installed because appinfo file cannot be read.',
75
+                    [$appId]
76
+                )
77
+            );
78
+        }
79
+
80
+        $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
81
+        $ignoreMax = $forceEnable || in_array($appId, $ignoreMaxApps, true);
82
+
83
+        $version = implode('.', \OCP\Util::getVersion());
84
+        if (!\OC_App::isAppCompatible($version, $info, $ignoreMax)) {
85
+            throw new \Exception(
86
+                // TODO $l
87
+                $l->t('App "%s" cannot be installed because it is not compatible with this version of the server.',
88
+                    [$info['name']]
89
+                )
90
+            );
91
+        }
92
+
93
+        // check for required dependencies
94
+        \OC_App::checkAppDependencies($this->config, $l, $info, $ignoreMax);
95
+        /** @var Coordinator $coordinator */
96
+        $coordinator = \OC::$server->get(Coordinator::class);
97
+        $coordinator->runLazyRegistration($appId);
98
+        \OC_App::registerAutoloading($appId, $basedir);
99
+
100
+        $previousVersion = $this->config->getAppValue($info['id'], 'installed_version', false);
101
+        if ($previousVersion) {
102
+            OC_App::executeRepairSteps($appId, $info['repair-steps']['pre-migration']);
103
+        }
104
+
105
+        //install the database
106
+        $ms = new MigrationService($info['id'], \OCP\Server::get(Connection::class));
107
+        $ms->migrate('latest', !$previousVersion);
108
+
109
+        if ($previousVersion) {
110
+            OC_App::executeRepairSteps($appId, $info['repair-steps']['post-migration']);
111
+        }
112
+
113
+        \OC_App::setupBackgroundJobs($info['background-jobs']);
114
+
115
+        //run appinfo/install.php
116
+        self::includeAppScript($basedir . '/appinfo/install.php');
117
+
118
+        OC_App::executeRepairSteps($appId, $info['repair-steps']['install']);
119
+
120
+        $config = \OCP\Server::get(IConfig::class);
121
+        //set the installed version
122
+        $config->setAppValue($info['id'], 'installed_version', \OCP\Server::get(IAppManager::class)->getAppVersion($info['id'], false));
123
+        $config->setAppValue($info['id'], 'enabled', 'no');
124
+
125
+        //set remote/public handlers
126
+        foreach ($info['remote'] as $name => $path) {
127
+            $config->setAppValue('core', 'remote_' . $name, $info['id'] . '/' . $path);
128
+        }
129
+        foreach ($info['public'] as $name => $path) {
130
+            $config->setAppValue('core', 'public_' . $name, $info['id'] . '/' . $path);
131
+        }
132
+
133
+        OC_App::setAppTypes($info['id']);
134
+
135
+        return $info['id'];
136
+    }
137
+
138
+    /**
139
+     * Updates the specified app from the appstore
140
+     *
141
+     * @param bool $allowUnstable Allow unstable releases
142
+     */
143
+    public function updateAppstoreApp(string $appId, bool $allowUnstable = false): bool {
144
+        if ($this->isUpdateAvailable($appId, $allowUnstable)) {
145
+            try {
146
+                $this->downloadApp($appId, $allowUnstable);
147
+            } catch (\Exception $e) {
148
+                $this->logger->error($e->getMessage(), [
149
+                    'exception' => $e,
150
+                ]);
151
+                return false;
152
+            }
153
+            return OC_App::updateApp($appId);
154
+        }
155
+
156
+        return false;
157
+    }
158
+
159
+    /**
160
+     * Split the certificate file in individual certs
161
+     *
162
+     * @param string $cert
163
+     * @return string[]
164
+     */
165
+    private function splitCerts(string $cert): array {
166
+        preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
167
+
168
+        return $matches[0];
169
+    }
170
+
171
+    /**
172
+     * Downloads an app and puts it into the app directory
173
+     *
174
+     * @param string $appId
175
+     * @param bool [$allowUnstable]
176
+     *
177
+     * @throws \Exception If the installation was not successful
178
+     */
179
+    public function downloadApp(string $appId, bool $allowUnstable = false): void {
180
+        $appId = strtolower($appId);
181
+
182
+        $apps = $this->appFetcher->get($allowUnstable);
183
+        foreach ($apps as $app) {
184
+            if ($app['id'] === $appId) {
185
+                // Load the certificate
186
+                $certificate = new X509();
187
+                $rootCrt = file_get_contents(__DIR__ . '/../../resources/codesigning/root.crt');
188
+                $rootCrts = $this->splitCerts($rootCrt);
189
+                foreach ($rootCrts as $rootCrt) {
190
+                    $certificate->loadCA($rootCrt);
191
+                }
192
+                $loadedCertificate = $certificate->loadX509($app['certificate']);
193
+
194
+                // Verify if the certificate has been revoked
195
+                $crl = new X509();
196
+                foreach ($rootCrts as $rootCrt) {
197
+                    $crl->loadCA($rootCrt);
198
+                }
199
+                $crl->loadCRL(file_get_contents(__DIR__ . '/../../resources/codesigning/root.crl'));
200
+                if ($crl->validateSignature() !== true) {
201
+                    throw new \Exception('Could not validate CRL signature');
202
+                }
203
+                $csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString();
204
+                $revoked = $crl->getRevoked($csn);
205
+                if ($revoked !== false) {
206
+                    throw new \Exception(
207
+                        sprintf(
208
+                            'Certificate "%s" has been revoked',
209
+                            $csn
210
+                        )
211
+                    );
212
+                }
213
+
214
+                // Verify if the certificate has been issued by the Nextcloud Code Authority CA
215
+                if ($certificate->validateSignature() !== true) {
216
+                    throw new \Exception(
217
+                        sprintf(
218
+                            'App with id %s has a certificate not issued by a trusted Code Signing Authority',
219
+                            $appId
220
+                        )
221
+                    );
222
+                }
223
+
224
+                // Verify if the certificate is issued for the requested app id
225
+                $certInfo = openssl_x509_parse($app['certificate']);
226
+                if (!isset($certInfo['subject']['CN'])) {
227
+                    throw new \Exception(
228
+                        sprintf(
229
+                            'App with id %s has a cert with no CN',
230
+                            $appId
231
+                        )
232
+                    );
233
+                }
234
+                if ($certInfo['subject']['CN'] !== $appId) {
235
+                    throw new \Exception(
236
+                        sprintf(
237
+                            'App with id %s has a cert issued to %s',
238
+                            $appId,
239
+                            $certInfo['subject']['CN']
240
+                        )
241
+                    );
242
+                }
243
+
244
+                // Download the release
245
+                $tempFile = $this->tempManager->getTemporaryFile('.tar.gz');
246
+                if ($tempFile === false) {
247
+                    throw new \RuntimeException('Could not create temporary file for downloading app archive.');
248
+                }
249
+
250
+                $timeout = $this->isCLI ? 0 : 120;
251
+                $client = $this->clientService->newClient();
252
+                $client->get($app['releases'][0]['download'], ['sink' => $tempFile, 'timeout' => $timeout]);
253
+
254
+                // Check if the signature actually matches the downloaded content
255
+                $certificate = openssl_get_publickey($app['certificate']);
256
+                $verified = openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512) === 1;
257
+
258
+                if ($verified === true) {
259
+                    // Seems to match, let's proceed
260
+                    $extractDir = $this->tempManager->getTemporaryFolder();
261
+                    if ($extractDir === false) {
262
+                        throw new \RuntimeException('Could not create temporary directory for unpacking app.');
263
+                    }
264
+
265
+                    $archive = new TAR($tempFile);
266
+                    if (!$archive->extract($extractDir)) {
267
+                        $errorMessage = 'Could not extract app ' . $appId;
268
+
269
+                        $archiveError = $archive->getError();
270
+                        if ($archiveError instanceof \PEAR_Error) {
271
+                            $errorMessage .= ': ' . $archiveError->getMessage();
272
+                        }
273
+
274
+                        throw new \Exception($errorMessage);
275
+                    }
276
+                    $allFiles = scandir($extractDir);
277
+                    $folders = array_diff($allFiles, ['.', '..']);
278
+                    $folders = array_values($folders);
279
+
280
+                    if (count($folders) < 1) {
281
+                        throw new \Exception(
282
+                            sprintf(
283
+                                'Extracted app %s has no folders',
284
+                                $appId
285
+                            )
286
+                        );
287
+                    }
288
+
289
+                    if (count($folders) > 1) {
290
+                        throw new \Exception(
291
+                            sprintf(
292
+                                'Extracted app %s has more than 1 folder',
293
+                                $appId
294
+                            )
295
+                        );
296
+                    }
297
+
298
+                    // Check if appinfo/info.xml has the same app ID as well
299
+                    $xml = simplexml_load_string(file_get_contents($extractDir . '/' . $folders[0] . '/appinfo/info.xml'));
300
+
301
+                    if ($xml === false) {
302
+                        throw new \Exception(
303
+                            sprintf(
304
+                                'Failed to load info.xml for app id %s',
305
+                                $appId,
306
+                            )
307
+                        );
308
+                    }
309
+
310
+                    if ((string)$xml->id !== $appId) {
311
+                        throw new \Exception(
312
+                            sprintf(
313
+                                'App for id %s has a wrong app ID in info.xml: %s',
314
+                                $appId,
315
+                                (string)$xml->id
316
+                            )
317
+                        );
318
+                    }
319
+
320
+                    // Check if the version is lower than before
321
+                    $currentVersion = \OCP\Server::get(IAppManager::class)->getAppVersion($appId, true);
322
+                    $newVersion = (string)$xml->version;
323
+                    if (version_compare($currentVersion, $newVersion) === 1) {
324
+                        throw new \Exception(
325
+                            sprintf(
326
+                                'App for id %s has version %s and tried to update to lower version %s',
327
+                                $appId,
328
+                                $currentVersion,
329
+                                $newVersion
330
+                            )
331
+                        );
332
+                    }
333
+
334
+                    $baseDir = OC_App::getInstallPath() . '/' . $appId;
335
+                    // Remove old app with the ID if existent
336
+                    Files::rmdirr($baseDir);
337
+                    // Move to app folder
338
+                    if (@mkdir($baseDir)) {
339
+                        $extractDir .= '/' . $folders[0];
340
+                    }
341
+                    // otherwise we just copy the outer directory
342
+                    $this->copyRecursive($extractDir, $baseDir);
343
+                    Files::rmdirr($extractDir);
344
+                    if (function_exists('opcache_reset')) {
345
+                        opcache_reset();
346
+                    }
347
+                    return;
348
+                }
349
+                // Signature does not match
350
+                throw new \Exception(
351
+                    sprintf(
352
+                        'App with id %s has invalid signature',
353
+                        $appId
354
+                    )
355
+                );
356
+            }
357
+        }
358
+
359
+        throw new \Exception(
360
+            sprintf(
361
+                'Could not download app %s',
362
+                $appId
363
+            )
364
+        );
365
+    }
366
+
367
+    /**
368
+     * Check if an update for the app is available
369
+     *
370
+     * @param string $appId
371
+     * @param bool $allowUnstable
372
+     * @return string|false false or the version number of the update
373
+     */
374
+    public function isUpdateAvailable($appId, $allowUnstable = false): string|false {
375
+        if ($this->isInstanceReadyForUpdates === null) {
376
+            $installPath = OC_App::getInstallPath();
377
+            if ($installPath === null) {
378
+                $this->isInstanceReadyForUpdates = false;
379
+            } else {
380
+                $this->isInstanceReadyForUpdates = true;
381
+            }
382
+        }
383
+
384
+        if ($this->isInstanceReadyForUpdates === false) {
385
+            return false;
386
+        }
387
+
388
+        if ($this->isInstalledFromGit($appId) === true) {
389
+            return false;
390
+        }
391
+
392
+        if ($this->apps === null) {
393
+            $this->apps = $this->appFetcher->get($allowUnstable);
394
+        }
395
+
396
+        foreach ($this->apps as $app) {
397
+            if ($app['id'] === $appId) {
398
+                $currentVersion = \OCP\Server::get(IAppManager::class)->getAppVersion($appId, true);
399
+
400
+                if (!isset($app['releases'][0]['version'])) {
401
+                    return false;
402
+                }
403
+                $newestVersion = $app['releases'][0]['version'];
404
+                if ($currentVersion !== '0' && version_compare($newestVersion, $currentVersion, '>')) {
405
+                    return $newestVersion;
406
+                } else {
407
+                    return false;
408
+                }
409
+            }
410
+        }
411
+
412
+        return false;
413
+    }
414
+
415
+    /**
416
+     * Check if app has been installed from git
417
+     *
418
+     * The function will check if the path contains a .git folder
419
+     */
420
+    private function isInstalledFromGit(string $appId): bool {
421
+        $app = \OC_App::findAppInDirectories($appId);
422
+        if ($app === false) {
423
+            return false;
424
+        }
425
+        $basedir = $app['path'] . '/' . $appId;
426
+        return file_exists($basedir . '/.git/');
427
+    }
428
+
429
+    /**
430
+     * Check if app is already downloaded
431
+     *
432
+     * The function will check if the app is already downloaded in the apps repository
433
+     */
434
+    public function isDownloaded(string $name): bool {
435
+        foreach (\OC::$APPSROOTS as $dir) {
436
+            $dirToTest = $dir['path'];
437
+            $dirToTest .= '/';
438
+            $dirToTest .= $name;
439
+            $dirToTest .= '/';
440
+
441
+            if (is_dir($dirToTest)) {
442
+                return true;
443
+            }
444
+        }
445
+
446
+        return false;
447
+    }
448
+
449
+    /**
450
+     * Removes an app
451
+     *
452
+     * This function works as follows
453
+     *   -# call uninstall repair steps
454
+     *   -# removing the files
455
+     *
456
+     * The function will not delete preferences, tables and the configuration,
457
+     * this has to be done by the function oc_app_uninstall().
458
+     */
459
+    public function removeApp(string $appId): bool {
460
+        if ($this->isDownloaded($appId)) {
461
+            if (\OCP\Server::get(IAppManager::class)->isShipped($appId)) {
462
+                return false;
463
+            }
464
+            $appDir = OC_App::getInstallPath() . '/' . $appId;
465
+            Files::rmdirr($appDir);
466
+            return true;
467
+        } else {
468
+            $this->logger->error('can\'t remove app ' . $appId . '. It is not installed.');
469
+
470
+            return false;
471
+        }
472
+    }
473
+
474
+    /**
475
+     * Installs the app within the bundle and marks the bundle as installed
476
+     *
477
+     * @throws \Exception If app could not get installed
478
+     */
479
+    public function installAppBundle(Bundle $bundle): void {
480
+        $appIds = $bundle->getAppIdentifiers();
481
+        foreach ($appIds as $appId) {
482
+            if (!$this->isDownloaded($appId)) {
483
+                $this->downloadApp($appId);
484
+            }
485
+            $this->installApp($appId);
486
+            $app = new OC_App();
487
+            $app->enable($appId);
488
+        }
489
+        $bundles = json_decode($this->config->getAppValue('core', 'installed.bundles', json_encode([])), true);
490
+        $bundles[] = $bundle->getIdentifier();
491
+        $this->config->setAppValue('core', 'installed.bundles', json_encode($bundles));
492
+    }
493
+
494
+    /**
495
+     * Installs shipped apps
496
+     *
497
+     * This function installs all apps found in the 'apps' directory that should be enabled by default;
498
+     * @param bool $softErrors When updating we ignore errors and simply log them, better to have a
499
+     *                         working ownCloud at the end instead of an aborted update.
500
+     * @return array Array of error messages (appid => Exception)
501
+     */
502
+    public static function installShippedApps(bool $softErrors = false, ?IOutput $output = null): array {
503
+        if ($output instanceof IOutput) {
504
+            $output->debug('Installing shipped apps');
505
+        }
506
+        $appManager = \OCP\Server::get(IAppManager::class);
507
+        $config = \OCP\Server::get(IConfig::class);
508
+        $errors = [];
509
+        foreach (\OC::$APPSROOTS as $app_dir) {
510
+            if ($dir = opendir($app_dir['path'])) {
511
+                while (false !== ($filename = readdir($dir))) {
512
+                    if ($filename[0] !== '.' and is_dir($app_dir['path'] . "/$filename")) {
513
+                        if (file_exists($app_dir['path'] . "/$filename/appinfo/info.xml")) {
514
+                            if ($config->getAppValue($filename, 'installed_version', null) === null) {
515
+                                $enabled = $appManager->isDefaultEnabled($filename);
516
+                                if (($enabled || in_array($filename, $appManager->getAlwaysEnabledApps()))
517
+                                      && $config->getAppValue($filename, 'enabled') !== 'no') {
518
+                                    if ($softErrors) {
519
+                                        try {
520
+                                            Installer::installShippedApp($filename, $output);
521
+                                        } catch (HintException $e) {
522
+                                            if ($e->getPrevious() instanceof TableExistsException) {
523
+                                                $errors[$filename] = $e;
524
+                                                continue;
525
+                                            }
526
+                                            throw $e;
527
+                                        }
528
+                                    } else {
529
+                                        Installer::installShippedApp($filename, $output);
530
+                                    }
531
+                                    $config->setAppValue($filename, 'enabled', 'yes');
532
+                                }
533
+                            }
534
+                        }
535
+                    }
536
+                }
537
+                closedir($dir);
538
+            }
539
+        }
540
+
541
+        return $errors;
542
+    }
543
+
544
+    /**
545
+     * install an app already placed in the app folder
546
+     */
547
+    public static function installShippedApp(string $app, ?IOutput $output = null): string|false {
548
+        if ($output instanceof IOutput) {
549
+            $output->debug('Installing ' . $app);
550
+        }
551
+
552
+        $appManager = \OCP\Server::get(IAppManager::class);
553
+        $config = \OCP\Server::get(IConfig::class);
554
+
555
+        $appPath = $appManager->getAppPath($app);
556
+        \OC_App::registerAutoloading($app, $appPath);
557
+
558
+        $ms = new MigrationService($app, \OCP\Server::get(Connection::class));
559
+        if ($output instanceof IOutput) {
560
+            $ms->setOutput($output);
561
+        }
562
+        $previousVersion = $config->getAppValue($app, 'installed_version', false);
563
+        $ms->migrate('latest', !$previousVersion);
564
+
565
+        //run appinfo/install.php
566
+        self::includeAppScript("$appPath/appinfo/install.php");
567
+
568
+        $info = \OCP\Server::get(IAppManager::class)->getAppInfo($app);
569
+        if (is_null($info)) {
570
+            return false;
571
+        }
572
+        if ($output instanceof IOutput) {
573
+            $output->debug('Registering tasks of ' . $app);
574
+        }
575
+        \OC_App::setupBackgroundJobs($info['background-jobs']);
576
+
577
+        OC_App::executeRepairSteps($app, $info['repair-steps']['install']);
578
+
579
+        $config->setAppValue($app, 'installed_version', \OCP\Server::get(IAppManager::class)->getAppVersion($app));
580
+        if (array_key_exists('ocsid', $info)) {
581
+            $config->setAppValue($app, 'ocsid', $info['ocsid']);
582
+        }
583
+
584
+        //set remote/public handlers
585
+        foreach ($info['remote'] as $name => $path) {
586
+            $config->setAppValue('core', 'remote_' . $name, $app . '/' . $path);
587
+        }
588
+        foreach ($info['public'] as $name => $path) {
589
+            $config->setAppValue('core', 'public_' . $name, $app . '/' . $path);
590
+        }
591
+
592
+        OC_App::setAppTypes($info['id']);
593
+
594
+        return $info['id'];
595
+    }
596
+
597
+    private static function includeAppScript(string $script): void {
598
+        if (file_exists($script)) {
599
+            include $script;
600
+        }
601
+    }
602
+
603
+    /**
604
+     * Recursive copying of local folders.
605
+     *
606
+     * @param string $src source folder
607
+     * @param string $dest target folder
608
+     */
609
+    private function copyRecursive(string $src, string $dest): void {
610
+        if (!file_exists($src)) {
611
+            return;
612
+        }
613
+
614
+        if (is_dir($src)) {
615
+            if (!is_dir($dest)) {
616
+                mkdir($dest);
617
+            }
618
+            $files = scandir($src);
619
+            foreach ($files as $file) {
620
+                if ($file != '.' && $file != '..') {
621
+                    $this->copyRecursive("$src/$file", "$dest/$file");
622
+                }
623
+            }
624
+        } else {
625
+            $validator = Server::get(FilenameValidator::class);
626
+            if (!$validator->isForbidden($src)) {
627
+                copy($src, $dest);
628
+            }
629
+        }
630
+    }
631 631
 }
Please login to merge, or discard this patch.