Installer.php 16 KB

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