GPM.php 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174
  1. <?php
  2. /**
  3. * @package Grav\Common\GPM
  4. *
  5. * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\GPM;
  9. use Grav\Common\Grav;
  10. use Grav\Common\Filesystem\Folder;
  11. use Grav\Common\Inflector;
  12. use Grav\Common\Iterator;
  13. use Grav\Common\Utils;
  14. use RocketTheme\Toolbox\File\YamlFile;
  15. class GPM extends Iterator
  16. {
  17. /**
  18. * Local installed Packages
  19. * @var Local\Packages
  20. */
  21. private $installed;
  22. /**
  23. * Remote available Packages
  24. * @var Remote\Packages
  25. */
  26. private $repository;
  27. /**
  28. * @var Remote\GravCore
  29. */
  30. public $grav;
  31. /**
  32. * Internal cache
  33. * @var array
  34. */
  35. protected $cache;
  36. protected $install_paths = [
  37. 'plugins' => 'user/plugins/%name%',
  38. 'themes' => 'user/themes/%name%',
  39. 'skeletons' => 'user/'
  40. ];
  41. /**
  42. * Creates a new GPM instance with Local and Remote packages available
  43. * @param bool $refresh Applies to Remote Packages only and forces a refetch of data
  44. * @param callable $callback Either a function or callback in array notation
  45. */
  46. public function __construct($refresh = false, $callback = null)
  47. {
  48. parent::__construct();
  49. $this->cache = [];
  50. $this->installed = new Local\Packages();
  51. try {
  52. $this->repository = new Remote\Packages($refresh, $callback);
  53. $this->grav = new Remote\GravCore($refresh, $callback);
  54. } catch (\Exception $e) {
  55. }
  56. }
  57. /**
  58. * Return the locally installed packages
  59. *
  60. * @return Local\Packages
  61. */
  62. public function getInstalled()
  63. {
  64. return $this->installed;
  65. }
  66. /**
  67. * Returns the Locally installable packages
  68. *
  69. * @param array $list_type_installed
  70. * @return array The installed packages
  71. */
  72. public function getInstallable($list_type_installed = ['plugins' => true, 'themes' => true])
  73. {
  74. $items = ['total' => 0];
  75. foreach ($list_type_installed as $type => $type_installed) {
  76. if ($type_installed === false) {
  77. continue;
  78. }
  79. $methodInstallableType = 'getInstalled' . ucfirst($type);
  80. $to_install = $this->$methodInstallableType();
  81. $items[$type] = $to_install;
  82. $items['total'] += count($to_install);
  83. }
  84. return $items;
  85. }
  86. /**
  87. * Returns the amount of locally installed packages
  88. * @return int Amount of installed packages
  89. */
  90. public function countInstalled()
  91. {
  92. $installed = $this->getInstalled();
  93. return count($installed['plugins']) + count($installed['themes']);
  94. }
  95. /**
  96. * Return the instance of a specific Package
  97. *
  98. * @param string $slug The slug of the Package
  99. * @return Local\Package The instance of the Package
  100. */
  101. public function getInstalledPackage($slug)
  102. {
  103. if (isset($this->installed['plugins'][$slug])) {
  104. return $this->installed['plugins'][$slug];
  105. }
  106. if (isset($this->installed['themes'][$slug])) {
  107. return $this->installed['themes'][$slug];
  108. }
  109. return null;
  110. }
  111. /**
  112. * Return the instance of a specific Plugin
  113. * @param string $slug The slug of the Plugin
  114. * @return Local\Package The instance of the Plugin
  115. */
  116. public function getInstalledPlugin($slug)
  117. {
  118. return $this->installed['plugins'][$slug];
  119. }
  120. /**
  121. * Returns the Locally installed plugins
  122. * @return Iterator The installed plugins
  123. */
  124. public function getInstalledPlugins()
  125. {
  126. return $this->installed['plugins'];
  127. }
  128. /**
  129. * Checks if a Plugin is installed
  130. * @param string $slug The slug of the Plugin
  131. * @return bool True if the Plugin has been installed. False otherwise
  132. */
  133. public function isPluginInstalled($slug)
  134. {
  135. return isset($this->installed['plugins'][$slug]);
  136. }
  137. public function isPluginInstalledAsSymlink($slug)
  138. {
  139. return $this->installed['plugins'][$slug]->symlink;
  140. }
  141. /**
  142. * Return the instance of a specific Theme
  143. * @param string $slug The slug of the Theme
  144. * @return Local\Package The instance of the Theme
  145. */
  146. public function getInstalledTheme($slug)
  147. {
  148. return $this->installed['themes'][$slug];
  149. }
  150. /**
  151. * Returns the Locally installed themes
  152. * @return Iterator The installed themes
  153. */
  154. public function getInstalledThemes()
  155. {
  156. return $this->installed['themes'];
  157. }
  158. /**
  159. * Checks if a Theme is installed
  160. * @param string $slug The slug of the Theme
  161. * @return bool True if the Theme has been installed. False otherwise
  162. */
  163. public function isThemeInstalled($slug)
  164. {
  165. return isset($this->installed['themes'][$slug]);
  166. }
  167. /**
  168. * Returns the amount of updates available
  169. * @return int Amount of available updates
  170. */
  171. public function countUpdates()
  172. {
  173. $count = 0;
  174. $count += count($this->getUpdatablePlugins());
  175. $count += count($this->getUpdatableThemes());
  176. return $count;
  177. }
  178. /**
  179. * Returns an array of Plugins and Themes that can be updated.
  180. * Plugins and Themes are extended with the `available` property that relies to the remote version
  181. * @param array $list_type_update specifies what type of package to update
  182. * @return array Array of updatable Plugins and Themes.
  183. * Format: ['total' => int, 'plugins' => array, 'themes' => array]
  184. */
  185. public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true])
  186. {
  187. $items = ['total' => 0];
  188. foreach ($list_type_update as $type => $type_updatable) {
  189. if ($type_updatable === false) {
  190. continue;
  191. }
  192. $methodUpdatableType = 'getUpdatable' . ucfirst($type);
  193. $to_update = $this->$methodUpdatableType();
  194. $items[$type] = $to_update;
  195. $items['total'] += count($to_update);
  196. }
  197. return $items;
  198. }
  199. /**
  200. * Returns an array of Plugins that can be updated.
  201. * The Plugins are extended with the `available` property that relies to the remote version
  202. * @return array Array of updatable Plugins
  203. */
  204. public function getUpdatablePlugins()
  205. {
  206. $items = [];
  207. if (!$this->repository) {
  208. return $items;
  209. }
  210. $repository = $this->repository['plugins'];
  211. // local cache to speed things up
  212. if (isset($this->cache[__METHOD__])) {
  213. return $this->cache[__METHOD__];
  214. }
  215. foreach ($this->installed['plugins'] as $slug => $plugin) {
  216. if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
  217. continue;
  218. }
  219. $local_version = $plugin->version ?: 'Unknown';
  220. $remote_version = $repository[$slug]->version;
  221. if (version_compare($local_version, $remote_version) < 0) {
  222. $repository[$slug]->available = $remote_version;
  223. $repository[$slug]->version = $local_version;
  224. $repository[$slug]->type = $repository[$slug]->release_type;
  225. $items[$slug] = $repository[$slug];
  226. }
  227. }
  228. $this->cache[__METHOD__] = $items;
  229. return $items;
  230. }
  231. /**
  232. * Get the latest release of a package from the GPM
  233. *
  234. * @param string $package_name
  235. *
  236. * @return string|null
  237. */
  238. public function getLatestVersionOfPackage($package_name)
  239. {
  240. $repository = $this->repository['plugins'];
  241. if (isset($repository[$package_name])) {
  242. return $repository[$package_name]->available ?: $repository[$package_name]->version;
  243. }
  244. //Not a plugin, it's a theme?
  245. $repository = $this->repository['themes'];
  246. if (isset($repository[$package_name])) {
  247. return $repository[$package_name]->available ?: $repository[$package_name]->version;
  248. }
  249. return null;
  250. }
  251. /**
  252. * Check if a Plugin or Theme is updatable
  253. * @param string $slug The slug of the package
  254. * @return bool True if updatable. False otherwise or if not found
  255. */
  256. public function isUpdatable($slug)
  257. {
  258. return $this->isPluginUpdatable($slug) || $this->isThemeUpdatable($slug);
  259. }
  260. /**
  261. * Checks if a Plugin is updatable
  262. * @param string $plugin The slug of the Plugin
  263. * @return bool True if the Plugin is updatable. False otherwise
  264. */
  265. public function isPluginUpdatable($plugin)
  266. {
  267. return array_key_exists($plugin, (array)$this->getUpdatablePlugins());
  268. }
  269. /**
  270. * Returns an array of Themes that can be updated.
  271. * The Themes are extended with the `available` property that relies to the remote version
  272. * @return array Array of updatable Themes
  273. */
  274. public function getUpdatableThemes()
  275. {
  276. $items = [];
  277. if (!$this->repository) {
  278. return $items;
  279. }
  280. $repository = $this->repository['themes'];
  281. // local cache to speed things up
  282. if (isset($this->cache[__METHOD__])) {
  283. return $this->cache[__METHOD__];
  284. }
  285. foreach ($this->installed['themes'] as $slug => $plugin) {
  286. if (!isset($repository[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
  287. continue;
  288. }
  289. $local_version = $plugin->version ?: 'Unknown';
  290. $remote_version = $repository[$slug]->version;
  291. if (version_compare($local_version, $remote_version) < 0) {
  292. $repository[$slug]->available = $remote_version;
  293. $repository[$slug]->version = $local_version;
  294. $repository[$slug]->type = $repository[$slug]->release_type;
  295. $items[$slug] = $repository[$slug];
  296. }
  297. }
  298. $this->cache[__METHOD__] = $items;
  299. return $items;
  300. }
  301. /**
  302. * Checks if a Theme is Updatable
  303. * @param string $theme The slug of the Theme
  304. * @return bool True if the Theme is updatable. False otherwise
  305. */
  306. public function isThemeUpdatable($theme)
  307. {
  308. return array_key_exists($theme, (array)$this->getUpdatableThemes());
  309. }
  310. /**
  311. * Get the release type of a package (stable / testing)
  312. *
  313. * @param string $package_name
  314. *
  315. * @return string|null
  316. */
  317. public function getReleaseType($package_name)
  318. {
  319. if (!$this->repository) {
  320. return null;
  321. }
  322. $repository = $this->repository['plugins'];
  323. if (isset($repository[$package_name])) {
  324. return $repository[$package_name]->release_type;
  325. }
  326. //Not a plugin, it's a theme?
  327. $repository = $this->repository['themes'];
  328. if (isset($repository[$package_name])) {
  329. return $repository[$package_name]->release_type;
  330. }
  331. return null;
  332. }
  333. /**
  334. * Returns true if the package latest release is stable
  335. *
  336. * @param string $package_name
  337. *
  338. * @return bool
  339. */
  340. public function isStableRelease($package_name)
  341. {
  342. return $this->getReleaseType($package_name) === 'stable';
  343. }
  344. /**
  345. * Returns true if the package latest release is testing
  346. *
  347. * @param string $package_name
  348. *
  349. * @return bool
  350. */
  351. public function isTestingRelease($package_name)
  352. {
  353. $hasTesting = isset($this->getInstalledPackage($package_name)->testing);
  354. $testing = $hasTesting ? $this->getInstalledPackage($package_name)->testing : false;
  355. return $this->getReleaseType($package_name) === 'testing' || $testing;
  356. }
  357. /**
  358. * Returns a Plugin from the repository
  359. * @param string $slug The slug of the Plugin
  360. * @return mixed Package if found, NULL if not
  361. */
  362. public function getRepositoryPlugin($slug)
  363. {
  364. return $this->repository['plugins'][$slug] ?? null;
  365. }
  366. /**
  367. * Returns the list of Plugins available in the repository
  368. * @return Iterator The Plugins remotely available
  369. */
  370. public function getRepositoryPlugins()
  371. {
  372. return $this->repository['plugins'] ?? null;
  373. }
  374. /**
  375. * Returns a Theme from the repository
  376. * @param string $slug The slug of the Theme
  377. * @return mixed Package if found, NULL if not
  378. */
  379. public function getRepositoryTheme($slug)
  380. {
  381. return $this->repository['themes'][$slug] ?? null;
  382. }
  383. /**
  384. * Returns the list of Themes available in the repository
  385. * @return Iterator The Themes remotely available
  386. */
  387. public function getRepositoryThemes()
  388. {
  389. return $this->repository['themes'] ?? null;
  390. }
  391. /**
  392. * Returns the list of Plugins and Themes available in the repository
  393. * @return Remote\Packages Available Plugins and Themes
  394. * Format: ['plugins' => array, 'themes' => array]
  395. */
  396. public function getRepository()
  397. {
  398. return $this->repository;
  399. }
  400. /**
  401. * Searches for a Package in the repository
  402. * @param string $search Can be either the slug or the name
  403. * @param bool $ignore_exception True if should not fire an exception (for use in Twig)
  404. * @return Remote\Package|bool Package if found, FALSE if not
  405. */
  406. public function findPackage($search, $ignore_exception = false)
  407. {
  408. $search = strtolower($search);
  409. $found = $this->getRepositoryTheme($search);
  410. if ($found) {
  411. return $found;
  412. }
  413. $found = $this->getRepositoryPlugin($search);
  414. if ($found) {
  415. return $found;
  416. }
  417. $themes = $this->getRepositoryThemes();
  418. $plugins = $this->getRepositoryPlugins();
  419. if (!$themes && !$plugins) {
  420. if (!is_writable(ROOT_DIR . '/cache/gpm')) {
  421. throw new \RuntimeException("The cache/gpm folder is not writable. Please check the folder permissions.");
  422. }
  423. if ($ignore_exception) {
  424. return false;
  425. }
  426. throw new \RuntimeException("GPM not reachable. Please check your internet connection or check the Grav site is reachable");
  427. }
  428. if ($themes) {
  429. foreach ($themes as $slug => $theme) {
  430. if ($search == $slug || $search == $theme->name) {
  431. return $theme;
  432. }
  433. }
  434. }
  435. if ($plugins) {
  436. foreach ($plugins as $slug => $plugin) {
  437. if ($search == $slug || $search == $plugin->name) {
  438. return $plugin;
  439. }
  440. }
  441. }
  442. return false;
  443. }
  444. /**
  445. * Download the zip package via the URL
  446. *
  447. * @param string $package_file
  448. * @param string $tmp
  449. * @return null|string
  450. */
  451. public static function downloadPackage($package_file, $tmp)
  452. {
  453. $package = parse_url($package_file);
  454. $filename = basename($package['path']);
  455. if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && $package['host'] !== 'getgrav.org') {
  456. throw new \RuntimeException("Only official GPM URLs are allowed. You can modify this behavior in the System configuration.");
  457. }
  458. $output = Response::get($package_file, []);
  459. if ($output) {
  460. Folder::create($tmp);
  461. file_put_contents($tmp . DS . $filename, $output);
  462. return $tmp . DS . $filename;
  463. }
  464. return null;
  465. }
  466. /**
  467. * Copy the local zip package to tmp
  468. *
  469. * @param string $package_file
  470. * @param string $tmp
  471. * @return null|string
  472. */
  473. public static function copyPackage($package_file, $tmp)
  474. {
  475. $package_file = realpath($package_file);
  476. if (file_exists($package_file)) {
  477. $filename = basename($package_file);
  478. Folder::create($tmp);
  479. copy(realpath($package_file), $tmp . DS . $filename);
  480. return $tmp . DS . $filename;
  481. }
  482. return null;
  483. }
  484. /**
  485. * Try to guess the package type from the source files
  486. *
  487. * @param string $source
  488. * @return bool|string
  489. */
  490. public static function getPackageType($source)
  491. {
  492. $plugin_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Plugin/m';
  493. $theme_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Theme/m';
  494. if (
  495. file_exists($source . 'system/defines.php') &&
  496. file_exists($source . 'system/config/system.yaml')
  497. ) {
  498. return 'grav';
  499. }
  500. // must have a blueprint
  501. if (!file_exists($source . 'blueprints.yaml')) {
  502. return false;
  503. }
  504. // either theme or plugin
  505. $name = basename($source);
  506. if (Utils::contains($name, 'theme')) {
  507. return 'theme';
  508. }
  509. if (Utils::contains($name, 'plugin')) {
  510. return 'plugin';
  511. }
  512. foreach (glob($source . '*.php') as $filename) {
  513. $contents = file_get_contents($filename);
  514. if (preg_match($theme_regex, $contents)) {
  515. return 'theme';
  516. }
  517. if (preg_match($plugin_regex, $contents)) {
  518. return 'plugin';
  519. }
  520. }
  521. // Assume it's a theme
  522. return 'theme';
  523. }
  524. /**
  525. * Try to guess the package name from the source files
  526. *
  527. * @param string $source
  528. * @return bool|string
  529. */
  530. public static function getPackageName($source)
  531. {
  532. $ignore_yaml_files = ['blueprints', 'languages'];
  533. foreach (glob($source . '*.yaml') as $filename) {
  534. $name = strtolower(basename($filename, '.yaml'));
  535. if (in_array($name, $ignore_yaml_files)) {
  536. continue;
  537. }
  538. return $name;
  539. }
  540. return false;
  541. }
  542. /**
  543. * Find/Parse the blueprint file
  544. *
  545. * @param string $source
  546. * @return array|bool
  547. */
  548. public static function getBlueprints($source)
  549. {
  550. $blueprint_file = $source . 'blueprints.yaml';
  551. if (!file_exists($blueprint_file)) {
  552. return false;
  553. }
  554. $file = YamlFile::instance($blueprint_file);
  555. $blueprint = (array)$file->content();
  556. $file->free();
  557. return $blueprint;
  558. }
  559. /**
  560. * Get the install path for a name and a particular type of package
  561. *
  562. * @param string $type
  563. * @param string $name
  564. * @return string
  565. */
  566. public static function getInstallPath($type, $name)
  567. {
  568. $locator = Grav::instance()['locator'];
  569. if ($type === 'theme') {
  570. $install_path = $locator->findResource('themes://', false) . DS . $name;
  571. } else {
  572. $install_path = $locator->findResource('plugins://', false) . DS . $name;
  573. }
  574. return $install_path;
  575. }
  576. /**
  577. * Searches for a list of Packages in the repository
  578. * @param array $searches An array of either slugs or names
  579. * @return array Array of found Packages
  580. * Format: ['total' => int, 'not_found' => array, <found-slugs>]
  581. */
  582. public function findPackages($searches = [])
  583. {
  584. $packages = ['total' => 0, 'not_found' => []];
  585. $inflector = new Inflector();
  586. foreach ($searches as $search) {
  587. $repository = '';
  588. // if this is an object, get the search data from the key
  589. if (is_object($search)) {
  590. $search = (array)$search;
  591. $key = key($search);
  592. $repository = $search[$key];
  593. $search = $key;
  594. }
  595. $found = $this->findPackage($search);
  596. if ($found) {
  597. // set override repository if provided
  598. if ($repository) {
  599. $found->override_repository = $repository;
  600. }
  601. if (!isset($packages[$found->package_type])) {
  602. $packages[$found->package_type] = [];
  603. }
  604. $packages[$found->package_type][$found->slug] = $found;
  605. $packages['total']++;
  606. } else {
  607. // make a best guess at the type based on the repo URL
  608. if (Utils::contains($repository, '-theme')) {
  609. $type = 'themes';
  610. } else {
  611. $type = 'plugins';
  612. }
  613. $not_found = new \stdClass();
  614. $not_found->name = $inflector::camelize($search);
  615. $not_found->slug = $search;
  616. $not_found->package_type = $type;
  617. $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]);
  618. $not_found->override_repository = $repository;
  619. $packages['not_found'][$search] = $not_found;
  620. }
  621. }
  622. return $packages;
  623. }
  624. /**
  625. * Return the list of packages that have the passed one as dependency
  626. *
  627. * @param string $slug The slug name of the package
  628. *
  629. * @return array
  630. */
  631. public function getPackagesThatDependOnPackage($slug)
  632. {
  633. $plugins = $this->getInstalledPlugins();
  634. $themes = $this->getInstalledThemes();
  635. $packages = array_merge($plugins->toArray(), $themes->toArray());
  636. $dependent_packages = [];
  637. foreach ($packages as $package_name => $package) {
  638. if (isset($package['dependencies'])) {
  639. foreach ($package['dependencies'] as $dependency) {
  640. if (is_array($dependency) && isset($dependency['name'])) {
  641. $dependency = $dependency['name'];
  642. }
  643. if ($dependency === $slug) {
  644. $dependent_packages[] = $package_name;
  645. }
  646. }
  647. }
  648. }
  649. return $dependent_packages;
  650. }
  651. /**
  652. * Get the required version of a dependency of a package
  653. *
  654. * @param string $package_slug
  655. * @param string $dependency_slug
  656. *
  657. * @return mixed
  658. */
  659. public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug)
  660. {
  661. $dependencies = $this->getInstalledPackage($package_slug)->dependencies;
  662. foreach ($dependencies as $dependency) {
  663. if (isset($dependency[$dependency_slug])) {
  664. return $dependency[$dependency_slug];
  665. }
  666. }
  667. return null;
  668. }
  669. /**
  670. * Check the package identified by $slug can be updated to the version passed as argument.
  671. * Thrown an exception if it cannot be updated because another package installed requires it to be at an older version.
  672. *
  673. * @param string $slug
  674. * @param string $version_with_operator
  675. * @param array $ignore_packages_list
  676. *
  677. * @return bool
  678. * @throws \RuntimeException
  679. */
  680. public function checkNoOtherPackageNeedsThisDependencyInALowerVersion(
  681. $slug,
  682. $version_with_operator,
  683. $ignore_packages_list
  684. ) {
  685. // check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package
  686. $dependent_packages = $this->getPackagesThatDependOnPackage($slug);
  687. $version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator);
  688. if (count($dependent_packages)) {
  689. foreach ($dependent_packages as $dependent_package) {
  690. $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package,
  691. $slug);
  692. $other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator);
  693. // check version is compatible with the one needed by the current package
  694. if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) {
  695. $compatible = $this->checkNextSignificantReleasesAreCompatible($version,
  696. $other_dependency_version);
  697. if (!$compatible) {
  698. if (!in_array($dependent_package, $ignore_packages_list, true)) {
  699. throw new \RuntimeException("Package <cyan>$slug</cyan> is required in an older version by package <cyan>$dependent_package</cyan>. This package needs a newer version, and because of this it cannot be installed. The <cyan>$dependent_package</cyan> package must be updated to use a newer release of <cyan>$slug</cyan>.",
  700. 2);
  701. }
  702. }
  703. }
  704. }
  705. }
  706. return true;
  707. }
  708. /**
  709. * Check the passed packages list can be updated
  710. *
  711. * @param array $packages_names_list
  712. *
  713. * @throws \Exception
  714. */
  715. public function checkPackagesCanBeInstalled($packages_names_list)
  716. {
  717. foreach ($packages_names_list as $package_name) {
  718. $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name,
  719. $this->getLatestVersionOfPackage($package_name), $packages_names_list);
  720. }
  721. }
  722. /**
  723. * Fetch the dependencies, check the installed packages and return an array with
  724. * the list of packages with associated an information on what to do: install, update or ignore.
  725. *
  726. * `ignore` means the package is already installed and can be safely left as-is.
  727. * `install` means the package is not installed and must be installed.
  728. * `update` means the package is already installed and must be updated as a dependency needs a higher version.
  729. *
  730. * @param array $packages
  731. *
  732. * @return mixed
  733. * @throws \Exception
  734. */
  735. public function getDependencies($packages)
  736. {
  737. $dependencies = $this->calculateMergedDependenciesOfPackages($packages);
  738. foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) {
  739. if (\in_array($dependency_slug, $packages, true)) {
  740. unset($dependencies[$dependency_slug]);
  741. continue;
  742. }
  743. // Check PHP version
  744. if ($dependency_slug === 'php') {
  745. $current_php_version = phpversion();
  746. if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator),
  747. $current_php_version) === 1
  748. ) {
  749. //Needs a Grav update first
  750. throw new \RuntimeException("<red>One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this");
  751. }
  752. unset($dependencies[$dependency_slug]);
  753. continue;
  754. }
  755. //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell.
  756. if ($dependency_slug === 'grav') {
  757. if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator),
  758. GRAV_VERSION) === 1
  759. ) {
  760. //Needs a Grav update first
  761. throw new \RuntimeException("<red>One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release.");
  762. }
  763. unset($dependencies[$dependency_slug]);
  764. continue;
  765. }
  766. if ($this->isPluginInstalled($dependency_slug)) {
  767. if ($this->isPluginInstalledAsSymlink($dependency_slug)) {
  768. unset($dependencies[$dependency_slug]);
  769. continue;
  770. }
  771. $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
  772. // get currently installed version
  773. $locator = Grav::instance()['locator'];
  774. $blueprints_path = $locator->findResource('plugins://' . $dependency_slug . DS . 'blueprints.yaml');
  775. $file = YamlFile::instance($blueprints_path);
  776. $package_yaml = $file->content();
  777. $file->free();
  778. $currentlyInstalledVersion = $package_yaml['version'];
  779. // if requirement is next significant release, check is compatible with currently installed version, might not be
  780. if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
  781. if ($this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) {
  782. $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion,
  783. $currentlyInstalledVersion);
  784. if (!$compatible) {
  785. throw new \RuntimeException('Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.',
  786. 2);
  787. }
  788. }
  789. }
  790. //if I already have the latest release, remove the dependency
  791. $latestRelease = $this->getLatestVersionOfPackage($dependency_slug);
  792. if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) {
  793. //throw an exception if a required version cannot be found in the GPM yet
  794. throw new \RuntimeException('Dependency <cyan>' . $package_yaml['name'] . '</cyan> is required in version <cyan>' . $dependencyVersion . '</cyan> which is higher than the latest release, <cyan>' . $latestRelease . '</cyan>. Try running `bin/gpm -f index` to force a refresh of the GPM cache',
  795. 1);
  796. }
  797. if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) {
  798. $dependencies[$dependency_slug] = 'update';
  799. } else {
  800. if ($currentlyInstalledVersion == $latestRelease) {
  801. unset($dependencies[$dependency_slug]);
  802. } else {
  803. // an update is not strictly required mark as 'ignore'
  804. $dependencies[$dependency_slug] = 'ignore';
  805. }
  806. }
  807. } else {
  808. $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
  809. // if requirement is next significant release, check is compatible with latest available version, might not be
  810. if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
  811. $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug);
  812. if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) {
  813. $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion,
  814. $latestVersionOfPackage);
  815. if (!$compatible) {
  816. throw new \Exception('Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.',
  817. 2);
  818. }
  819. }
  820. }
  821. $dependencies[$dependency_slug] = 'install';
  822. }
  823. }
  824. $dependencies_slugs = array_keys($dependencies);
  825. $this->checkNoOtherPackageNeedsTheseDependenciesInALowerVersion(array_merge($packages, $dependencies_slugs));
  826. return $dependencies;
  827. }
  828. public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs)
  829. {
  830. foreach ($dependencies_slugs as $dependency_slug) {
  831. $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($dependency_slug,
  832. $this->getLatestVersionOfPackage($dependency_slug), $dependencies_slugs);
  833. }
  834. }
  835. private function firstVersionIsLower($firstVersion, $secondVersion)
  836. {
  837. return version_compare($firstVersion, $secondVersion) === -1;
  838. }
  839. /**
  840. * Calculates and merges the dependencies of a package
  841. *
  842. * @param string $packageName The package information
  843. *
  844. * @param array $dependencies The dependencies array
  845. *
  846. * @return array
  847. * @throws \Exception
  848. */
  849. private function calculateMergedDependenciesOfPackage($packageName, $dependencies)
  850. {
  851. $packageData = $this->findPackage($packageName);
  852. //Check for dependencies
  853. if (isset($packageData->dependencies)) {
  854. foreach ($packageData->dependencies as $dependency) {
  855. $current_package_name = $dependency['name'];
  856. if (isset($dependency['version'])) {
  857. $current_package_version_information = $dependency['version'];
  858. }
  859. if (!isset($dependencies[$current_package_name])) {
  860. // Dependency added for the first time
  861. if (!isset($current_package_version_information)) {
  862. $dependencies[$current_package_name] = '*';
  863. } else {
  864. $dependencies[$current_package_name] = $current_package_version_information;
  865. }
  866. //Factor in the package dependencies too
  867. $dependencies = $this->calculateMergedDependenciesOfPackage($current_package_name, $dependencies);
  868. } else {
  869. // Dependency already added by another package
  870. //if this package requires a version higher than the currently stored one, store this requirement instead
  871. if (isset($current_package_version_information) && $current_package_version_information !== '*') {
  872. $currently_stored_version_information = $dependencies[$current_package_name];
  873. $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currently_stored_version_information);
  874. $currently_stored_version_is_in_next_significant_release_format = false;
  875. if ($this->versionFormatIsNextSignificantRelease($currently_stored_version_information)) {
  876. $currently_stored_version_is_in_next_significant_release_format = true;
  877. }
  878. if (!$currently_stored_version_number) {
  879. $currently_stored_version_number = '*';
  880. }
  881. $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($current_package_version_information);
  882. if (!$current_package_version_number) {
  883. throw new \RuntimeException('Bad format for version of dependency ' . $current_package_name . ' for package ' . $packageName,
  884. 1);
  885. }
  886. $current_package_version_is_in_next_significant_release_format = false;
  887. if ($this->versionFormatIsNextSignificantRelease($current_package_version_information)) {
  888. $current_package_version_is_in_next_significant_release_format = true;
  889. }
  890. //If I had stored '*', change right away with the more specific version required
  891. if ($currently_stored_version_number === '*') {
  892. $dependencies[$current_package_name] = $current_package_version_information;
  893. } else {
  894. if (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) {
  895. //Comparing versions equals or higher, a simple version_compare is enough
  896. if (version_compare($currently_stored_version_number,
  897. $current_package_version_number) === -1
  898. ) { //Current package version is higher
  899. $dependencies[$current_package_name] = $current_package_version_information;
  900. }
  901. } else {
  902. $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number,
  903. $current_package_version_number);
  904. if (!$compatible) {
  905. throw new \RuntimeException('Dependency ' . $current_package_name . ' is required in two incompatible versions',
  906. 2);
  907. }
  908. }
  909. }
  910. }
  911. }
  912. }
  913. }
  914. return $dependencies;
  915. }
  916. /**
  917. * Calculates and merges the dependencies of the passed packages
  918. *
  919. * @param array $packages
  920. *
  921. * @return mixed
  922. * @throws \Exception
  923. */
  924. public function calculateMergedDependenciesOfPackages($packages)
  925. {
  926. $dependencies = [];
  927. foreach ($packages as $package) {
  928. $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies);
  929. }
  930. return $dependencies;
  931. }
  932. /**
  933. * Returns the actual version from a dependency version string.
  934. * Examples:
  935. * $versionInformation == '~2.0' => returns '2.0'
  936. * $versionInformation == '>=2.0.2' => returns '2.0.2'
  937. * $versionInformation == '2.0.2' => returns '2.0.2'
  938. * $versionInformation == '*' => returns null
  939. * $versionInformation == '' => returns null
  940. *
  941. * @param string $version
  942. *
  943. * @return null|string
  944. */
  945. public function calculateVersionNumberFromDependencyVersion($version)
  946. {
  947. if ($version === '*') {
  948. return null;
  949. }
  950. if ($version === '') {
  951. return null;
  952. }
  953. if ($this->versionFormatIsNextSignificantRelease($version)) {
  954. return trim(substr($version, 1));
  955. }
  956. if ($this->versionFormatIsEqualOrHigher($version)) {
  957. return trim(substr($version, 2));
  958. }
  959. return $version;
  960. }
  961. /**
  962. * Check if the passed version information contains next significant release (tilde) operator
  963. *
  964. * Example: returns true for $version: '~2.0'
  965. *
  966. * @param string $version
  967. *
  968. * @return bool
  969. */
  970. public function versionFormatIsNextSignificantRelease($version): bool
  971. {
  972. return strpos($version, '~') === 0;
  973. }
  974. /**
  975. * Check if the passed version information contains equal or higher operator
  976. *
  977. * Example: returns true for $version: '>=2.0'
  978. *
  979. * @param string $version
  980. *
  981. * @return bool
  982. */
  983. public function versionFormatIsEqualOrHigher($version): bool
  984. {
  985. return strpos($version, '>=') === 0;
  986. }
  987. /**
  988. * Check if two releases are compatible by next significant release
  989. *
  990. * ~1.2 is equivalent to >=1.2 <2.0.0
  991. * ~1.2.3 is equivalent to >=1.2.3 <1.3.0
  992. *
  993. * In short, allows the last digit specified to go up
  994. *
  995. * @param string $version1 the version string (e.g. '2.0.0' or '1.0')
  996. * @param string $version2 the version string (e.g. '2.0.0' or '1.0')
  997. *
  998. * @return bool
  999. */
  1000. public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool
  1001. {
  1002. $version1array = explode('.', $version1);
  1003. $version2array = explode('.', $version2);
  1004. if (\count($version1array) > \count($version2array)) {
  1005. list($version1array, $version2array) = [$version2array, $version1array];
  1006. }
  1007. $i = 0;
  1008. while ($i < \count($version1array) - 1) {
  1009. if ($version1array[$i] != $version2array[$i]) {
  1010. return false;
  1011. }
  1012. $i++;
  1013. }
  1014. return true;
  1015. }
  1016. }