HestiaApp.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. <?php
  2. declare(strict_types=1);
  3. namespace Hestia\System;
  4. use function Hestiacp\quoteshellarg\quoteshellarg;
  5. class HestiaApp {
  6. /** @var string[] */
  7. public $errors;
  8. protected const TMPDIR_DOWNLOADS = "/tmp/hestia-webapp";
  9. protected $phpsupport = false;
  10. public function __construct() {
  11. @mkdir(self::TMPDIR_DOWNLOADS);
  12. }
  13. public function run(string $cmd, $args, &$cmd_result = null): bool {
  14. $cli_script = realpath(HESTIA_DIR_BIN . $cmd);
  15. if (!str_starts_with((string) $cli_script, HESTIA_DIR_BIN)) {
  16. $errstr = "$cmd is trying to traverse outside of " . HESTIA_DIR_BIN;
  17. trigger_error($errstr);
  18. throw new \Exception($errstr);
  19. }
  20. $cli_script = "/usr/bin/sudo " . quoteshellarg($cli_script);
  21. $cli_arguments = "";
  22. if (!empty($args) && is_array($args)) {
  23. foreach ($args as $arg) {
  24. $cli_arguments .= quoteshellarg((string) $arg) . " ";
  25. }
  26. } else {
  27. $cli_arguments = quoteshellarg($args);
  28. }
  29. exec($cli_script . " " . $cli_arguments . " 2>&1", $output, $exit_code);
  30. $result["code"] = $exit_code;
  31. $result["args"] = $cli_arguments;
  32. $result["raw"] = $output;
  33. $result["text"] = implode(PHP_EOL, $output);
  34. $result["json"] = json_decode($result["text"], true);
  35. $cmd_result = (object) $result;
  36. if ($exit_code > 0) {
  37. //log error message in nginx-error.log
  38. trigger_error($result["text"]);
  39. //throw exception if command fails
  40. throw new \Exception($result["text"]);
  41. }
  42. return $exit_code === 0;
  43. }
  44. public function runUser(string $cmd, $args, &$cmd_result = null): bool {
  45. if (!empty($args) && is_array($args)) {
  46. array_unshift($args, $this->user());
  47. } else {
  48. $args = [$this->user(), $args];
  49. }
  50. return $this->run($cmd, $args, $cmd_result);
  51. }
  52. public function installComposer($version) {
  53. exec("curl https://composer.github.io/installer.sig", $output);
  54. $signature = implode(PHP_EOL, $output);
  55. if (empty($signature)) {
  56. throw new \Exception("Error reading composer signature");
  57. }
  58. $composer_setup =
  59. self::TMPDIR_DOWNLOADS . DIRECTORY_SEPARATOR . "composer-setup-" . $signature . ".php";
  60. exec(
  61. "wget https://getcomposer.org/installer --quiet -O " . quoteshellarg($composer_setup),
  62. $output,
  63. $return_code,
  64. );
  65. if ($return_code !== 0) {
  66. throw new \Exception("Error downloading composer");
  67. }
  68. if ($signature !== hash_file("sha384", $composer_setup)) {
  69. unlink($composer_setup);
  70. throw new \Exception("Invalid composer signature");
  71. }
  72. $install_folder = $this->getUserHomeDir() . DIRECTORY_SEPARATOR . ".composer";
  73. if (!file_exists($install_folder)) {
  74. exec(HESTIA_CMD . "v-rebuild-user " . $this->user(), $output, $return_code);
  75. if ($return_code !== 0) {
  76. throw new \Exception("Unable to rebuild user");
  77. }
  78. }
  79. $this->runUser(
  80. "v-run-cli-cmd",
  81. [
  82. "/usr/bin/php",
  83. $composer_setup,
  84. "--quiet",
  85. "--install-dir=" . $install_folder,
  86. "--filename=composer",
  87. "--$version",
  88. ],
  89. $status,
  90. );
  91. unlink($composer_setup);
  92. if ($status->code !== 0) {
  93. throw new \Exception("Error installing composer");
  94. }
  95. }
  96. public function updateComposer($version) {
  97. $this->runUser("v-run-cli-cmd", ["composer", "selfupdate", "--$version"]);
  98. }
  99. public function runComposer($args, &$cmd_result = null, $data = []): bool {
  100. $composer =
  101. $this->getUserHomeDir() .
  102. DIRECTORY_SEPARATOR .
  103. ".composer" .
  104. DIRECTORY_SEPARATOR .
  105. "composer";
  106. if (!is_file($composer)) {
  107. $this->installComposer($data["version"]);
  108. } else {
  109. $this->updateComposer($data["version"]);
  110. }
  111. if (empty($data["php_version"])) {
  112. $data["php_version"] = "";
  113. }
  114. if (!empty($args) && is_array($args)) {
  115. array_unshift($args, "php" . $data["php_version"], $composer);
  116. } else {
  117. $args = ["php" . $data["php_version"], $composer, $args];
  118. }
  119. return $this->runUser("v-run-cli-cmd", $args, $cmd_result);
  120. }
  121. public function runWp($args, &$cmd_result = null): bool {
  122. $wp =
  123. $this->getUserHomeDir() . DIRECTORY_SEPARATOR . ".wp-cli" . DIRECTORY_SEPARATOR . "wp";
  124. if (!is_file($wp)) {
  125. $this->runUser("v-add-user-wp-cli", []);
  126. } else {
  127. $this->runUser("v-run-cli-cmd", [$wp, "cli", "update", "--yes"]);
  128. }
  129. array_unshift($args, $wp);
  130. return $this->runUser("v-run-cli-cmd", $args, $cmd_result);
  131. }
  132. // Logged in user
  133. public function realuser(): string {
  134. return $_SESSION["user"];
  135. }
  136. // Effective user
  137. public function user(): string {
  138. $user = $this->realuser();
  139. if ($_SESSION["userContext"] === "admin" && !empty($_SESSION["look"])) {
  140. $user = $_SESSION["look"];
  141. }
  142. if (strpos($user, DIRECTORY_SEPARATOR) !== false) {
  143. throw new \Exception("illegal characters in username");
  144. }
  145. return $user;
  146. }
  147. public function getUserHomeDir() {
  148. $info = posix_getpwnam($this->user());
  149. return $info["dir"];
  150. }
  151. public function userOwnsDomain(string $domain): bool {
  152. return $this->runUser("v-list-web-domain", [$domain, "json"]);
  153. }
  154. public function checkDatabaseLimit() {
  155. $status = $this->runUser("v-list-user", ["json"], $result);
  156. $result->json[$this->user()];
  157. if ($result->json[$this->user()]["DATABASES"] != "unlimited") {
  158. if (
  159. $result->json[$this->user()]["DATABASES"] -
  160. $result->json[$this->user()]["U_DATABASES"] <
  161. 1
  162. ) {
  163. return false;
  164. }
  165. }
  166. return true;
  167. }
  168. public function databaseAdd(
  169. string $dbname,
  170. string $dbuser,
  171. string $dbpass,
  172. string $dbtype = "mysql",
  173. string $charset = "utf8mb4",
  174. ) {
  175. $v_password = tempnam("/tmp", "hst");
  176. $fp = fopen($v_password, "w");
  177. fwrite($fp, $dbpass . "\n");
  178. fclose($fp);
  179. $status = $this->runUser("v-add-database", [
  180. $dbname,
  181. $dbuser,
  182. $v_password,
  183. $dbtype,
  184. "localhost",
  185. $charset,
  186. ]);
  187. if (!$status) {
  188. $this->errors[] = _("Unable to add database!");
  189. }
  190. unlink($v_password);
  191. return $status;
  192. }
  193. public function getCurrentBackendTemplate(string $domain) {
  194. $status = $this->runUser("v-list-web-domain", [$domain, "json"], $return_message);
  195. $version = $return_message->json[$domain]["BACKEND"];
  196. if (!empty($version)) {
  197. if ($version != "default") {
  198. $test = preg_match("/^.*PHP-([0-9])\_([0-9])/", $version, $match);
  199. return $match[1] . "." . $match[2];
  200. } else {
  201. $supported = $this->run("v-list-sys-php", "json", $result);
  202. return $result->json[0];
  203. }
  204. } else {
  205. $supported = $this->run("v-list-sys-php", "json", $result);
  206. return $result->json[0];
  207. }
  208. }
  209. public function changeWebTemplate(string $domain, string $template) {
  210. $status = $this->runUser("v-change-web-domain-tpl", [$domain, $template]);
  211. }
  212. public function changeBackendTemplate(string $domain, string $template) {
  213. $status = $this->runUser("v-change-web-domain-backend-tpl", [$domain, $template]);
  214. }
  215. public function listSuportedPHP() {
  216. if (!$this->phpsupport) {
  217. $status = $this->run("v-list-sys-php", "json", $result);
  218. $this->phpsupport = $result->json;
  219. }
  220. return $this->phpsupport;
  221. }
  222. /*
  223. Return highest available supported php version
  224. Eg: Package requires: 7.3 or 7.4 and system has 8.0 and 7.4 it will return 7.4
  225. Package requires: 8.0 or 8.1 and system has 8.0 and 7.4 it will return 8.0
  226. Package requires: 7.4 or 8.0 and system has 8.0 and 7.4 it will return 8.0
  227. If package isn't supported by the available php version false will returned
  228. */
  229. public function getSupportedPHP($support) {
  230. $versions = $this->listSuportedPHP();
  231. $supported = false;
  232. $supported_versions = [];
  233. foreach ($versions as $version) {
  234. if (in_array($version, $support)) {
  235. $supported = true;
  236. $supported_versions[] = $version;
  237. }
  238. }
  239. if ($supported) {
  240. return $supported_versions;
  241. } else {
  242. return false;
  243. }
  244. }
  245. public function getWebDomainIp(string $domain) {
  246. $this->runUser("v-list-web-domain", [$domain, "json"], $result);
  247. $ip = $result->json[$domain]["IP"];
  248. return filter_var($ip, FILTER_VALIDATE_IP);
  249. }
  250. public function getWebDomainPath(string $domain) {
  251. return Util::join_paths($this->getUserHomeDir(), "web", $domain);
  252. }
  253. public function downloadUrl(string $src, $path = null, &$result = null) {
  254. if (strpos($src, "http://") !== 0 && strpos($src, "https://") !== 0) {
  255. return false;
  256. }
  257. exec(
  258. "/usr/bin/wget --tries 3 --timeout=30 --no-dns-cache -nv " .
  259. quoteshellarg($src) .
  260. " -P " .
  261. quoteshellarg(self::TMPDIR_DOWNLOADS) .
  262. " 2>&1",
  263. $output,
  264. $return_var,
  265. );
  266. if ($return_var !== 0) {
  267. return false;
  268. }
  269. if (
  270. !preg_match(
  271. '/URL:\s*(.+?)\s*\[(.+?)\]\s*->\s*"(.+?)"/',
  272. implode(PHP_EOL, $output),
  273. $matches,
  274. )
  275. ) {
  276. return false;
  277. }
  278. if (empty($matches) || count($matches) != 4) {
  279. return false;
  280. }
  281. $status["url"] = $matches[1];
  282. $status["file"] = $matches[3];
  283. $result = (object) $status;
  284. return true;
  285. }
  286. public function archiveExtract(string $src, string $path, $skip_components = null) {
  287. if (empty($path)) {
  288. throw new \Exception("Error extracting archive: missing target folder");
  289. }
  290. if (realpath($src)) {
  291. $archive_file = $src;
  292. } else {
  293. if (!$this->downloadUrl($src, null, $download_result)) {
  294. throw new \Exception("Error downloading archive");
  295. }
  296. $archive_file = $download_result->file;
  297. }
  298. $result = $this->runUser("v-extract-fs-archive", [
  299. $archive_file,
  300. $path,
  301. null,
  302. $skip_components,
  303. ]);
  304. unlink($archive_file);
  305. return $result;
  306. }
  307. public function cleanupTmpDir(): void {
  308. $files = glob(self::TMPDIR_DOWNLOADS . "/*");
  309. foreach ($files as $file) {
  310. if (is_file($file)) {
  311. unlink($file);
  312. }
  313. }
  314. }
  315. public function __destruct() {
  316. $this->cleanupTmpDir();
  317. }
  318. }