GPM.php 41 KB

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