1<?php
2/**
3 * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
4 * @author Bart Visscher <bartv@thisnet.nl>
5 * @author Brice Maron <brice@bmaron.net>
6 * @author Christian Weiske <cweiske@cweiske.de>
7 * @author Christopher Schäpers <kondou@ts.unde.re>
8 * @author Frank Karlitschek <frank@karlitschek.de>
9 * @author Georg Ehrke <georg@owncloud.com>
10 * @author Jakob Sack <mail@jakobsack.de>
11 * @author Joas Schilling <coding@schilljs.com>
12 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
13 * @author Kamil Domanski <kdomanski@kdemail.net>
14 * @author Lukas Reschke <lukas@statuscode.ch>
15 * @author michag86 <micha_g@arcor.de>
16 * @author Morris Jobke <hey@morrisjobke.de>
17 * @author Robin Appelman <icewind@owncloud.com>
18 * @author Roeland Jago Douma <rullzer@owncloud.com>
19 * @author Thomas Müller <thomas.mueller@tmit.eu>
20 * @author Thomas Tanghus <thomas@tanghus.net>
21 *
22 * @copyright Copyright (c) 2018, ownCloud GmbH
23 * @license AGPL-3.0
24 *
25 * This code is free software: you can redistribute it and/or modify
26 * it under the terms of the GNU Affero General Public License, version 3,
27 * as published by the Free Software Foundation.
28 *
29 * This program is distributed in the hope that it will be useful,
30 * but WITHOUT ANY WARRANTY; without even the implied warranty of
31 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
32 * GNU Affero General Public License for more details.
33 *
34 * You should have received a copy of the GNU Affero General Public License, version 3,
35 * along with this program.  If not, see <http://www.gnu.org/licenses/>
36 *
37 */
38
39namespace OC;
40
41use Doctrine\DBAL\Exception\TableExistsException;
42use OC\App\CodeChecker\CodeChecker;
43use OC\App\CodeChecker\EmptyCheck;
44use OC\App\CodeChecker\PrivateCheck;
45use OC\DB\MigrationService;
46use OC_App;
47use OC_DB;
48use OC_Helper;
49use OCP\App\AppAlreadyInstalledException;
50
51/**
52 * This class provides the functionality needed to install, update and remove plugins/apps
53 */
54class Installer {
55
56	/**
57	 *
58	 * This function installs an app. All information needed are passed in the
59	 * associative array $data.
60	 * The following keys are required:
61	 *   - source: string, can be "path" or "http"
62	 *
63	 * One of the following keys is required:
64	 *   - path: path to the file containing the app
65	 *   - href: link to the downloadable file containing the app
66	 *
67	 * The following keys are optional:
68	 *   - pretend: boolean, if set true the system won't do anything
69	 *   - noinstall: boolean, if true appinfo/install.php won't be loaded
70	 *   - inactive: boolean, if set true the appconfig/app.sample.php won't be
71	 *     renamed
72	 *
73	 * This function works as follows
74	 *   -# fetching the file
75	 *   -# unzipping it
76	 *   -# check the code
77	 *   -# installing the database at appinfo/database.xml
78	 *   -# including appinfo/install.php
79	 *   -# setting the installed version
80	 *
81	 * It is the task of oc_app_install to create the tables and do whatever is
82	 * needed to get the app working.
83	 *
84	 * Installs an app
85	 * @param array $data with all information
86	 * @throws \Exception
87	 * @return integer
88	 */
89	public static function installApp($data = []) {
90		$l = \OC::$server->getL10N('lib');
91
92		list($extractDir, $path) = self::downloadApp($data);
93
94		$info = self::checkAppsIntegrity($data, $extractDir, $path);
95		$appId = OC_App::cleanAppId($info['id']);
96		$appsFolder = OC_App::getInstallPath();
97
98		if ($appsFolder === null || !\is_writable($appsFolder)) {
99			throw new \Exception('Apps folder is not writable');
100		}
101		$basedir = "$appsFolder/$appId";
102		//check if the destination directory already exists
103		if (\is_dir($basedir)) {
104			OC_Helper::rmdirr($extractDir);
105			if ($data['source']=='http') {
106				\unlink($path);
107			}
108			throw new \Exception($l->t("App directory already exists"));
109		}
110
111		if (!empty($data['pretent'])) {
112			return false;
113		}
114
115		//copy the app to the correct place
116		if (@!\mkdir($basedir)) {
117			OC_Helper::rmdirr($extractDir);
118			if ($data['source']=='http') {
119				\unlink($path);
120			}
121			throw new \Exception($l->t("Can't create app folder. Please fix permissions. %s", [$basedir]));
122		}
123
124		$extractDir .= '/' . $info['id'];
125		if (!\file_exists($extractDir)) {
126			OC_Helper::rmdirr($basedir);
127			throw new \Exception($l->t("Archive does not contain a directory named %s", $info['id']));
128		}
129		OC_Helper::copyr($extractDir, $basedir);
130
131		//remove temporary files
132		OC_Helper::rmdirr($extractDir);
133
134		//install the database
135		if (isset($info['use-migrations']) && $info['use-migrations'] === 'true') {
136			$ms = new \OC\DB\MigrationService($appId, \OC::$server->getDatabaseConnection());
137			$ms->migrate();
138		} else {
139			if (\is_file($basedir.'/appinfo/database.xml')) {
140				if (\OC::$server->getAppConfig()->getValue($info['id'], 'installed_version') === null) {
141					OC_DB::createDbFromStructure($basedir . '/appinfo/database.xml');
142				} else {
143					OC_DB::updateDbFromStructure($basedir . '/appinfo/database.xml');
144				}
145			}
146		}
147
148		\OC_App::setupBackgroundJobs($info['background-jobs']);
149
150		//run appinfo/install.php
151		if ((!isset($data['noinstall']) or $data['noinstall']==false)) {
152			self::includeAppScript($basedir . '/appinfo/install.php');
153		}
154
155		$appData = OC_App::getAppInfo($appId);
156		OC_App::executeRepairSteps($appId, $appData['repair-steps']['install']);
157
158		//set the installed version
159		\OC::$server->getConfig()->setAppValue($info['id'], 'installed_version', OC_App::getAppVersion($info['id']));
160		\OC::$server->getConfig()->setAppValue($info['id'], 'enabled', 'no');
161
162		//set remote/public handlers
163		foreach ($info['remote'] as $name=>$path) {
164			\OC::$server->getConfig()->setAppValue('core', 'remote_'.$name, $info['id'].'/'.$path);
165		}
166		foreach ($info['public'] as $name=>$path) {
167			\OC::$server->getConfig()->setAppValue('core', 'public_'.$name, $info['id'].'/'.$path);
168		}
169
170		OC_App::setAppTypes($info['id']);
171
172		return $info['id'];
173	}
174
175	/**
176	 * @brief checks whether or not an app is installed
177	 * @param string $app app
178	 * @return bool
179	 *
180	 * Checks whether or not an app is installed, i.e. registered in apps table.
181	 */
182	public static function isInstalled($app) {
183		return (\OC::$server->getConfig()->getAppValue($app, "installed_version", null) !== null);
184	}
185
186	/**
187	 * @brief Update an application
188	 * @param array $info
189	 * @param bool $isShipped
190	 * @throws \Exception
191	 * @return bool
192	 *
193	 * This function could work like described below, but currently it disables and then
194	 * enables the app again. This does result in an updated app.
195	 *
196	 *
197	 * This function installs an app. All information needed are passed in the
198	 * associative array $info.
199	 * The following keys are required:
200	 *   - source: string, can be "path" or "http"
201	 *
202	 * One of the following keys is required:
203	 *   - path: path to the file containing the app
204	 *   - href: link to the downloadable file containing the app
205	 *
206	 * The following keys are optional:
207	 *   - pretend: boolean, if set true the system won't do anything
208	 *   - noupgrade: boolean, if true appinfo/upgrade.php won't be loaded
209	 *
210	 * This function works as follows
211	 *   -# fetching the file
212	 *   -# removing the old files
213	 *   -# unzipping new file
214	 *   -# including appinfo/upgrade.php
215	 *   -# setting the installed version
216	 *
217	 * upgrade.php can determine the current installed version of the app using
218	 * "\OC::$server->getAppConfig()->getValue($appid, 'installed_version')"
219	 */
220	public static function updateApp($info= [], $isShipped=false) {
221		list($extractDir, $path) = self::downloadApp($info);
222		$info = self::checkAppsIntegrity($info, $extractDir, $path, $isShipped);
223
224		$currentDir = OC_App::getAppPath($info['id']);
225		if (\is_dir("$currentDir/.git")) {
226			throw new AppAlreadyInstalledException("App <{$info['id']}> is a git clone - it will not be updated.");
227		}
228
229		$basedir  = OC_App::getInstallPath();
230		$basedir .= '/';
231		$basedir .= $info['id'];
232
233		if ($currentDir !== false && OC_App::isAppDirWritable($info['id'])) {
234			$basedir = $currentDir;
235		}
236
237		if (\is_dir($basedir)) {
238			OC_Helper::rmdirr($basedir);
239		}
240
241		$appInExtractDir = $extractDir;
242		if (\substr($extractDir, -1) !== '/') {
243			$appInExtractDir .= '/';
244		}
245
246		$appInExtractDir .= $info['id'];
247		OC_Helper::copyr($appInExtractDir, $basedir);
248		OC_Helper::rmdirr($extractDir);
249
250		return OC_App::updateApp($info['id']);
251	}
252
253	/**
254	 * @param array $data
255	 * @return array
256	 * @throws \Exception
257	 */
258	public static function downloadApp($data = []) {
259		$l = \OC::$server->getL10N('lib');
260
261		if (!isset($data['source'])) {
262			throw new \Exception($l->t("No source specified when installing app"));
263		}
264
265		//download the file if necessary
266		if ($data['source']=='http') {
267			$pathInfo = \pathinfo($data['href']);
268			$extension = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : '';
269			$path = \OC::$server->getTempManager()->getTemporaryFile($extension);
270			if (!isset($data['href'])) {
271				throw new \Exception($l->t("No href specified when installing app from http"));
272			}
273			$client = \OC::$server->getHTTPClientService()->newClient();
274			$client->get($data['href'], ['save_to' => $path]);
275		} else {
276			if (!isset($data['path'])) {
277				throw new \Exception($l->t("No path specified when installing app from local file"));
278			}
279			$path=$data['path'];
280		}
281
282		//detect the archive type
283		$mime = \OC::$server->getMimeTypeDetector()->detect($path);
284		if ($mime !=='application/zip' && $mime !== 'application/x-gzip' && $mime !== 'application/gzip' && $mime !== 'application/x-bzip2') {
285			throw new \Exception($l->t("Archives of type %s are not supported", [$mime]));
286		}
287
288		//extract the archive in a temporary folder
289		$extractDir = \OC::$server->getTempManager()->getTemporaryFolder();
290		OC_Helper::rmdirr($extractDir);
291		\mkdir($extractDir);
292		if ($archive=\OC\Archive\Archive::open($path)) {
293			$archive->extract($extractDir);
294		} else {
295			OC_Helper::rmdirr($extractDir);
296			if ($data['source']=='http') {
297				\unlink($path);
298			}
299			throw new \Exception($l->t("Failed to open archive when installing app"));
300		}
301
302		return [
303			$extractDir,
304			$path
305		];
306	}
307
308	/**
309	 * check an app's integrity
310	 * @param array $data
311	 * @param string $extractDir
312	 * @param string $path
313	 * @param bool $isShipped
314	 * @return array
315	 * @throws \Exception
316	 */
317	public static function checkAppsIntegrity($data, $extractDir, $path, $isShipped = false) {
318		$l = \OC::$server->getL10N('lib');
319		//load the info.xml file of the app
320		if (!\is_file($extractDir.'/appinfo/info.xml')) {
321			//try to find it in a subdir
322			$dh=\opendir($extractDir);
323			if (\is_resource($dh)) {
324				while (($folder = \readdir($dh)) !== false) {
325					if ($folder[0]!='.' and \is_dir($extractDir.'/'.$folder)) {
326						if (\is_file($extractDir.'/'.$folder.'/appinfo/info.xml')) {
327							$extractDir.='/'.$folder;
328						}
329					}
330				}
331			}
332		}
333		if (!\is_file($extractDir.'/appinfo/info.xml')) {
334			OC_Helper::rmdirr($extractDir);
335			if ($data['source'] === 'http') {
336				\unlink($path);
337			}
338			throw new \Exception($l->t("App does not provide an info.xml file"));
339		}
340
341		$info = OC_App::getAppInfo($extractDir.'/appinfo/info.xml', true);
342		if (!\is_array($info)) {
343			throw new \Exception($l->t('App cannot be installed because appinfo file cannot be read.'));
344		}
345
346		// We can't trust the parsed info.xml file as it may have been tampered
347		// with by an attacker and thus we need to use the local data to check
348		// whether the application needs to be signed.
349		if (\file_exists("$extractDir/appinfo/signature.json")) {
350			\OC::$server->getConfig()->setAppValue($info['id'], 'signed', 'true');
351			$integrityResult = \OC::$server->getIntegrityCodeChecker()
352				->verifyAppSignature(
353					$info['id'],
354					$extractDir
355				);
356			if ($integrityResult !== []) {
357				$e = new \Exception(
358					$l->t(
359						'Signature could not get checked. Please contact the app developer and check your admin screen.'
360					)
361				);
362				throw $e;
363			}
364		}
365
366		// check if the app is compatible with this version of ownCloud
367		if (!OC_App::isAppCompatible(\OCP\Util::getVersion(), $info)) {
368			OC_Helper::rmdirr($extractDir);
369			throw new \Exception($l->t("App can't be installed because it is not compatible with this version of ownCloud"));
370		}
371
372		// check if shipped tag is set which is only allowed for apps that are shipped with ownCloud
373		if (!$isShipped && isset($info['shipped']) && ($info['shipped']=='true')) {
374			OC_Helper::rmdirr($extractDir);
375			throw new \Exception($l->t("App can't be installed because it contains the <shipped>true</shipped> tag which is not allowed for non shipped apps"));
376		}
377
378		// check if the ocs version is the same as the version in info.xml/version
379		$version = \trim($info['version']);
380
381		if (isset($data['appdata']['version']) && $version<>\trim($data['appdata']['version'])) {
382			OC_Helper::rmdirr($extractDir);
383			throw new \Exception($l->t("App can't be installed because the version in info.xml is not the same as the version reported from the app store"));
384		}
385
386		return $info;
387	}
388
389	/**
390	 * Check if app is already downloaded
391	 * @param string $name name of the application to remove
392	 * @return boolean
393	 *
394	 * The function will check if the app is already downloaded in the apps repository
395	 */
396	public static function isDownloaded($name) {
397		foreach (\OC::$APPSROOTS as $dir) {
398			$dirToTest  = $dir['path'];
399			$dirToTest .= '/';
400			$dirToTest .= $name;
401			$dirToTest .= '/';
402
403			if (\is_dir($dirToTest)) {
404				return true;
405			}
406		}
407
408		return false;
409	}
410
411	/**
412	 * Removes an app
413	 * @param string $appId name of the application to remove
414	 * @return boolean
415	 * @throws AppAlreadyInstalledException
416	 *
417	 *
418	 * This function works as follows
419	 *   -# call uninstall repair steps
420	 *   -# removing the files
421	 *
422	 * The function will not delete preferences, tables and the configuration,
423	 * this has to be done by the function oc_app_uninstall().
424	 */
425	public static function removeApp($appId) {
426		if (Installer::isDownloaded($appId)) {
427			$appDir = OC_App::getAppPath($appId);
428			if ($appDir === false) {
429				return false;
430			}
431			if (\is_dir("$appDir/.git")) {
432				throw new AppAlreadyInstalledException("App <$appId> is a git clone - it will not be deleted.");
433			}
434
435			OC_Helper::rmdirr($appDir);
436
437			return true;
438		}
439		\OCP\Util::writeLog('core', 'can\'t remove app '.$appId.'. It is not installed.', \OCP\Util::ERROR);
440
441		return false;
442	}
443
444	protected static function getShippedApps() {
445		$shippedApps = [];
446		foreach (\OC::$APPSROOTS as $app_dir) {
447			if ($dir = \opendir($app_dir['path'])) {
448				$nodes = \scandir($app_dir['path']);
449				foreach ($nodes as $filename) {
450					// Since core 10.5.0, enterprise_key app is no longer used
451					// Be sure not to accidentally enable it if it has been
452					// left behind in an apps dir.
453					if ($filename === 'enterprise_key') {
454						continue;
455					}
456					if (\substr($filename, 0, 1) != '.' and \is_dir($app_dir['path']."/$filename")) {
457						if (\file_exists($app_dir['path']."/$filename/appinfo/info.xml")) {
458							if (!Installer::isInstalled($filename)) {
459								$info=OC_App::getAppInfo($filename);
460								$enabled = isset($info['default_enable']);
461								if (($enabled || \in_array($filename, \OC::$server->getAppManager()->getAlwaysEnabledApps()))
462									&& \OC::$server->getConfig()->getAppValue($filename, 'enabled') !== 'no') {
463									$shippedApps[] = $filename;
464								}
465							}
466						}
467					}
468				}
469				\closedir($dir);
470			}
471		}
472
473		// Fix the order - make files first
474		$shippedApps = \array_diff($shippedApps, ['files', 'dav']);
475		\array_unshift($shippedApps, 'dav');
476		\array_unshift($shippedApps, 'files');
477		return $shippedApps;
478	}
479
480	/**
481	 * Installs shipped apps
482	 *
483	 * This function installs all apps found in the 'apps' directory that should be enabled by default;
484	 * @param bool $softErrors When updating we ignore errors and simply log them, better to have a
485	 *                         working ownCloud at the end instead of an aborted update.
486	 * @return array Array of error messages (appid => Exception)
487	 */
488	public static function installShippedApps($softErrors = false) {
489		$errors = [];
490		$appsToInstall = Installer::getShippedApps();
491
492		foreach ($appsToInstall as $appToInstall) {
493			if (!Installer::isInstalled($appToInstall)) {
494				if ($softErrors) {
495					try {
496						Installer::installShippedApp($appToInstall);
497					} catch (TableExistsException $e) {
498						\OC::$server->getLogger()->logException($e, ['app' => __CLASS__]);
499						$errors[$appToInstall] = $e;
500						continue;
501					}
502				} else {
503					Installer::installShippedApp($appToInstall);
504				}
505				\OC::$server->getConfig()->setAppValue($appToInstall, 'enabled', 'yes');
506			}
507		}
508
509		return $errors;
510	}
511
512	/**
513	 * install an app already placed in the app folder
514	 * @param string $app id of the app to install
515	 * @return integer|false
516	 */
517	public static function installShippedApp($app) {
518		\OC::$server->getLogger()->info('Attempting to install shipped app: '.$app);
519
520		$info = OC_App::getAppInfo($app);
521		if ($info === null) {
522			return false;
523		}
524
525		//install the database
526		$appPath = OC_App::getAppPath($app);
527		if (isset($info['use-migrations']) && $info['use-migrations'] === 'true') {
528			\OC::$server->getLogger()->debug('Running app database migrations');
529			$ms = new MigrationService($app, \OC::$server->getDatabaseConnection());
530			$ms->migrate();
531		} else {
532			if (\is_file($appPath.'/appinfo/database.xml')) {
533				\OC::$server->getLogger()->debug('Create app database from schema file');
534				OC_DB::createDbFromStructure($appPath . '/appinfo/database.xml');
535			}
536		}
537
538		//run appinfo/install.php
539		\OC_App::registerAutoloading($app, $appPath);
540
541		\OC::$server->getLogger()->debug('Running app install script');
542		self::includeAppScript("$appPath/appinfo/install.php");
543
544		\OC_App::setupBackgroundJobs($info['background-jobs']);
545
546		\OC::$server->getLogger()->debug('Running app install repair steps');
547		OC_App::executeRepairSteps($app, $info['repair-steps']['install']);
548
549		$config = \OC::$server->getConfig();
550
551		$config->setAppValue($app, 'installed_version', OC_App::getAppVersion($app));
552
553		//set remote/public handlers
554		foreach ($info['remote'] as $name=>$path) {
555			$config->setAppValue('core', 'remote_'.$name, $app.'/'.$path);
556		}
557		foreach ($info['public'] as $name=>$path) {
558			$config->setAppValue('core', 'public_'.$name, $app.'/'.$path);
559		}
560
561		OC_App::setAppTypes($info['id']);
562
563		return $info['id'];
564	}
565
566	/**
567	 * @param $script
568	 */
569	private static function includeAppScript($script) {
570		if (\file_exists($script)) {
571			include $script;
572		}
573	}
574}
575