Installer.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. <?php
  2. /**
  3. * @package Grav\Common\GPM
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\GPM;
  9. use DirectoryIterator;
  10. use Grav\Common\Filesystem\Folder;
  11. use Grav\Common\Grav;
  12. use Grav\Common\Utils;
  13. use RuntimeException;
  14. use ZipArchive;
  15. use function count;
  16. use function in_array;
  17. use function is_string;
  18. /**
  19. * Class Installer
  20. * @package Grav\Common\GPM
  21. */
  22. class Installer
  23. {
  24. /** @const No error */
  25. public const OK = 0;
  26. /** @const Target already exists */
  27. public const EXISTS = 1;
  28. /** @const Target is a symbolic link */
  29. public const IS_LINK = 2;
  30. /** @const Target doesn't exist */
  31. public const NOT_FOUND = 4;
  32. /** @const Target is not a directory */
  33. public const NOT_DIRECTORY = 8;
  34. /** @const Target is not a Grav instance */
  35. public const NOT_GRAV_ROOT = 16;
  36. /** @const Error while trying to open the ZIP package */
  37. public const ZIP_OPEN_ERROR = 32;
  38. /** @const Error while trying to extract the ZIP package */
  39. public const ZIP_EXTRACT_ERROR = 64;
  40. /** @const Invalid source file */
  41. public const INVALID_SOURCE = 128;
  42. /** @var string Destination folder on which validation checks are applied */
  43. protected static $target;
  44. /** @var int|string Error code or string */
  45. protected static $error = 0;
  46. /** @var int Zip Error Code */
  47. protected static $error_zip = 0;
  48. /** @var string Post install message */
  49. protected static $message = '';
  50. /** @var array Default options for the install */
  51. protected static $options = [
  52. 'overwrite' => true,
  53. 'ignore_symlinks' => true,
  54. 'sophisticated' => false,
  55. 'theme' => false,
  56. 'install_path' => '',
  57. 'ignores' => [],
  58. 'exclude_checks' => [self::EXISTS, self::NOT_FOUND, self::IS_LINK]
  59. ];
  60. /**
  61. * Installs a given package to a given destination.
  62. *
  63. * @param string $zip the local path to ZIP package
  64. * @param string $destination The local path to the Grav Instance
  65. * @param array $options Options to use for installing. ie, ['install_path' => 'user/themes/antimatter']
  66. * @param string|null $extracted The local path to the extacted ZIP package
  67. * @param bool $keepExtracted True if you want to keep the original files
  68. * @return bool True if everything went fine, False otherwise.
  69. */
  70. public static function install($zip, $destination, $options = [], $extracted = null, $keepExtracted = false)
  71. {
  72. $destination = rtrim($destination, DS);
  73. $options = array_merge(self::$options, $options);
  74. $install_path = rtrim($destination . DS . ltrim($options['install_path'], DS), DS);
  75. if (!self::isGravInstance($destination) || !self::isValidDestination(
  76. $install_path,
  77. $options['exclude_checks']
  78. )
  79. ) {
  80. return false;
  81. }
  82. if ((self::lastErrorCode() === self::IS_LINK && $options['ignore_symlinks']) ||
  83. (self::lastErrorCode() === self::EXISTS && !$options['overwrite'])
  84. ) {
  85. return false;
  86. }
  87. // Create a tmp location
  88. $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
  89. $tmp = $tmp_dir . '/Grav-' . uniqid('', false);
  90. if (!$extracted) {
  91. $extracted = self::unZip($zip, $tmp);
  92. if (!$extracted) {
  93. Folder::delete($tmp);
  94. return false;
  95. }
  96. }
  97. if (!file_exists($extracted)) {
  98. self::$error = self::INVALID_SOURCE;
  99. return false;
  100. }
  101. $is_install = true;
  102. $installer = self::loadInstaller($extracted, $is_install);
  103. if (isset($options['is_update']) && $options['is_update'] === true) {
  104. $method = 'preUpdate';
  105. } else {
  106. $method = 'preInstall';
  107. }
  108. if ($installer && method_exists($installer, $method)) {
  109. $method_result = $installer::$method();
  110. if ($method_result !== true) {
  111. self::$error = 'An error occurred';
  112. if (is_string($method_result)) {
  113. self::$error = $method_result;
  114. }
  115. return false;
  116. }
  117. }
  118. if (!$options['sophisticated']) {
  119. $isTheme = $options['theme'] ?? false;
  120. // Make sure that themes are always being copied, even if option was not set!
  121. $isTheme = $isTheme || preg_match('|/themes/[^/]+|ui', $install_path);
  122. if ($isTheme) {
  123. self::copyInstall($extracted, $install_path);
  124. } else {
  125. self::moveInstall($extracted, $install_path);
  126. }
  127. } else {
  128. self::sophisticatedInstall($extracted, $install_path, $options['ignores'], $keepExtracted);
  129. }
  130. Folder::delete($tmp);
  131. if (isset($options['is_update']) && $options['is_update'] === true) {
  132. $method = 'postUpdate';
  133. } else {
  134. $method = 'postInstall';
  135. }
  136. self::$message = '';
  137. if ($installer && method_exists($installer, $method)) {
  138. self::$message = $installer::$method();
  139. }
  140. self::$error = self::OK;
  141. return true;
  142. }
  143. /**
  144. * Unzip a file to somewhere
  145. *
  146. * @param string $zip_file
  147. * @param string $destination
  148. * @return string|false
  149. */
  150. public static function unZip($zip_file, $destination)
  151. {
  152. $zip = new ZipArchive();
  153. $archive = $zip->open($zip_file);
  154. if ($archive === true) {
  155. Folder::create($destination);
  156. $unzip = $zip->extractTo($destination);
  157. if (!$unzip) {
  158. self::$error = self::ZIP_EXTRACT_ERROR;
  159. Folder::delete($destination);
  160. $zip->close();
  161. return false;
  162. }
  163. $package_folder_name = $zip->getNameIndex(0);
  164. if ($package_folder_name === false) {
  165. throw new \RuntimeException('Bad package file: ' . Utils::basename($zip_file));
  166. }
  167. $package_folder_name = preg_replace('#\./$#', '', $package_folder_name);
  168. $zip->close();
  169. return $destination . '/' . $package_folder_name;
  170. }
  171. self::$error = self::ZIP_EXTRACT_ERROR;
  172. self::$error_zip = $archive;
  173. return false;
  174. }
  175. /**
  176. * Instantiates and returns the package installer class
  177. *
  178. * @param string $installer_file_folder The folder path that contains install.php
  179. * @param bool $is_install True if install, false if removal
  180. * @return string|null
  181. */
  182. private static function loadInstaller($installer_file_folder, $is_install)
  183. {
  184. $installer_file_folder = rtrim($installer_file_folder, DS);
  185. $install_file = $installer_file_folder . DS . 'install.php';
  186. if (!file_exists($install_file)) {
  187. return null;
  188. }
  189. require_once $install_file;
  190. if ($is_install) {
  191. $slug = '';
  192. if (($pos = strpos($installer_file_folder, 'grav-plugin-')) !== false) {
  193. $slug = substr($installer_file_folder, $pos + strlen('grav-plugin-'));
  194. } elseif (($pos = strpos($installer_file_folder, 'grav-theme-')) !== false) {
  195. $slug = substr($installer_file_folder, $pos + strlen('grav-theme-'));
  196. }
  197. } else {
  198. $path_elements = explode('/', $installer_file_folder);
  199. $slug = end($path_elements);
  200. }
  201. if (!$slug) {
  202. return null;
  203. }
  204. $class_name = ucfirst($slug) . 'Install';
  205. if (class_exists($class_name)) {
  206. return $class_name;
  207. }
  208. $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name) ?? $class_name;
  209. if (class_exists($class_name_alphanumeric)) {
  210. return $class_name_alphanumeric;
  211. }
  212. return null;
  213. }
  214. /**
  215. * @param string $source_path
  216. * @param string $install_path
  217. * @return bool
  218. */
  219. public static function moveInstall($source_path, $install_path)
  220. {
  221. if (file_exists($install_path)) {
  222. Folder::delete($install_path);
  223. }
  224. Folder::move($source_path, $install_path);
  225. return true;
  226. }
  227. /**
  228. * @param string $source_path
  229. * @param string $install_path
  230. * @return bool
  231. */
  232. public static function copyInstall($source_path, $install_path)
  233. {
  234. if (empty($source_path)) {
  235. throw new RuntimeException("Directory $source_path is missing");
  236. }
  237. Folder::rcopy($source_path, $install_path);
  238. return true;
  239. }
  240. /**
  241. * @param string $source_path
  242. * @param string $install_path
  243. * @param array $ignores
  244. * @param bool $keep_source
  245. * @return bool
  246. */
  247. public static function sophisticatedInstall($source_path, $install_path, $ignores = [], $keep_source = false)
  248. {
  249. foreach (new DirectoryIterator($source_path) as $file) {
  250. if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) {
  251. continue;
  252. }
  253. $path = $install_path . DS . $file->getFilename();
  254. if ($file->isDir()) {
  255. Folder::delete($path);
  256. if ($keep_source) {
  257. Folder::copy($file->getPathname(), $path);
  258. } else {
  259. Folder::move($file->getPathname(), $path);
  260. }
  261. if ($file->getFilename() === 'bin') {
  262. $glob = glob($path . DS . '*') ?: [];
  263. foreach ($glob as $bin_file) {
  264. @chmod($bin_file, 0755);
  265. }
  266. }
  267. } else {
  268. @unlink($path);
  269. @copy($file->getPathname(), $path);
  270. }
  271. }
  272. return true;
  273. }
  274. /**
  275. * Uninstalls one or more given package
  276. *
  277. * @param string $path The slug of the package(s)
  278. * @param array $options Options to use for uninstalling
  279. * @return bool True if everything went fine, False otherwise.
  280. */
  281. public static function uninstall($path, $options = [])
  282. {
  283. $options = array_merge(self::$options, $options);
  284. if (!self::isValidDestination($path, $options['exclude_checks'])
  285. ) {
  286. return false;
  287. }
  288. $installer_file_folder = $path;
  289. $is_install = false;
  290. $installer = self::loadInstaller($installer_file_folder, $is_install);
  291. if ($installer && method_exists($installer, 'preUninstall')) {
  292. $method_result = $installer::preUninstall();
  293. if ($method_result !== true) {
  294. self::$error = 'An error occurred';
  295. if (is_string($method_result)) {
  296. self::$error = $method_result;
  297. }
  298. return false;
  299. }
  300. }
  301. $result = Folder::delete($path);
  302. self::$message = '';
  303. if ($result && $installer && method_exists($installer, 'postUninstall')) {
  304. self::$message = $installer::postUninstall();
  305. }
  306. return $result;
  307. }
  308. /**
  309. * Runs a set of checks on the destination and sets the Error if any
  310. *
  311. * @param string $destination The directory to run validations at
  312. * @param array $exclude An array of constants to exclude from the validation
  313. * @return bool True if validation passed. False otherwise
  314. */
  315. public static function isValidDestination($destination, $exclude = [])
  316. {
  317. self::$error = 0;
  318. self::$target = $destination;
  319. if (is_link($destination)) {
  320. self::$error = self::IS_LINK;
  321. } elseif (file_exists($destination)) {
  322. self::$error = self::EXISTS;
  323. } elseif (!file_exists($destination)) {
  324. self::$error = self::NOT_FOUND;
  325. } elseif (!is_dir($destination)) {
  326. self::$error = self::NOT_DIRECTORY;
  327. }
  328. if (count($exclude) && in_array(self::$error, $exclude, true)) {
  329. return true;
  330. }
  331. return !self::$error;
  332. }
  333. /**
  334. * Validates if the given path is a Grav Instance
  335. *
  336. * @param string $target The local path to the Grav Instance
  337. * @return bool True if is a Grav Instance. False otherwise
  338. */
  339. public static function isGravInstance($target)
  340. {
  341. self::$error = 0;
  342. self::$target = $target;
  343. if (!file_exists($target . DS . 'index.php') ||
  344. !file_exists($target . DS . 'bin') ||
  345. !file_exists($target . DS . 'user') ||
  346. !file_exists($target . DS . 'system' . DS . 'config' . DS . 'system.yaml')
  347. ) {
  348. self::$error = self::NOT_GRAV_ROOT;
  349. }
  350. return !self::$error;
  351. }
  352. /**
  353. * Returns the last message added by the installer
  354. *
  355. * @return string The message
  356. */
  357. public static function getMessage()
  358. {
  359. return self::$message;
  360. }
  361. /**
  362. * Returns the last error occurred in a string message format
  363. *
  364. * @return string The message of the last error
  365. */
  366. public static function lastErrorMsg()
  367. {
  368. if (is_string(self::$error)) {
  369. return self::$error;
  370. }
  371. switch (self::$error) {
  372. case 0:
  373. $msg = 'No Error';
  374. break;
  375. case self::EXISTS:
  376. $msg = 'The target path "' . self::$target . '" already exists';
  377. break;
  378. case self::IS_LINK:
  379. $msg = 'The target path "' . self::$target . '" is a symbolic link';
  380. break;
  381. case self::NOT_FOUND:
  382. $msg = 'The target path "' . self::$target . '" does not appear to exist';
  383. break;
  384. case self::NOT_DIRECTORY:
  385. $msg = 'The target path "' . self::$target . '" does not appear to be a folder';
  386. break;
  387. case self::NOT_GRAV_ROOT:
  388. $msg = 'The target path "' . self::$target . '" does not appear to be a Grav instance';
  389. break;
  390. case self::ZIP_OPEN_ERROR:
  391. $msg = 'Unable to open the package file';
  392. break;
  393. case self::ZIP_EXTRACT_ERROR:
  394. $msg = 'Unable to extract the package. ';
  395. if (self::$error_zip) {
  396. switch (self::$error_zip) {
  397. case ZipArchive::ER_EXISTS:
  398. $msg .= 'File already exists.';
  399. break;
  400. case ZipArchive::ER_INCONS:
  401. $msg .= 'Zip archive inconsistent.';
  402. break;
  403. case ZipArchive::ER_MEMORY:
  404. $msg .= 'Memory allocation failure.';
  405. break;
  406. case ZipArchive::ER_NOENT:
  407. $msg .= 'No such file.';
  408. break;
  409. case ZipArchive::ER_NOZIP:
  410. $msg .= 'Not a zip archive.';
  411. break;
  412. case ZipArchive::ER_OPEN:
  413. $msg .= "Can't open file.";
  414. break;
  415. case ZipArchive::ER_READ:
  416. $msg .= 'Read error.';
  417. break;
  418. case ZipArchive::ER_SEEK:
  419. $msg .= 'Seek error.';
  420. break;
  421. }
  422. }
  423. break;
  424. case self::INVALID_SOURCE:
  425. $msg = 'Invalid source file';
  426. break;
  427. default:
  428. $msg = 'Unknown Error';
  429. break;
  430. }
  431. return $msg;
  432. }
  433. /**
  434. * Returns the last error code of the occurred error
  435. *
  436. * @return int|string The code of the last error
  437. */
  438. public static function lastErrorCode()
  439. {
  440. return self::$error;
  441. }
  442. /**
  443. * Allows to manually set an error
  444. *
  445. * @param int|string $error the Error code
  446. * @return void
  447. */
  448. public static function setError($error)
  449. {
  450. self::$error = $error;
  451. }
  452. }