1<?php
2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
3//
4// All Rights Reserved. See copyright.txt for details and a complete list of authors.
5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
6// $Id$
7
8namespace Tiki\Package;
9
10use Symfony\Component\Process\Exception\ExceptionInterface as ProcessExceptionInterface;
11use Symfony\Component\Process\Process;
12
13/**
14 * Wrapper to composer.phar to allow installation of packages from the admin interface
15 */
16class ComposerCli
17{
18
19	const COMPOSER_URL = 'https://getcomposer.org/installer';
20	const COMPOSER_SETUP = 'temp/composer-setup.php';
21	const COMPOSER_PHAR = 'temp/composer.phar';
22	const COMPOSER_CONFIG = 'composer.json';
23	const COMPOSER_LOCK = 'composer.lock';
24	const COMPOSER_HOME = 'temp/composer';
25	const PHP_COMMAND_NAMES = [
26		'php',
27		// TODO: Dynamically build version part from running PHP version
28		'php56',
29		'php5.6',
30		'php5.6-cli',
31	];
32	const PHP_MIN_VERSION = '7.2.0';
33
34	const FALLBACK_COMPOSER_JSON = '{"minimum-stability": "stable","config": {"process-timeout": 5000,"bin-dir": "bin","component-dir": "vendor/components"}, "repositories": [{"type": "composer","url": "https://composer.tiki.org"}]}';
35
36	/**
37	 * @var string path to the base folder from tiki
38	 */
39	protected $basePath = '';
40
41	/**
42	 * @var string path to the folder that will be used
43	 */
44	protected $workingPath = '';
45
46	/**
47	 * @var string|null Will hold the php bin detected
48	 */
49	protected $phpCli = null;
50
51	/**
52	 * @var int timeout in seconds waiting for composer commands to execute, default 5 min (300s)
53	 */
54	protected $timeout = 300;
55
56	/**
57	 * @var null|array Result from last execution null if never executed, else an array with command, output, errors and code
58	 */
59	protected $lastResult = null;
60
61	/**
62	 * ComposerCli constructor.
63	 * @param string $basePath
64	 * @param string $workingPath
65	 */
66	public function __construct($basePath, $workingPath = null)
67	{
68		$basePath = rtrim($basePath, '/');
69		if ($basePath) {
70			$this->basePath = $basePath . '/';
71		}
72
73		if (is_null($workingPath)) {
74			$this->workingPath = $this->basePath;
75		} else {
76			$workingPath = rtrim($workingPath, '/');
77			if ($workingPath) {
78				$this->workingPath = $workingPath . '/';
79			}
80		}
81	}
82
83	/**
84	 * Returns the the current working path location
85	 * @return string
86	 */
87	public function getWorkingPath()
88	{
89		return $this->workingPath;
90	}
91
92	/**
93	 * Sets the current working path location
94	 * @return string
95	 */
96	public function setWorkingPath($path)
97	{
98		$this->workingPath = $path;
99	}
100
101	/**
102	 * Returns the location of the composer.json file
103	 * @return string
104	 */
105	public function getComposerConfigFilePath()
106	{
107		return $this->workingPath . self::COMPOSER_CONFIG;
108	}
109
110	/**
111	 * Returns the location of the composer.lock file
112	 * @return string
113	 */
114	public function getComposerLockFilePath()
115	{
116		return $this->workingPath . self::COMPOSER_LOCK;
117	}
118
119	/**
120	 * Return the composer.json parsed as array, false if the file can not be processed
121	 * @return bool|array
122	 */
123	protected function getComposerConfig()
124	{
125		if (! $this->checkConfigExists()) {
126			return false;
127		}
128		$content = json_decode(file_get_contents($this->getComposerConfigFilePath()), true);
129
130		return $content;
131	}
132
133	/**
134	 * Return the composer.json parsed as array, or a default version for the composer.json if do not exists
135	 * First try to load the dist version, if not use a hardcoded version with the minimal setup
136	 * @return array|bool
137	 */
138	public function getComposerConfigOrDefault()
139	{
140		$content = $this->getComposerConfig();
141		if (! is_array($content)) {
142			$content = [];
143		}
144
145		$distFile = $this->workingPath . self::COMPOSER_CONFIG . '.dist';
146		$distContent = [];
147		if (file_exists($distFile)) {
148			$distContent = json_decode(file_get_contents($distFile), true);
149			if (! is_array($distContent)) {
150				$distContent = [];
151			}
152		}
153
154		if (empty($distContent)) {
155			$distContent = json_decode(self::FALLBACK_COMPOSER_JSON, true);
156		}
157
158		return array_merge($distContent, $content);
159	}
160
161	/**
162	 * Return the location of the composer.phar file (in the temp folder, as downloaded by setup.sh)
163	 * @return string
164	 */
165	public function getComposerPharPath()
166	{
167		return $this->basePath . self::COMPOSER_PHAR;
168	}
169
170	/**
171	 * Check the version of the command line version of PHP
172	 *
173	 * @param $php
174	 * @return string
175	 */
176	protected function getPhpVersion($php)
177	{
178		$process = new Process([$php, '--version'], null, ['HTTP_ACCEPT_ENCODING' => '']);
179		$process->inheritEnvironmentVariables();
180		$process->run();
181		foreach (explode("\n", $process->getOutput()) as $line) {
182			$parts = explode(' ', $line);
183			if ($parts[0] === 'PHP') {
184				return $parts[1];
185			}
186		}
187
188		return '';
189	}
190
191	/**
192	 * Attempts to resolve the location of the PHP binary
193	 *
194	 * @return null|bool|string
195	 */
196	protected function getPhpPath()
197	{
198		if (! is_null($this->phpCli)) {
199			return $this->phpCli;
200		}
201
202		$this->phpCli = false;
203
204		// try to check the PHP binary path using operating system resolution mechanisms
205		foreach (self::PHP_COMMAND_NAMES as $cli) {
206			$possibleCli = $cli;
207			$prefix = 'command';
208			if (\TikiInit::isWindows()) {
209				$possibleCli .= '.exe';
210				$prefix = 'where';
211			}
212			$process = new Process([$prefix, $possibleCli], null, ['HTTP_ACCEPT_ENCODING' => '']);
213			$process->inheritEnvironmentVariables();
214			$process->setTimeout($this->timeout);
215			$process->run();
216			$output = $process->getOutput();
217			if ($output) {
218				$this->phpCli = trim($output);
219				return $this->phpCli;
220			}
221		}
222
223		// Fall back to path search
224		foreach (explode(PATH_SEPARATOR, $_SERVER['PATH']) as $path) {
225			foreach (self::PHP_COMMAND_NAMES as $cli) {
226				$possibleCli = $path . DIRECTORY_SEPARATOR . $cli;
227				if (\TikiInit::isWindows()) {
228					$possibleCli .= '.exe';
229				}
230				if (file_exists($possibleCli) && is_executable($possibleCli)) {
231					$version = $this->getPhpVersion($possibleCli);
232					if (version_compare($version, self::PHP_MIN_VERSION, '<')) {
233						continue;
234					}
235					$this->phpCli = $possibleCli;
236
237					return $this->phpCli;
238				}
239			}
240		}
241
242		return $this->phpCli;
243	}
244
245	/**
246	 * Evaluates if composer can be executed
247	 *
248	 * @return bool
249	 */
250	public function canExecuteComposer()
251	{
252		static $canExecute = null;
253		if (! is_null($canExecute)) {
254			return $canExecute;
255		}
256
257		$canExecute = false;
258
259		if ($this->composerPharExists()) {
260			list($output) = $this->execComposer(['--no-ansi', '--version']);
261			if (strncmp($output, 'Composer', 8) == 0) {
262				$canExecute = true;
263			}
264		}
265
266		return $canExecute;
267	}
268
269	/**
270	 * Check if composer.phar exists
271	 *
272	 * @return bool
273	 */
274	public function composerPharExists()
275	{
276		return file_exists($this->getComposerPharPath());
277	}
278
279	/**
280	 * Execute Composer
281	 *
282	 * @param $args
283	 * @return array
284	 */
285	protected function execComposer($args)
286	{
287		if (! is_array($args)) {
288			$args = [$args];
289		}
290
291		$command = $output = $errors = '';
292
293		try {
294			$composerPath = $this->getComposerPharPath();
295			array_unshift($args, $composerPath);
296
297			$cmd = $this->getPhpPath();
298			if ($cmd) {
299				array_unshift($args, $cmd);
300			}
301
302			if (! getenv('COMPOSER_HOME')) {
303				$env['COMPOSER_HOME'] = $this->basePath . self::COMPOSER_HOME;
304			}
305			// HTTP_ACCEPT_ENCODING interfere with the composer output, so set it to know value
306			$env['HTTP_ACCEPT_ENCODING'] = '';
307
308			$process = new Process($args, null, $env);
309			$process->inheritEnvironmentVariables();
310			$command = $process->getCommandLine();
311			$process->setTimeout($this->timeout);
312			$process->run();
313
314			$code = $process->getExitCode();
315
316			$output = $process->getOutput();
317			$errors = $process->getErrorOutput();
318		} catch (ProcessExceptionInterface $e) {
319			$errors .= $e->getMessage();
320			$code = 1;
321		}
322
323		$this->lastResult = [
324			'command' => $command,
325			'output' => $output,
326			'errors' => $errors,
327			'code' => $code
328		];
329
330		return [$output, $errors, $code];
331	}
332
333	/**
334	 * Execute show command
335	 *
336	 * @return array
337	 */
338	protected function execShow()
339	{
340		if (! $this->canExecuteComposer()) {
341			return [];
342		}
343		list($result) = $this->execComposer(['--format=json', 'show', '-d', $this->workingPath]);
344		$json = json_decode($result, true);
345
346		return $json;
347	}
348
349	/**
350	 * Execute Clear-Cache command
351	 *
352	 * @return array
353	 */
354	public function execClearCache()
355	{
356		if (! $this->canExecuteComposer()) {
357			return [];
358		}
359		list(, $errors, ) = $this->execComposer(['clear-cache']);
360
361		return $errors;
362	}
363
364
365	/**
366	 * Check if the composer.json file exists
367	 *
368	 * @return bool
369	 */
370	public function checkConfigExists()
371	{
372		return file_exists($this->getComposerConfigFilePath());
373	}
374
375	/**
376	 * Retrieve list of packages in composer.json
377	 *
378	 * @return array|bool
379	 */
380	public function getListOfPackagesFromConfig()
381	{
382		if (! $this->checkConfigExists()) {
383			return false;
384		}
385
386		$content = json_decode(file_get_contents($this->getComposerConfigFilePath()), true);
387		$composerShow = $this->execShow();
388
389		$installedPackages = [];
390		if (isset($composerShow['installed']) && is_array($composerShow['installed'])) {
391			foreach ($composerShow['installed'] as $package) {
392				$installedPackages[$this->normalizePackageName($package['name'])] = $package;
393			}
394		}
395
396		$result = [];
397		if (isset($content['require']) && is_array($content['require'])) {
398			foreach ($content['require'] as $name => $version) {
399				if (isset($installedPackages[$this->normalizePackageName($name)])) {
400					$result[] = [
401						'name' => $name,
402						'status' => ComposerManager::STATUS_INSTALLED,
403						'required' => $version,
404						'installed' => $installedPackages[$name]['version'],
405					];
406				} else {
407					$result[] = [
408						'name' => $name,
409						'status' => ComposerManager::STATUS_MISSING,
410						'required' => $version,
411						'installed' => '',
412					];
413				}
414			}
415		}
416
417		return $result;
418	}
419
420	/**
421	 * Get list of packages from the composer.lock file
422	 * @return array|bool
423	 */
424	public function getListOfPackagesFromLock()
425	{
426		if (! $this->checkConfigExists()) {
427			return false;
428		}
429
430		$content = json_decode(file_get_contents($this->getComposerLockFilePath()), true);
431		$packagesFromConfig = json_decode(file_get_contents($this->getComposerConfigFilePath()), true);
432
433		if (empty($content['packages']) || empty($packagesFromConfig)) {
434			return [];
435		}
436
437		// We will create a map with the required values to prevent extra logic afterwards
438		$configRequiredMap = [];
439		foreach ($packagesFromConfig['require'] as $packageName => $packageVersion) {
440			$configRequiredMap[$packageName] = $packageVersion;
441		}
442
443		$result = [];
444		foreach ($content['packages'] as $package) {
445			if (! isset($configRequiredMap[$package['name']])) {
446				continue;
447			}
448
449			$result[$package['name']] = [
450				'name'      => $package['name'],
451				'status'    => ComposerManager::STATUS_INSTALLED,
452				'required'  => $configRequiredMap[$package['name']],
453				'installed' => $package['version'],
454			];
455		}
456
457		return $result;
458	}
459
460	/**
461	 * Ensure packages configured in composer.json are installed
462	 *
463	 * @return bool
464	 */
465	public function installMissingPackages()
466	{
467		global $tikipath;
468		if (! $this->checkConfigExists() || ! $this->canExecuteComposer()) {
469			return false;
470		}
471
472		$exe = ['--no-ansi', '--no-dev', '--prefer-dist', 'update', '-d', $this->workingPath, 'nothing'];
473		if (is_dir($tikipath . 'vendor_bundled/vendor/phpunit')) {
474			$exe = ['--no-ansi', '--prefer-dist', 'update', '-d', $this->workingPath, 'nothing'];
475		}
476
477		list($output, $errors) = $this->execComposer($exe);
478
479		return $this->glueOutputAndErrors($output, $errors);
480	}
481
482	/**
483	 * Execute the diagnostic command
484	 *
485	 * @return array|bool
486	 */
487	public function execDiagnose()
488	{
489		if (! $this->canExecuteComposer()) {
490			return false;
491		}
492
493		list($output, $errors) = $this->execComposer(['--no-ansi', 'diagnose', '-d', $this->workingPath]);
494
495		return $this->glueOutputAndErrors($output, $errors);
496	}
497
498	/**
499	 * Install a package (from the package definition)
500	 *
501	 * @param ComposerPackage $package
502	 * @return bool|string
503	 */
504	public function installPackage(ComposerPackage $package)
505	{
506		if (! $this->canExecuteComposer()) {
507			return false;
508		}
509
510		$composerJson = $this->getComposerConfigOrDefault();
511		$composerJson = $this->addComposerPackageToJson(
512			$composerJson,
513			$package->getName(),
514			$package->getRequiredVersion(),
515			$package->getScripts()
516		);
517		$fileContent = json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
518		file_put_contents($this->getComposerConfigFilePath(), $fileContent);
519
520		$commandOutput = $this->installMissingPackages();
521
522		return tr('= New composer.json file content') . ":\n\n"
523		. $fileContent . "\n\n"
524		. tr('= Composer execution output') . ":\n\n"
525		. $commandOutput;
526	}
527
528	/**
529	 * Update a package required version (from the package definition)
530	 *
531	 * @param ComposerPackage $package
532	 * @return bool
533	 */
534	public function updatePackage(ComposerPackage $package)
535	{
536
537		if (! $this->canExecuteComposer() || ! $this->checkConfigExists()) {
538			return false;
539		}
540
541		list($commandOutput, $errors) = $this->execComposer(
542			['require', $package->getName() . ':' . $package->getRequiredVersion(), '--update-no-dev', '-d', $this->workingPath, '--no-ansi', '--no-interaction']
543		);
544
545		$fileContent = file_get_contents($this->getComposerConfigFilePath());
546
547		return tr('= New composer.json file content') . ":\n\n"
548			. $fileContent . "\n\n"
549			. tr('= Composer execution output') . ":\n\n"
550			. $this->glueOutputAndErrors($commandOutput, $errors);
551	}
552
553	/**
554	 * Remove a package (from the package definition)
555	 *
556	 * @param ComposerPackage $package
557	 * @return bool|string
558	 */
559	public function removePackage(ComposerPackage $package)
560	{
561		if (! $this->canExecuteComposer() || ! $this->checkConfigExists()) {
562			return false;
563		}
564
565		list($commandOutput, $errors) = $this->execComposer(
566			['remove', $package->getName(), '--update-no-dev', '-d', $this->workingPath, '--no-ansi', '--no-interaction']
567		);
568
569		$fileContent = file_get_contents($this->getComposerConfigFilePath());
570
571		return tr('= New composer.json file content') . ":\n\n"
572		. $fileContent . "\n\n"
573		. tr('= Composer execution output') . ":\n\n"
574		. $this->glueOutputAndErrors($commandOutput, $errors);
575	}
576
577
578	/**
579	 * Append a package to composer.json
580	 *
581	 * @param $composerJson
582	 * @param $package
583	 * @param $version
584	 * @param array $scripts
585	 * @return array
586	 */
587	public function addComposerPackageToJson($composerJson, $package, $version, $scripts = [])
588	{
589
590		$scriptsKeys = [
591			'pre-install-cmd',
592			'post-install-cmd',
593			'pre-update-cmd',
594			'post-update-cmd',
595		];
596
597		if (! is_array($composerJson)) {
598			$composerJson = [];
599		}
600		// require
601		if (! isset($composerJson['require'])) {
602			$composerJson['require'] = [];
603		}
604		if (! isset($composerJson['require'][$package])) {
605			$composerJson['require'][$package] = $version;
606		}
607
608		// scripts
609		if (is_array($scripts) && count($scripts)) {
610			if (! isset($composerJson['scripts'])) {
611				$composerJson['scripts'] = [];
612			}
613			foreach ($scriptsKeys as $type) {
614				if (! isset($scripts[$type])) {
615					continue;
616				}
617				$scriptList = $scripts[$type];
618				if (is_string($scriptList)) {
619					$scriptList = [$scriptList];
620				}
621				if (! count($scriptList)) {
622					continue;
623				}
624				if (! isset($composerJson['scripts'][$type])) {
625					$composerJson['scripts'][$type] = [];
626				}
627				foreach ($scriptList as $scriptString) {
628					$composerJson['scripts'][$type][] = $scriptString;
629				}
630				$composerJson['scripts'][$type] = array_unique($composerJson['scripts'][$type]);
631			}
632		}
633
634		return $composerJson;
635	}
636
637	/**
638	 * Normalize the package name
639	 *
640	 * @param string $packageName
641	 * @return string
642	 */
643	public function normalizePackageName($packageName)
644	{
645		return strtolower($packageName);
646	}
647
648	/**
649	 * Sets the execution timeout for composer
650	 *
651	 * @param int $timeout max amount of seconds waiting for a composer command to finish
652	 */
653	public function setTimeout($timeout)
654	{
655		$this->timeout = (int)$timeout;
656	}
657
658	/**
659	 * Retrieves the execution timeout for composer
660	 *
661	 * @return int return the value of timeout in seconds
662	 */
663	public function getTimeout()
664	{
665		return $this->timeout;
666	}
667
668	/**
669	 * Returns the result of the last composer command executed
670	 *
671	 * @return array|null last result, null for never executed, array(command, output, error, code) if executed
672	 */
673	public function getLastResult()
674	{
675		return $this->lastResult;
676	}
677
678	/**
679	 * Clear the information about the last execution result
680	 */
681	public function clearLastResult()
682	{
683		$this->lastResult = null;
684	}
685
686	/**
687	 * Glue both output ans errors, Checking if the different parts are not empty
688	 * @param $output
689	 * @param $errors
690	 * @return string
691	 */
692	protected function glueOutputAndErrors($output, $errors)
693	{
694		$string = $output;
695
696		if (! empty($errors)) {
697			if (! empty($string)) {
698				$string .= "\n";
699			}
700			$string .= tr('Errors:') . "\n" . $errors;
701		}
702		return $string;
703	}
704
705	/**
706	 * Add composer.phar to temp/ folder
707	 */
708	public function installComposer()
709	{
710		$expectedSig = trim(file_get_contents('https://composer.github.io/installer.sig'));
711
712		if (! copy(self::COMPOSER_URL, self::COMPOSER_SETUP)) {
713			return [false, tr('Unable to download composer installer from %0', self::COMPOSER_URL)];
714		}
715
716		$actualSig = hash_file('SHA384', self::COMPOSER_SETUP);
717
718		if ($expectedSig !== $actualSig) {
719			unlink(self::COMPOSER_SETUP);
720			return [false, tr('Invalid composer installer signature.')];
721		}
722
723		$env = null;
724		if (! getenv('COMPOSER_HOME')) {
725			$env['COMPOSER_HOME'] = $this->basePath . self::COMPOSER_HOME;
726		}
727		$env['HTTP_ACCEPT_ENCODING'] = '';
728
729		$command = [$this->getPhpPath(), self::COMPOSER_SETUP, '--quiet', '--install-dir=temp'];
730		$process = new Process($command, null, $env);
731		$process->inheritEnvironmentVariables();
732		$process->run();
733
734		$output = $process->getOutput();
735		$result = $process->isSuccessful();
736
737		if ($result) {
738			$message = tr('composer.phar installed in temp folder.');
739		} else {
740			$message = tr('There was a problem when installing Composer.');
741		}
742
743		if (! empty($output)) {
744			$message .= '<br>' . str_replace("\n", '<br>', $output);
745		}
746
747		unlink(self::COMPOSER_SETUP);
748
749		return [$result, $message];
750	}
751
752	/**
753	 * Add composer.phar to temp/ folder
754	 */
755	public function updateComposer()
756	{
757		$env = null;
758		if (! getenv('COMPOSER_HOME')) {
759			$env['COMPOSER_HOME'] = $this->basePath . self::COMPOSER_HOME;
760		}
761		$env['HTTP_ACCEPT_ENCODING'] = '';
762
763		$command = [$this->getComposerPharPath(), 'self-update', '--no-progress'];
764		$process = new Process($command, null, $env);
765		$process->inheritEnvironmentVariables();
766		$process->start();
767		$output = '';
768		foreach ($process as $type => $data) {
769			$output .= $data;
770		}
771
772		$result = $process->isSuccessful();
773		$message = str_replace("\n", '<br>', trim($output));
774
775		return [$result, $message];
776	}
777}
778