Installer.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. <?php
  2. /**
  3. * @package Grav.Common.GPM
  4. *
  5. * @copyright Copyright (C) 2015 - 2018 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. const OK = 0;
  15. /** @const Target already exists */
  16. const EXISTS = 1;
  17. /** @const Target is a symbolic link */
  18. const IS_LINK = 2;
  19. /** @const Target doesn't exist */
  20. const NOT_FOUND = 4;
  21. /** @const Target is not a directory */
  22. const NOT_DIRECTORY = 8;
  23. /** @const Target is not a Grav instance */
  24. const NOT_GRAV_ROOT = 16;
  25. /** @const Error while trying to open the ZIP package */
  26. const ZIP_OPEN_ERROR = 32;
  27. /** @const Error while trying to extract the ZIP package */
  28. const ZIP_EXTRACT_ERROR = 64;
  29. /** @const Invalid source file */
  30. 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 integer Error Code
  38. */
  39. protected static $error = 0;
  40. /**
  41. * @var integer 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. * @return bool True if everything went fine, False otherwise.
  69. */
  70. public static function install($zip, $destination, $options = [], $extracted = null)
  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($install_path,
  76. $options['exclude_checks'])
  77. ) {
  78. return false;
  79. }
  80. if (self::lastErrorCode() == self::IS_LINK && $options['ignore_symlinks'] ||
  81. self::lastErrorCode() == self::EXISTS && !$options['overwrite']
  82. ) {
  83. return false;
  84. }
  85. // Create a tmp location
  86. $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
  87. $tmp = $tmp_dir . '/Grav-' . uniqid();
  88. if (!$extracted) {
  89. $extracted = self::unZip($zip, $tmp);
  90. if (!$extracted) {
  91. Folder::delete($tmp);
  92. return false;
  93. }
  94. }
  95. if (!file_exists($extracted)) {
  96. self::$error = self::INVALID_SOURCE;
  97. return false;
  98. }
  99. $is_install = true;
  100. $installer = self::loadInstaller($extracted, $is_install);
  101. if (isset($options['is_update']) && $options['is_update'] === true) {
  102. $method = 'preUpdate';
  103. } else {
  104. $method = 'preInstall';
  105. }
  106. if ($installer && method_exists($installer, $method)) {
  107. $method_result = $installer::$method();
  108. if ($method_result !== true) {
  109. self::$error = 'An error occurred';
  110. if (is_string($method_result)) {
  111. self::$error = $method_result;
  112. }
  113. return false;
  114. }
  115. }
  116. if (!$options['sophisticated']) {
  117. if ($options['theme']) {
  118. self::copyInstall($extracted, $install_path);
  119. } else {
  120. self::moveInstall($extracted, $install_path);
  121. }
  122. } else {
  123. self::sophisticatedInstall($extracted, $install_path, $options['ignores']);
  124. }
  125. Folder::delete($tmp);
  126. if (isset($options['is_update']) && $options['is_update'] === true) {
  127. $method = 'postUpdate';
  128. } else {
  129. $method = 'postInstall';
  130. }
  131. self::$message = '';
  132. if ($installer && method_exists($installer, $method)) {
  133. self::$message = $installer::$method();
  134. }
  135. self::$error = self::OK;
  136. return true;
  137. }
  138. /**
  139. * Unzip a file to somewhere
  140. *
  141. * @param $zip_file
  142. * @param $destination
  143. * @return bool|string
  144. */
  145. public static function unZip($zip_file, $destination)
  146. {
  147. $zip = new \ZipArchive();
  148. $archive = $zip->open($zip_file);
  149. if ($archive === true) {
  150. Folder::mkdir($destination);
  151. $unzip = $zip->extractTo($destination);
  152. if (!$unzip) {
  153. self::$error = self::ZIP_EXTRACT_ERROR;
  154. Folder::delete($destination);
  155. $zip->close();
  156. return false;
  157. }
  158. $package_folder_name = preg_replace('#\./$#', '', $zip->getNameIndex(0));
  159. $zip->close();
  160. $extracted_folder = $destination . '/' . $package_folder_name;
  161. return $extracted_folder;
  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 $source_path
  211. * @param $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 $source_path
  225. * @param $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. } else {
  234. Folder::rcopy($source_path, $install_path);
  235. }
  236. return true;
  237. }
  238. /**
  239. * @param $source_path
  240. * @param $install_path
  241. *
  242. * @return bool
  243. */
  244. public static function sophisticatedInstall($source_path, $install_path, $ignores = [])
  245. {
  246. foreach (new \DirectoryIterator($source_path) as $file) {
  247. if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores)) {
  248. continue;
  249. }
  250. $path = $install_path . DS . $file->getFilename();
  251. if ($file->isDir()) {
  252. Folder::delete($path);
  253. Folder::move($file->getPathname(), $path);
  254. if ($file->getFilename() === 'bin') {
  255. foreach (glob($path . DS . '*') as $bin_file) {
  256. @chmod($bin_file, 0755);
  257. }
  258. }
  259. } else {
  260. @unlink($path);
  261. @copy($file->getPathname(), $path);
  262. }
  263. }
  264. return true;
  265. }
  266. /**
  267. * Uninstalls one or more given package
  268. *
  269. * @param string $path The slug of the package(s)
  270. * @param array $options Options to use for uninstalling
  271. *
  272. * @return boolean True if everything went fine, False otherwise.
  273. */
  274. public static function uninstall($path, $options = [])
  275. {
  276. $options = array_merge(self::$options, $options);
  277. if (!self::isValidDestination($path, $options['exclude_checks'])
  278. ) {
  279. return false;
  280. }
  281. $installer_file_folder = $path;
  282. $is_install = false;
  283. $installer = self::loadInstaller($installer_file_folder, $is_install);
  284. if ($installer && method_exists($installer, 'preUninstall')) {
  285. $method_result = $installer::preUninstall();
  286. if ($method_result !== true) {
  287. self::$error = 'An error occurred';
  288. if (is_string($method_result)) {
  289. self::$error = $method_result;
  290. }
  291. return false;
  292. }
  293. }
  294. $result = Folder::delete($path);
  295. self::$message = '';
  296. if ($result && $installer && method_exists($installer, 'postUninstall')) {
  297. self::$message = $installer::postUninstall();
  298. }
  299. return $result;
  300. }
  301. /**
  302. * Runs a set of checks on the destination and sets the Error if any
  303. *
  304. * @param string $destination The directory to run validations at
  305. * @param array $exclude An array of constants to exclude from the validation
  306. *
  307. * @return boolean True if validation passed. False otherwise
  308. */
  309. public static function isValidDestination($destination, $exclude = [])
  310. {
  311. self::$error = 0;
  312. self::$target = $destination;
  313. if (is_link($destination)) {
  314. self::$error = self::IS_LINK;
  315. } elseif (file_exists($destination)) {
  316. self::$error = self::EXISTS;
  317. } elseif (!file_exists($destination)) {
  318. self::$error = self::NOT_FOUND;
  319. } elseif (!is_dir($destination)) {
  320. self::$error = self::NOT_DIRECTORY;
  321. }
  322. if (count($exclude) && in_array(self::$error, $exclude)) {
  323. return true;
  324. }
  325. return !(self::$error);
  326. }
  327. /**
  328. * Validates if the given path is a Grav Instance
  329. *
  330. * @param string $target The local path to the Grav Instance
  331. *
  332. * @return boolean True if is a Grav Instance. False otherwise
  333. */
  334. public static function isGravInstance($target)
  335. {
  336. self::$error = 0;
  337. self::$target = $target;
  338. if (
  339. !file_exists($target . DS . 'index.php') ||
  340. !file_exists($target . DS . 'bin') ||
  341. !file_exists($target . DS . 'user') ||
  342. !file_exists($target . DS . 'system' . DS . 'config' . DS . 'system.yaml')
  343. ) {
  344. self::$error = self::NOT_GRAV_ROOT;
  345. }
  346. return !self::$error;
  347. }
  348. /**
  349. * Returns the last message added by the installer
  350. * @return string The message
  351. */
  352. public static function getMessage()
  353. {
  354. return self::$message;
  355. }
  356. /**
  357. * Returns the last error occurred in a string message format
  358. * @return string The message of the last error
  359. */
  360. public static function lastErrorMsg()
  361. {
  362. if (is_string(self::$error)) {
  363. return self::$error;
  364. }
  365. switch (self::$error) {
  366. case 0:
  367. $msg = 'No Error';
  368. break;
  369. case self::EXISTS:
  370. $msg = 'The target path "' . self::$target . '" already exists';
  371. break;
  372. case self::IS_LINK:
  373. $msg = 'The target path "' . self::$target . '" is a symbolic link';
  374. break;
  375. case self::NOT_FOUND:
  376. $msg = 'The target path "' . self::$target . '" does not appear to exist';
  377. break;
  378. case self::NOT_DIRECTORY:
  379. $msg = 'The target path "' . self::$target . '" does not appear to be a folder';
  380. break;
  381. case self::NOT_GRAV_ROOT:
  382. $msg = 'The target path "' . self::$target . '" does not appear to be a Grav instance';
  383. break;
  384. case self::ZIP_OPEN_ERROR:
  385. $msg = 'Unable to open the package file';
  386. break;
  387. case self::ZIP_EXTRACT_ERROR:
  388. $msg = 'Unable to extract the package. ';
  389. if (self::$error_zip) {
  390. switch(self::$error_zip) {
  391. case \ZipArchive::ER_EXISTS:
  392. $msg .= "File already exists.";
  393. break;
  394. case \ZipArchive::ER_INCONS:
  395. $msg .= "Zip archive inconsistent.";
  396. break;
  397. case \ZipArchive::ER_MEMORY:
  398. $msg .= "Malloc failure.";
  399. break;
  400. case \ZipArchive::ER_NOENT:
  401. $msg .= "No such file.";
  402. break;
  403. case \ZipArchive::ER_NOZIP:
  404. $msg .= "Not a zip archive.";
  405. break;
  406. case \ZipArchive::ER_OPEN:
  407. $msg .= "Can't open file.";
  408. break;
  409. case \ZipArchive::ER_READ:
  410. $msg .= "Read error.";
  411. break;
  412. case \ZipArchive::ER_SEEK:
  413. $msg .= "Seek error.";
  414. break;
  415. }
  416. }
  417. break;
  418. default:
  419. $msg = 'Unknown Error';
  420. break;
  421. }
  422. return $msg;
  423. }
  424. /**
  425. * Returns the last error code of the occurred error
  426. * @return integer The code of the last error
  427. */
  428. public static function lastErrorCode()
  429. {
  430. return self::$error;
  431. }
  432. /**
  433. * Allows to manually set an error
  434. *
  435. * @param int|string $error the Error code
  436. */
  437. public static function setError($error)
  438. {
  439. self::$error = $error;
  440. }
  441. }