Преглед на файлове

Gather uniform installation target information before install (#4694)

* Gather uniform installation target information before install

* cleanup docuwiki installer

* Convert dolibarr setup

* Introduct Symfony Process to make running commands easier

* Cleanup resources and fix Drupal installation

* Simplify Flarum installation and add support for document_root change

* Cleanup grav installer

* Improve download mechanism

* Huge overhaul of the installer system with clear order

* Add test to run quick install app with test

* Cleanup most of the installing logic and revert to htaccess redirect

* improve flarum installer with stronger .htaccess file

* use separate htaccess files

* fix installer and test
Robert-Jan de Dreu преди 1 година
родител
ревизия
65dee1ecfa
променени са 61 файла, в които са добавени 2993 реда и са изтрити 3162 реда
  1. 5 0
      .editorconfig
  2. 3 0
      .prettierignore
  3. 1 1
      bin/v-add-user-wp-cli
  4. 1 1
      bin/v-quick-install-app
  5. 1 0
      bin/v-run-cli-cmd
  6. 94 0
      install/deb/templates/web/nginx/php-fpm/flarum-composer.stpl
  7. 82 0
      install/deb/templates/web/nginx/php-fpm/flarum-composer.tpl
  8. 1 1
      install/deb/templates/web/nginx/php-fpm/opencart.stpl
  9. 1 1
      install/deb/templates/web/nginx/php-fpm/opencart.tpl
  10. 17 0
      phpcs.xml
  11. 6 0
      test/test.bats
  12. 22 57
      web/add/webapp/index.php
  13. 0 7
      web/src/app/Models/DnsDomain.php
  14. 0 7
      web/src/app/Models/MailDomain.php
  15. 0 28
      web/src/app/Models/Model.php
  16. 0 7
      web/src/app/Models/User.php
  17. 0 7
      web/src/app/Models/WebDomain.php
  18. 413 361
      web/src/app/System/HestiaApp.php
  19. 0 7
      web/src/app/System/HestiaCli.php
  20. 22 0
      web/src/app/System/HestiaCommandResult.php
  21. 28 25
      web/src/app/System/Util.php
  22. 16 0
      web/src/app/System/WebDomain.php
  23. 182 141
      web/src/app/WebApp/AppWizard.php
  24. 181 0
      web/src/app/WebApp/BaseSetup.php
  25. 29 0
      web/src/app/WebApp/InstallationTarget/InstallationTarget.php
  26. 22 0
      web/src/app/WebApp/InstallationTarget/TargetDatabase.php
  27. 28 0
      web/src/app/WebApp/InstallationTarget/TargetDomain.php
  28. 42 0
      web/src/app/WebApp/InstallerInfo.php
  29. 10 4
      web/src/app/WebApp/InstallerInterface.php
  30. 0 202
      web/src/app/WebApp/Installers/BaseSetup.php
  31. 82 129
      web/src/app/WebApp/Installers/DokuWiki/DokuWikiSetup.php
  32. 446 446
      web/src/app/WebApp/Installers/DokuWiki/dokuwiki-logo.svg
  33. 5 0
      web/src/app/WebApp/Installers/Dolibarr/.htaccess
  34. 112 133
      web/src/app/WebApp/Installers/Dolibarr/DolibarrSetup.php
  35. 68 80
      web/src/app/WebApp/Installers/Drupal/DrupalSetup.php
  36. 131 0
      web/src/app/WebApp/Installers/Flarum/.htaccess
  37. 56 203
      web/src/app/WebApp/Installers/Flarum/FlarumSetup.php
  38. 58 66
      web/src/app/WebApp/Installers/Grav/GravSetup.php
  39. 1 9
      web/src/app/WebApp/Installers/Grav/grav-symbol.svg
  40. 71 103
      web/src/app/WebApp/Installers/Joomla/JoomlaSetup.php
  41. 4 0
      web/src/app/WebApp/Installers/Laravel/.htaccess
  42. 38 50
      web/src/app/WebApp/Installers/Laravel/LaravelSetup.php
  43. 0 19
      web/src/app/WebApp/Installers/MediaWiki/MediaWiki-2020-logo.svg
  44. 59 82
      web/src/app/WebApp/Installers/MediaWiki/MediaWikiSetup.php
  45. 43 45
      web/src/app/WebApp/Installers/NamelessMC/NamelessMCSetup.php
  46. 61 77
      web/src/app/WebApp/Installers/Nextcloud/NextcloudSetup.php
  47. 80 121
      web/src/app/WebApp/Installers/OpenCart/OpenCartSetup.php
  48. 63 83
      web/src/app/WebApp/Installers/PrestaShop/PrestaShopSetup.php
  49. 0 37
      web/src/app/WebApp/Installers/Resources/ComposerResource.php
  50. 0 28
      web/src/app/WebApp/Installers/Resources/WpResource.php
  51. 4 0
      web/src/app/WebApp/Installers/Symfony/.htaccess
  52. 52 57
      web/src/app/WebApp/Installers/Symfony/SymfonySetup.php
  53. 56 89
      web/src/app/WebApp/Installers/ThirtyBees/ThirtyBeesSetup.php
  54. 49 60
      web/src/app/WebApp/Installers/Vvveb/VvvebSetup.php
  55. 4 4
      web/src/app/WebApp/Installers/Vvveb/vvveb-symbol.svg
  56. 105 260
      web/src/app/WebApp/Installers/WordPress/WordPressSetup.php
  57. 6 4
      web/src/composer.json
  58. 124 98
      web/src/composer.lock
  59. 1 15
      web/src/init.php
  60. 5 5
      web/templates/pages/list_webapps.php
  61. 2 2
      web/templates/pages/setup_webapp.php

+ 5 - 0
.editorconfig

@@ -24,3 +24,8 @@ indent_style = space
 # Views have longer line length for now
 [web/templates/**/*.php]
 max_line_length = 200
+
+# Modern PHP uses spaces for indentation
+[web/src/**/*.php]
+indent_size = 4
+indent_style = space

+ 3 - 0
.prettierignore

@@ -27,6 +27,9 @@
 # Web templates (for now)
 web/templates/
 
+# Modern Web (handled by PHPCS)
+web/src/
+
 # Patch files
 /install/upgrade/patch/*
 

+ 1 - 1
bin/v-add-user-wp-cli

@@ -45,7 +45,7 @@ WPCLI_DIR="/home/$user/.wp-cli"
 WPCLI_BIN="$WPCLI_DIR/wp"
 
 if [ -f "$WPCLI_BIN" ]; then
-	if [ -f "$update" ]; then
+	if [ "$update" = 'yes' ]; then
 		user_exec $WPCLI_BIN cli update --yes
 		exit
 	fi

+ 1 - 1
bin/v-quick-install-app

@@ -61,7 +61,7 @@ $application -> register('install')
 			return Command::FAILURE;
 		}
 		$app_installer_class = "\Hestia\WebApp\Installers\\" . $app . "\\" . $app . "Setup";
-		$app_installer = new $app_installer_class($v_domain, $hestia);
+		$app_installer = new $app_installer_class($hestia);
 
 		// check for default fields
 		$WebappInstaller = new \Hestia\WebApp\AppWizard($app_installer, $v_domain, $hestia);

+ 1 - 0
bin/v-run-cli-cmd

@@ -49,6 +49,7 @@ fi
 basecmd="$(basename "$clicmd")"
 if [ "$basecmd" != 'ps' -a \
 	"$basecmd" != 'ls' -a \
+	"$basecmd" != 'wget' -a \
 	"$basecmd" != 'tar' -a \
 	"$basecmd" != 'zip' -a \
 	"$basecmd" != 'unzip' -a \

+ 94 - 0
install/deb/templates/web/nginx/php-fpm/flarum-composer.stpl

@@ -0,0 +1,94 @@
+#=========================================================================#
+# Default Web Domain Template                                             #
+# DO NOT MODIFY THIS FILE! CHANGES WILL BE LOST WHEN REBUILDING DOMAINS   #
+# https://hestiacp.com/docs/server-administration/web-templates.html      #
+#=========================================================================#
+
+server {
+	listen      %ip%:%web_ssl_port% ssl;
+	server_name %domain_idn% %alias_idn%;
+	root        %sdocroot%/public;
+	index       index.php index.html index.htm;
+	access_log  /var/log/nginx/domains/%domain%.log combined;
+	access_log  /var/log/nginx/domains/%domain%.bytes bytes;
+	error_log   /var/log/nginx/domains/%domain%.error.log error;
+
+	ssl_certificate     %ssl_pem%;
+	ssl_certificate_key %ssl_key%;
+	ssl_stapling        on;
+	ssl_stapling_verify on;
+
+	# TLS 1.3 0-RTT anti-replay
+	if ($anti_replay = 307) { return 307 https://$host$request_uri; }
+	if ($anti_replay = 425) { return 425; }
+
+	include %home%/%user%/conf/web/%domain%/nginx.hsts.conf*;
+
+	# Pass requests that don't refer directly to files in the filesystem to index.php
+	location / {
+		try_files $uri $uri/ /index.php?$query_string;
+	}
+
+	location ~ \.php$ {
+		try_files $uri =404;
+
+		include /etc/nginx/fastcgi_params;
+
+		fastcgi_index index.php;
+		fastcgi_param HTTP_EARLY_DATA $rfc_early_data if_not_empty;
+		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+
+		fastcgi_pass %backend_lsnr%;
+
+		include %home%/%user%/conf/web/%domain%/nginx.fastcgi_cache.conf*;
+	}
+
+	# Uncomment the following lines if you are not using a "public" directory
+	# to prevent sensitive resources from being exposed.
+	location ~* ^/(\.git|composer\.(json|lock)|auth\.json|config\.php|flarum|storage|vendor) {
+		deny all;
+		return 404;
+	}
+
+	# The following directives are based on best practices from H5BP Nginx Server Configs
+	# https://github.com/h5bp/server-configs-nginx
+
+	# Expire rules for static content
+	location ~* \.(?:manifest|appcache|html?|xml|json)$ {
+		add_header Cache-Control "max-age=0";
+	}
+
+	location ~* \.(?:rss|atom)$ {
+		add_header Cache-Control "max-age=3600";
+	}
+
+	location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|mp4|ogg|ogv|webm|htc)$ {
+		add_header Cache-Control "max-age=2592000";
+		access_log off;
+	}
+
+	location ~* \.(?:css|js)$ {
+		add_header Cache-Control "max-age=31536000";
+		access_log off;
+	}
+
+	location ~* \.(?:ttf|ttc|otf|eot|woff|woff2)$ {
+		add_header Cache-Control "max-age=2592000";
+		access_log off;
+	}
+
+	location /error/ {
+		alias %home%/%user%/web/%domain%/document_errors/;
+	}
+
+	location /vstats/ {
+		alias   %home%/%user%/web/%domain%/stats/;
+		include %home%/%user%/web/%domain%/stats/auth.conf*;
+	}
+
+	proxy_hide_header Upgrade;
+
+	include /etc/nginx/conf.d/phpmyadmin.inc*;
+	include /etc/nginx/conf.d/phppgadmin.inc*;
+	include %home%/%user%/conf/web/%domain%/nginx.ssl.conf_*;
+}

+ 82 - 0
install/deb/templates/web/nginx/php-fpm/flarum-composer.tpl

@@ -0,0 +1,82 @@
+#=========================================================================#
+# Default Web Domain Template                                             #
+# DO NOT MODIFY THIS FILE! CHANGES WILL BE LOST WHEN REBUILDING DOMAINS   #
+# https://hestiacp.com/docs/server-administration/web-templates.html      #
+#=========================================================================#
+
+server {
+	listen      %ip%:%web_port%;
+	server_name %domain_idn% %alias_idn%;
+	root        %docroot%/public;
+	index       index.php index.html index.htm;
+	access_log  /var/log/nginx/domains/%domain%.log combined;
+	access_log  /var/log/nginx/domains/%domain%.bytes bytes;
+	error_log   /var/log/nginx/domains/%domain%.error.log error;
+
+	include %home%/%user%/conf/web/%domain%/nginx.forcessl.conf*;
+
+	# Pass requests that don't refer directly to files in the filesystem to index.php
+	location / {
+		try_files $uri $uri/ /index.php?$query_string;
+	}
+
+	location ~ \.php$ {
+		try_files $uri =404;
+
+		include /etc/nginx/fastcgi_params;
+
+		fastcgi_index index.php;
+		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+
+		fastcgi_pass %backend_lsnr%;
+
+		include %home%/%user%/conf/web/%domain%/nginx.fastcgi_cache.conf*;
+	}
+
+	# Uncomment the following lines if you are not using a "public" directory
+	# to prevent sensitive resources from being exposed.
+	location ~* ^/(\.git|composer\.(json|lock)|auth\.json|config\.php|flarum|storage|vendor) {
+		deny all;
+		return 404;
+	}
+
+	# The following directives are based on best practices from H5BP Nginx Server Configs
+	# https://github.com/h5bp/server-configs-nginx
+
+	# Expire rules for static content
+	location ~* \.(?:manifest|appcache|html?|xml|json)$ {
+		add_header Cache-Control "max-age=0";
+	}
+
+	location ~* \.(?:rss|atom)$ {
+		add_header Cache-Control "max-age=3600";
+	}
+
+	location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|mp4|ogg|ogv|webm|htc)$ {
+		add_header Cache-Control "max-age=2592000";
+		access_log off;
+	}
+
+	location ~* \.(?:css|js)$ {
+		add_header Cache-Control "max-age=31536000";
+		access_log off;
+	}
+
+	location ~* \.(?:ttf|ttc|otf|eot|woff|woff2)$ {
+		add_header Cache-Control "max-age=2592000";
+		access_log off;
+	}
+
+	location /error/ {
+		alias %home%/%user%/web/%domain%/document_errors/;
+	}
+
+	location /vstats/ {
+		alias   %home%/%user%/web/%domain%/stats/;
+		include %home%/%user%/web/%domain%/stats/auth.conf*;
+	}
+
+	include /etc/nginx/conf.d/phpmyadmin.inc*;
+	include /etc/nginx/conf.d/phppgadmin.inc*;
+	include %home%/%user%/conf/web/%domain%/nginx.conf_*;
+}

+ 1 - 1
install/deb/templates/web/nginx/php-fpm/opencart.stpl

@@ -56,7 +56,7 @@ server {
 		rewrite ^/(.+)$ /index.php?_route_=$1 last;
 	}
 
-	location /storage/ {
+	location /system/storage/ {
 		deny all;
 		return 404;
 	}

+ 1 - 1
install/deb/templates/web/nginx/php-fpm/opencart.tpl

@@ -46,7 +46,7 @@ server {
 		rewrite ^/(.+)$ /index.php?_route_=$1 last;
 	}
 
-	location /storage/ {
+	location /system/storage/ {
 		deny all;
 		return 404;
 	}

+ 17 - 0
phpcs.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0"?>
+<ruleset name="HestiaCP">
+	<file>./web/src/app</file>
+
+	<exclude-pattern>*.js</exclude-pattern>
+
+	<!-- This is the rule we inherit from. If you want to exlude some specific rules, see the docs on how to do that -->
+	<rule ref="PSR12"/>
+
+	<!-- Lines can be a bit longer before they break the build -->
+	<rule ref="Generic.Files.LineLength">
+		<properties>
+			<property name="lineLimit" value="120"/>
+			<property name="absoluteLineLimit" value="150"/>
+		</properties>
+	</rule>
+</ruleset>

+ 6 - 0
test/test.bats

@@ -917,6 +917,12 @@ function check_ip_not_banned(){
     refute_output
 }
 
+@test "WEB: Use quick install app on web domain" {
+    run v-quick-install-app install $user $domain Laravel
+    assert_success
+    refute_output
+}
+
 #----------------------------------------------------------#
 #                         IDN                              #
 #----------------------------------------------------------#

+ 22 - 57
web/add/webapp/index.php

@@ -42,25 +42,15 @@ if (!empty($_GET["app"])) {
 	$app_installer_class = "\Hestia\WebApp\Installers\\" . $app . "\\" . $app . "Setup";
 	if (class_exists($app_installer_class)) {
 		try {
-			$app_installer = new $app_installer_class($v_domain, $hestia);
-			$info = $app_installer->info();
-			foreach ($php_versions as $version) {
-				if (in_array($version, $info["php_support"])) {
-					$supported = true;
-					$supported_versions[] = $version;
-				}
-			}
-			if ($supported) {
-				$info["enabled"] = true;
-			} else {
-				$info["enabled"] = false;
+			$app_installer = new $app_installer_class($hestia);
+			$info = $app_installer->getInfo();
+
+			if (!$info->isInstallable()) {
 				$_SESSION["error_msg"] = sprintf(
-					_("Unable to install %s, %s is not available."),
+					_("Unable to install %s, required php version is not available."),
 					$app,
-					"PHP-" . end($info["php_support"]),
 				);
-			}
-			if ($info["enabled"] == true) {
+			} else {
 				$installer = new \Hestia\WebApp\AppWizard($app_installer, $v_domain, $hestia);
 				$GLOBALS["WebappInstaller"] = $installer;
 			}
@@ -81,19 +71,12 @@ if (!empty($_POST["ok"]) && !empty($app)) {
 
 	if ($installer) {
 		try {
-			if (!$installer->execute($_POST)) {
-				$result = $installer->getStatus();
-				if (!empty($result)) {
-					$_SESSION["error_msg"] = implode(PHP_EOL, $result);
-				}
-			} else {
-				$_SESSION["ok_msg"] = sprintf(
-					_("%s installed successfully."),
-					htmlspecialchars($app),
-				);
-				header("Location: /add/webapp/?domain=" . $v_domain);
-				exit();
-			}
+			$installer->execute($_POST);
+
+			$_SESSION["ok_msg"] = sprintf(_("%s installed successfully."), htmlspecialchars($app));
+
+			header("Location: /add/webapp/?domain=" . $v_domain);
+			exit();
 		} catch (Exception $e) {
 			$_SESSION["error_msg"] = $e->getMessage();
 			header("Location: /add/webapp/?app=" . rawurlencode($app) . "&domain=" . $v_domain);
@@ -105,39 +88,21 @@ if (!empty($_POST["ok"]) && !empty($app)) {
 if (!empty($installer)) {
 	render_page($user, $TAB, "setup_webapp");
 } else {
+	$hestia = new \Hestia\System\HestiaApp();
 	$appInstallers = glob(__DIR__ . "/../../src/app/WebApp/Installers/*/*.php");
+
 	$v_web_apps = [];
 	foreach ($appInstallers as $app) {
-		$hestia = new \Hestia\System\HestiaApp();
-		if (
-			preg_match(
-				"/Installers\/([a-zA-Z][a-zA-Z0,9].*)\/([a-zA-Z][a-zA-Z0,9].*).php/",
-				$app,
-				$matches,
-			)
-		) {
-			if ($matches[1] != "Resources") {
-				$app_installer_class =
-					"\Hestia\WebApp\Installers\\" . $matches[1] . "\\" . $matches[1] . "Setup";
-				$app_installer = new $app_installer_class($v_domain, $hestia);
-				$appInstallerInfo = $app_installer->info();
-				$supported = false;
-				$supported_versions = [];
-				foreach ($php_versions as $version) {
-					if (in_array($version, $appInstallerInfo["php_support"])) {
-						$supported = true;
-						$supported_versions[] = $version;
-					}
-				}
-				if ($supported) {
-					$appInstallerInfo["enabled"] = true;
-				} else {
-					$appInstallerInfo["enabled"] = false;
-				}
-				$v_web_apps[] = $appInstallerInfo;
-			}
+		$pattern = "/Installers\/([a-zA-Z][a-zA-Z0,9].*)\/([a-zA-Z][a-zA-Z0,9].*)Setup\.php/";
+		$class = "\Hestia\WebApp\Installers\%s\%sSetup";
+
+		if (preg_match($pattern, $app, $matches)) {
+			$app_installer_class = sprintf($class, $matches[1], $matches[1]);
+
+			$v_web_apps[] = (new $app_installer_class($hestia))->getInfo();
 		}
 	}
+
 	render_page($user, $TAB, "list_webapps");
 }
 

+ 0 - 7
web/src/app/Models/DnsDomain.php

@@ -1,7 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hestia\Models;
-
-class DnsDomain extends Model {}

+ 0 - 7
web/src/app/Models/MailDomain.php

@@ -1,7 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hestia\Models;
-
-class MailDomain extends Model {}

+ 0 - 28
web/src/app/Models/Model.php

@@ -1,28 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hestia\Models;
-
-class Model {
-	public function __construct() {}
-
-	public static function all() {}
-}
-
-/**
- * Minimal list of models required
- *
- * User
- *
- * WebDomain
- *
- * MailDomain
- * `-MailAccount
- *
- * DNSDomain
- * `-DNSRecord
- *
- * Database
- *
- */

+ 0 - 7
web/src/app/Models/User.php

@@ -1,7 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hestia\Models;
-
-class User extends Model {}

+ 0 - 7
web/src/app/Models/WebDomain.php

@@ -1,7 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hestia\Models;
-
-class WebDomain extends Model {}

+ 413 - 361
web/src/app/System/HestiaApp.php

@@ -3,365 +3,417 @@
 declare(strict_types=1);
 
 namespace Hestia\System;
-use function Hestiacp\quoteshellarg\quoteshellarg;
-
-class HestiaApp {
-	/** @var string[] */
-	public $errors;
-	protected const TMPDIR_DOWNLOADS = "/tmp/hestia-webapp";
-	protected $phpsupport = false;
-
-	public function __construct() {
-		@mkdir(self::TMPDIR_DOWNLOADS);
-	}
-
-	public function runUser(string $cmd, $args, &$cmd_result = null): bool {
-		if (!empty($args) && is_array($args)) {
-			array_unshift($args, $this->user());
-		} else {
-			$args = [$this->user(), $args];
-		}
-		return $this->run($cmd, $args, $cmd_result);
-	}
-
-	public function installComposer($version) {
-		exec("curl https://composer.github.io/installer.sig", $output);
-
-		$signature = implode(PHP_EOL, $output);
-		if (empty($signature)) {
-			throw new \Exception("Error reading composer signature");
-		}
-
-		$composer_setup =
-			self::TMPDIR_DOWNLOADS . DIRECTORY_SEPARATOR . "composer-setup-" . $signature . ".php";
-
-		exec(
-			"wget https://getcomposer.org/installer --quiet -O " . quoteshellarg($composer_setup),
-			$output,
-			$return_code,
-		);
-		if ($return_code !== 0) {
-			throw new \Exception("Error downloading composer");
-		}
-
-		if ($signature !== hash_file("sha384", $composer_setup)) {
-			unlink($composer_setup);
-			throw new \Exception("Invalid composer signature");
-		}
-
-		$install_folder = $this->getUserHomeDir() . DIRECTORY_SEPARATOR . ".composer";
-
-		if (!file_exists($install_folder)) {
-			exec(HESTIA_CMD . "v-rebuild-user " . $this->user(), $output, $return_code);
-			if ($return_code !== 0) {
-				throw new \Exception("Unable to rebuild user");
-			}
-		}
-
-		$this->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php",
-				$composer_setup,
-				"--quiet",
-				"--install-dir=" . $install_folder,
-				"--filename=composer",
-				"--$version",
-			],
-			$status,
-		);
-
-		unlink($composer_setup);
-
-		if ($status->code !== 0) {
-			throw new \Exception("Error installing composer");
-		}
-	}
-
-	public function updateComposer($version) {
-		$this->runUser("v-run-cli-cmd", ["composer", "selfupdate", "--$version"]);
-	}
-
-	public function runComposer($args, &$cmd_result = null, $data = []): bool {
-		$composer =
-			$this->getUserHomeDir() .
-			DIRECTORY_SEPARATOR .
-			".composer" .
-			DIRECTORY_SEPARATOR .
-			"composer";
-		if (!is_file($composer)) {
-			$this->installComposer($data["version"]);
-		} else {
-			$this->updateComposer($data["version"]);
-		}
-		if (empty($data["php_version"])) {
-			$data["php_version"] = "";
-		}
-		if (!empty($args) && is_array($args)) {
-			array_unshift($args, "php" . $data["php_version"], $composer);
-		} else {
-			$args = ["php" . $data["php_version"], $composer, $args];
-		}
-
-		return $this->runUser("v-run-cli-cmd", $args, $cmd_result);
-	}
-
-	public function runWp($args, &$cmd_result = null): bool {
-		$wp =
-			$this->getUserHomeDir() . DIRECTORY_SEPARATOR . ".wp-cli" . DIRECTORY_SEPARATOR . "wp";
-		if (!is_file($wp)) {
-			$this->runUser("v-add-user-wp-cli", []);
-		} else {
-			$this->runUser("v-run-cli-cmd", [$wp, "cli", "update", "--yes"]);
-		}
-		array_unshift($args, $wp);
-
-		return $this->runUser("v-run-cli-cmd", $args, $cmd_result);
-	}
-
-	// Logged in user
-	public function realuser(): string {
-		return $_SESSION["user"];
-	}
-
-	// Effective user
-	public function user(): string {
-		$user = $this->realuser();
-		if ($_SESSION["userContext"] === "admin" && !empty($_SESSION["look"])) {
-			$user = $_SESSION["look"];
-		}
-
-		if (strpos($user, DIRECTORY_SEPARATOR) !== false) {
-			throw new \Exception("illegal characters in username");
-		}
-		return $user;
-	}
-
-	public function getUserHomeDir() {
-		$info = posix_getpwnam($this->user());
-		return $info["dir"];
-	}
-
-	public function userOwnsDomain(string $domain): bool {
-		return $this->runUser("v-list-web-domain", [$domain, "json"]);
-	}
-
-	public function checkDatabaseLimit() {
-		$status = $this->runUser("v-list-user", ["json"], $result);
-		$result->json[$this->user()];
-		if ($result->json[$this->user()]["DATABASES"] != "unlimited") {
-			if (
-				$result->json[$this->user()]["DATABASES"] -
-					$result->json[$this->user()]["U_DATABASES"] <
-				1
-			) {
-				return false;
-			}
-		}
-		return true;
-	}
-	public function databaseAdd(
-		string $dbname,
-		string $dbuser,
-		string $dbpass,
-		string $dbtype = "mysql",
-		string $dbhost = "localhost",
-		string $charset = "utf8mb4",
-	) {
-		$v_password = tempnam("/tmp", "hst");
-		$fp = fopen($v_password, "w");
-		fwrite($fp, $dbpass . "\n");
-		fclose($fp);
-		$status = $this->runUser("v-add-database", [
-			$dbname,
-			$dbuser,
-			$v_password,
-			$dbtype,
-			$dbhost,
-			$charset,
-		]);
-		if (!$status) {
-			$this->errors[] = _("Unable to add database!");
-		}
-		unlink($v_password);
-		return $status;
-	}
-
-	public function getCurrentBackendTemplate(string $domain) {
-		$status = $this->runUser("v-list-web-domain", [$domain, "json"], $return_message);
-		$version = $return_message->json[$domain]["BACKEND"];
-		if (!empty($version)) {
-			if ($version != "default") {
-				$test = preg_match("/^.*PHP-([0-9])\_([0-9])/", $version, $match);
-				return $match[1] . "." . $match[2];
-			} else {
-				$supported = $this->run("v-list-sys-php", "json", $result);
-				return $result->json[0];
-			}
-		} else {
-			$supported = $this->run("v-list-sys-php", "json", $result);
-			return $result->json[0];
-		}
-	}
-
-	public function changeWebTemplate(string $domain, string $template) {
-		$status = $this->runUser("v-change-web-domain-tpl", [$domain, $template]);
-	}
-	public function changeBackendTemplate(string $domain, string $template) {
-		$status = $this->runUser("v-change-web-domain-backend-tpl", [$domain, $template]);
-	}
-
-	public function listSuportedPHP() {
-		if (!$this->phpsupport) {
-			$status = $this->run("v-list-sys-php", "json", $result);
-			$this->phpsupport = $result->json;
-		}
-		return $this->phpsupport;
-	}
-
-	/*
-		Return highest available supported php version
-		Eg: Package requires: 7.3 or 7.4 and system has 8.0 and 7.4 it will return 7.4
-				Package requires: 8.0 or 8.1 and system has 8.0 and 7.4 it will return 8.0
-				Package requires: 7.4 or 8.0 and system has 8.0 and 7.4 it will return 8.0
-				If package isn't supported by the available php version false will returned
-		*/
-	public function getSupportedPHP($support) {
-		$versions = $this->listSuportedPHP();
-		$supported = false;
-		$supported_versions = [];
-
-		foreach ($versions as $version) {
-			if (in_array($version, $support)) {
-				$supported = true;
-				$supported_versions[] = $version;
-			}
-		}
-		if ($supported) {
-			return $supported_versions;
-		} else {
-			return false;
-		}
-	}
-
-	public function getWebDomainIp(string $domain) {
-		$this->runUser("v-list-web-domain", [$domain, "json"], $result);
-		$ip = $result->json[$domain]["IP"];
-		return filter_var($ip, FILTER_VALIDATE_IP);
-	}
-
-	public function getWebDomainPath(string $domain) {
-		return Util::join_paths($this->getUserHomeDir(), "web", $domain);
-	}
-
-	public function downloadUrl(string $src, $path = null, &$result = null) {
-		if (strpos($src, "http://") !== 0 && strpos($src, "https://") !== 0) {
-			return false;
-		}
-
-		exec(
-			"/usr/bin/wget --tries 3 --timeout=30 --no-dns-cache -nv " .
-				quoteshellarg($src) .
-				" -P " .
-				quoteshellarg(self::TMPDIR_DOWNLOADS) .
-				" 2>&1",
-			$output,
-			$return_var,
-		);
-		if ($return_var !== 0) {
-			return false;
-		}
-
-		if (
-			!preg_match(
-				'/URL:\s*(.+?)\s*\[(.+?)\]\s*->\s*"(.+?)"/',
-				implode(PHP_EOL, $output),
-				$matches,
-			)
-		) {
-			return false;
-		}
-
-		if (empty($matches) || count($matches) != 4) {
-			return false;
-		}
-
-		$status["url"] = $matches[1];
-		$status["file"] = $matches[3];
-		$result = (object) $status;
-		return true;
-	}
-
-	public function archiveExtract(string $src, string $path, $skip_components = null) {
-		if (empty($path)) {
-			throw new \Exception("Error extracting archive: missing target folder");
-		}
-
-		if (realpath($src)) {
-			$archive_file = $src;
-		} else {
-			if (!$this->downloadUrl($src, null, $download_result)) {
-				throw new \Exception("Error downloading archive");
-			}
-			$archive_file = $download_result->file;
-		}
-
-		$result = $this->runUser("v-extract-fs-archive", [
-			$archive_file,
-			$path,
-			null,
-			$skip_components,
-		]);
-		unlink($archive_file);
-
-		return $result;
-	}
-
-	public function cleanupTmpDir(): void {
-		$files = glob(self::TMPDIR_DOWNLOADS . "/*");
-		foreach ($files as $file) {
-			if (is_file($file)) {
-				unlink($file);
-			}
-		}
-	}
-
-	public function __destruct() {
-		$this->cleanupTmpDir();
-	}
-
-	private function run(string $cmd, $args, &$cmd_result = null): bool {
-		$cli_script = realpath(HESTIA_DIR_BIN . $cmd);
-		if (!str_starts_with((string) $cli_script, HESTIA_DIR_BIN)) {
-			$errstr = "$cmd is trying to traverse outside of " . HESTIA_DIR_BIN;
-			trigger_error($errstr);
-			throw new \Exception($errstr);
-		}
-		$cli_script = "/usr/bin/sudo " . quoteshellarg($cli_script);
-
-		$cli_arguments = "";
-		if (!empty($args) && is_array($args)) {
-			foreach ($args as $arg) {
-				$cli_arguments .= quoteshellarg((string) $arg) . " ";
-			}
-		} else {
-			$cli_arguments = quoteshellarg($args);
-		}
-
-		exec($cli_script . " " . $cli_arguments . " 2>&1", $output, $exit_code);
-
-		$result["code"] = $exit_code;
-		$result["args"] = $cli_arguments;
-		$result["raw"] = $output;
-		$result["text"] = implode(PHP_EOL, $output);
-		$result["json"] = json_decode($result["text"], true);
-		$cmd_result = (object) $result;
-		if ($exit_code > 0) {
-			//log error message in nginx-error.log
-			trigger_error($cli_script . " " . $cli_arguments . " | " . $result["text"]);
-			//throw exception if command fails
-			throw new \Exception($result["text"]);
-		}
-		return $exit_code === 0;
-	}
+
+use Exception;
+use RuntimeException;
+use Symfony\Component\Process\Exception\ProcessFailedException;
+use Symfony\Component\Process\Process;
+
+use function _;
+use function array_column;
+use function array_filter;
+use function basename;
+use function chmod;
+use function explode;
+use function in_array;
+use function realpath;
+use function sprintf;
+use function str_contains;
+use function trigger_error;
+use function unlink;
+
+use function var_dump;
+use const DIRECTORY_SEPARATOR;
+
+class HestiaApp
+{
+    public function addDirectory(string $path): void
+    {
+        try {
+            $this->runUser('v-add-fs-directory', [$path]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(sprintf('Failed to add directory "%s"', $path));
+        }
+    }
+
+    public function copyDirectory(string $fromPath, string $toPath): void
+    {
+        try {
+            $this->runUser('v-copy-fs-directory', [$fromPath, $toPath]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(
+                sprintf('Failed to copy directory "%s" to "%s"', $fromPath, $toPath),
+            );
+        }
+    }
+
+    public function deleteDirectory(string $path): void
+    {
+        try {
+            $this->runUser('v-delete-fs-directory', [$path]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(sprintf('Failed to remove directory "%s"', $path));
+        }
+    }
+
+    /**
+     * @param string $path
+     * @return string[]
+     */
+    public function listFiles(string $path): array
+    {
+        try {
+            $result = $this->runUser('v-run-cli-cmd', ['ls', '-A', $path]);
+
+            return array_filter(explode("\n", $result->output));
+        } catch (ProcessFailedException) {
+            throw new RuntimeException('Cannot list domain files');
+        }
+    }
+
+    public function readFile(string $path): string
+    {
+        try {
+            $result = $this->runUser('v-open-fs-file', [$path]);
+
+            return $result->output;
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(sprintf('Failed to remove directory "%s"', $path));
+        }
+    }
+
+    public function createFile(string $path, string $contents): void
+    {
+        $tmpFile = tempnam('/tmp', 'hst.');
+
+        if (!$tmpFile) {
+            throw new RuntimeException('Error creating temp file');
+        }
+
+        if (!file_put_contents($tmpFile, $contents)) {
+            throw new RuntimeException('Error writing to temp file');
+        }
+
+        chmod($tmpFile, 0644);
+
+        $this->runUser('v-copy-fs-file', [$tmpFile, $path]);
+
+        unlink($tmpFile);
+    }
+
+    public function moveFile(string $fromPath, string $toPath): void
+    {
+        try {
+            $this->runUser('v-move-fs-file', [$fromPath, $toPath]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(
+                sprintf('Failed to move file "%s" to "%s"', $fromPath, $toPath),
+            );
+        }
+    }
+
+    public function changeFilePermissions(string $filePath, string $permission): void
+    {
+        try {
+            $this->runUser('v-change-fs-file-permission', [$filePath, $permission]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(
+                sprintf('Failed to change file "%s" permissions to "%s"', $filePath, $permission),
+            );
+        }
+    }
+
+    public function deleteFile(string $filePath): void
+    {
+        try {
+            $this->runUser('v-delete-fs-file', [$filePath]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(sprintf('Failed to delete file "%s"', $filePath));
+        }
+    }
+
+    public function archiveExtract(string $filePath, string $extractDirectoryPath): void
+    {
+        if (!realpath($filePath)) {
+            throw new RuntimeException('Error extracting archive: archive file not found');
+        }
+
+        if (empty($extractDirectoryPath)) {
+            throw new RuntimeException('Error extracting archive: missing target folder');
+        }
+
+        try {
+            $this->runUser('v-extract-fs-archive', [$filePath, $extractDirectoryPath, '', 1]);
+
+            unlink($filePath);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(sprintf('Failed to extract "%s"', $filePath));
+        }
+    }
+
+    public function downloadUrl(string $url, string $path): string
+    {
+        try {
+            $result = $this->runUser('v-run-cli-cmd', [
+                '/usr/bin/wget',
+                '--tries',
+                '3',
+                '--timeout=30',
+                '--no-dns-cache',
+                '-nv',
+                $url,
+                '-P',
+                $path,
+            ]);
+
+            $pattern = '/URL:\s*.+?\s*\[.+?\]\s*->\s*"(.+?)"/';
+            if (preg_match($pattern, $result->output, $matches) && count($matches) > 1) {
+                return $matches[1];
+            }
+
+            // Fallback on guessed result
+            return $path . '/' . basename($url);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(
+                sprintf('Failed to download "%s" to path "%s"', $url, $path),
+            );
+        }
+    }
+
+    public function sendPostRequest($url, array $formData, array $headers = []): void
+    {
+        $ch = curl_init($url);
+
+        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+        curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_POST, true);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($formData));
+
+        if ($headers !== []) {
+            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+        }
+
+        curl_exec($ch);
+
+        $error = curl_error($ch);
+        $errno = curl_errno($ch);
+
+        curl_close($ch);
+
+        if (0 !== $errno) {
+            throw new RuntimeException($error, $errno);
+        }
+    }
+
+    // Effective user
+    public function user(): string
+    {
+        $user = $_SESSION['user'];
+
+        if ($_SESSION['userContext'] === 'admin' && !empty($_SESSION['look'])) {
+            $user = $_SESSION['look'];
+        }
+
+        if (str_contains($user, DIRECTORY_SEPARATOR)) {
+            throw new Exception('illegal characters in username');
+        }
+        return $user;
+    }
+
+    public function getUserHomeDir(): string
+    {
+        $info = posix_getpwnam($this->user());
+        return $info['dir'];
+    }
+
+    public function userOwnsDomain(string $domain): bool
+    {
+        try {
+            $this->runUser('v-list-web-domain', [$domain, 'json']);
+
+            return true;
+        } catch (ProcessFailedException) {
+            return false;
+        }
+    }
+
+    /**
+     * @return string[]
+     */
+    public function getDatabaseHosts(string $type): array
+    {
+        try {
+            $result = $this->run('v-list-database-hosts', ['json']);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException('Failed to list database hosts');
+        }
+
+        $hostOfType = array_filter(
+            $result->getOutputJson(),
+            fn(array $host) => $host['TYPE'] === $type,
+        );
+
+        return array_column($hostOfType, 'HOST');
+    }
+
+    public function checkDatabaseLimit(): bool
+    {
+        try {
+            $result = $this->runUser('v-list-user', ['json']);
+
+            $userInfo = $result->getOutputJson()[$this->user()];
+
+            return $userInfo['DATABASES'] === 'unlimited' ||
+                $userInfo['DATABASES'] - $userInfo['U_DATABASES'] < 1;
+        } catch (ProcessFailedException) {
+            throw new RuntimeException('Unable to check database limit');
+        }
+    }
+
+    public function databaseAdd(
+        string $name,
+        string $user,
+        string $password,
+        string $host,
+        string $type = 'mysql',
+        string $charset = 'utf8mb4',
+    ): void {
+        $passwordFile = tempnam('/tmp', 'hst');
+
+        $fp = fopen($passwordFile, 'w');
+        fwrite($fp, $password . "\n");
+        fclose($fp);
+
+        try {
+            $this->runUser('v-add-database', [$name, $user, $passwordFile, $type, $host, $charset]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(_('Unable to add database!'));
+        } finally {
+            unlink($passwordFile);
+        }
+    }
+
+    public function changeWebTemplate(string $domain, string $template): void
+    {
+        try {
+            $this->runUser('v-change-web-domain-tpl', [$domain, $template]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(sprintf('Failed to change to template "%s"', $template));
+        }
+    }
+
+    public function changeBackendTemplate(string $domain, string $template): void
+    {
+        try {
+            $this->runUser('v-change-web-domain-backend-tpl', [$domain, $template]);
+        } catch (ProcessFailedException) {
+            throw new RuntimeException(
+                sprintf('Failed to change backend template to "%s"', $template),
+            );
+        }
+    }
+
+    public function getSupportedPHPVersions(array $supportedPHP): array
+    {
+        try {
+            // Load installed PHP Versions
+            $result = $this->run('v-list-sys-php', ['json']);
+
+            $installedPHPVersions = array_filter(
+                $result->getOutputJson(),
+                fn(string $installedPHP) => in_array($installedPHP, $supportedPHP, true),
+            );
+
+            sort($installedPHPVersions);
+
+            return $installedPHPVersions;
+        } catch (ProcessFailedException) {
+            throw new RuntimeException('Failed to load installed PHP versions');
+        }
+    }
+
+    public function getWebDomain(string $domainName): WebDomain
+    {
+        try {
+            $result = $this->runUser('v-list-web-domain', [$domainName, 'json']);
+
+            return new WebDomain(
+                $domainName,
+                Util::joinPaths($this->getUserHomeDir(), 'web', $domainName),
+                filter_var($result->getOutputJson()[$domainName]['IP'], FILTER_VALIDATE_IP),
+                $result->getOutputJson()[$domainName]['SSL'] === 'yes',
+            );
+        } catch (ProcessFailedException) {
+            throw new Exception('Cannot find domain for user');
+        }
+    }
+
+    public function runComposer(string $phpVersion, array $arguments): HestiaCommandResult
+    {
+        $this->runUser('v-add-user-composer', ['2', 'yes']);
+
+        $composerBin = $this->getUserHomeDir() . '/.composer/composer';
+
+        return $this->runPHP($phpVersion, $composerBin, $arguments);
+    }
+
+    public function runWp(string $phpVersion, array $arguments): HestiaCommandResult
+    {
+        $this->runUser('v-add-user-wp-cli', ['yes']);
+
+        $wpCliBin = $this->getUserHomeDir() . '/.wp-cli/wp';
+
+        return $this->runPHP($phpVersion, $wpCliBin, $arguments);
+    }
+
+    public function runPHP(
+        string $phpVersion,
+        string $command,
+        array $arguments,
+    ): HestiaCommandResult {
+        $phpCommand = ['/usr/bin/php' . $phpVersion, $command, ...$arguments];
+
+        try {
+            return $this->runUser('v-run-cli-cmd', $phpCommand);
+        } catch (ProcessFailedException $exception) {
+            throw new RuntimeException(
+                sprintf(
+                    'Failed to run php command "%s"',
+                    $exception->getProcess()->getCommandLine(),
+                ),
+            );
+        }
+    }
+
+    private function runUser(string $cmd, array $arguments): HestiaCommandResult
+    {
+        return $this->run($cmd, [$this->user(), ...$arguments]);
+    }
+
+    private function run(string $cmd, array $arguments): HestiaCommandResult
+    {
+        $cli_script = realpath(HESTIA_DIR_BIN . $cmd);
+
+        $command = ['/usr/bin/sudo', $cli_script, ...$arguments];
+
+        // Escape spaces to disallow splitting commands and allow spaces in names like site names
+        $command = array_map(fn(string $argument) => str_replace(' ', '\\ ', $argument), $command);
+
+        $process = new Process($command);
+        $process->run();
+
+        if (!$process->isSuccessful()) {
+            //log error message in nginx-error.log
+            trigger_error($process->getCommandLine() . ' | ' . $process->getOutput());
+            //throw exception if command fails
+            throw new ProcessFailedException($process);
+        }
+
+        return new HestiaCommandResult(
+            $process->getCommandLine(),
+            $process->getExitCode(),
+            $process->getOutput(),
+        );
+    }
 }

+ 0 - 7
web/src/app/System/HestiaCli.php

@@ -1,7 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Hestia\System;
-
-class HestiaCLI {}

+ 22 - 0
web/src/app/System/HestiaCommandResult.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\System;
+
+use function json_decode;
+
+class HestiaCommandResult
+{
+    public function __construct(
+        public readonly string $command,
+        public readonly int $exitCode,
+        public readonly string $output,
+    ) {
+    }
+
+    public function getOutputJson(): array
+    {
+        return (array) json_decode($this->output, true, 512, JSON_THROW_ON_ERROR);
+    }
+}

+ 28 - 25
web/src/app/System/Util.php

@@ -4,30 +4,33 @@ declare(strict_types=1);
 
 namespace Hestia\System;
 
-class Util {
-	/*
-	 * Method from: https://stackoverflow.com/a/15575293
-	 * https://stackoverflow.com/questions/1091107/how-to-join-filesystem-path-strings-in-php
-	 */
-	public static function join_paths() {
-		$paths = [];
-		foreach (func_get_args() as $arg) {
-			if ($arg !== "") {
-				$paths[] = $arg;
-			}
-		}
-		return preg_replace("#/+#", "/", join("/", $paths));
-	}
+class Util
+{
+    /*
+     * Method from: https://stackoverflow.com/a/15575293
+     * https://stackoverflow.com/questions/1091107/how-to-join-filesystem-path-strings-in-php
+     */
+    public static function joinPaths()
+    {
+        $paths = [];
+        foreach (func_get_args() as $arg) {
+            if ($arg !== '') {
+                $paths[] = $arg;
+            }
+        }
+        return preg_replace('#/+#', '/', implode('/', $paths));
+    }
 
-	public static function generate_string(int $length = 16, $full = true) {
-		$chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
-		if ($full) {
-			$chars .= '~`!@|#[]$%^&*() _-=+{}:;<>?,./';
-		}
-		$random_string = "";
-		for ($i = 0; $i < $length; $i++) {
-			$random_string .= $chars[random_int(0, strlen($chars) - 1)];
-		}
-		return $random_string;
-	}
+    public static function generateString(int $length = 16, $full = true)
+    {
+        $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+        if ($full) {
+            $chars .= '~`!@|#[]$%^&*() _-=+{}:;<>?,./';
+        }
+        $random_string = '';
+        for ($i = 0; $i < $length; $i++) {
+            $random_string .= $chars[random_int(0, strlen($chars) - 1)];
+        }
+        return $random_string;
+    }
 }

+ 16 - 0
web/src/app/System/WebDomain.php

@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\System;
+
+class WebDomain
+{
+    public function __construct(
+        public readonly string $domainName,
+        public readonly string $domainPath,
+        public readonly string $ipAddress,
+        public readonly bool $isSslEnabled,
+    ) {
+    }
+}

+ 182 - 141
web/src/app/WebApp/AppWizard.php

@@ -1,148 +1,189 @@
 <?php
+
 declare(strict_types=1);
 
 namespace Hestia\WebApp;
 
 use Hestia\System\HestiaApp;
-
-class AppWizard {
-	private $domain;
-	private $appsetup;
-	private $appcontext;
-	private $formNamespace = "webapp";
-	private $errors;
-
-	private $database_config = [
-		"database_create" => ["type" => "boolean", "value" => true],
-		"database_host" => ["type" => "select"],
-		"database_name" => ["type" => "text", "placeholder" => "auto"],
-		"database_user" => ["type" => "text", "placeholder" => "auto"],
-		"database_password" => ["type" => "password", "placeholder" => "auto"],
-	];
-
-	public function __construct(InstallerInterface $app, string $domain, HestiaApp $context) {
-		$this->domain = $domain;
-		$this->appcontext = $context;
-
-		if (!$this->appcontext->userOwnsDomain($domain)) {
-			throw new \Exception("User does not have access to domain [$domain]");
-		}
-
-		$this->appsetup = $app;
-	}
-
-	public function getStatus() {
-		return $this->errors;
-	}
-
-	public function isDomainRootClean() {
-		$this->appcontext->runUser("v-run-cli-cmd", ["ls", $this->appsetup->getDocRoot()], $status);
-		if ($status->code !== 0) {
-			throw new \Exception("Cannot list domain files");
-		}
-
-		$files = $status->raw;
-		if (count($files) > 2) {
-			return false;
-		}
-
-		foreach ($files as $file) {
-			if (!in_array($file, ["index.html", "robots.txt"])) {
-				return false;
-			}
-		}
-		return true;
-	}
-
-	public function formNs() {
-		return $this->formNamespace;
-	}
-
-	public function getOptions() {
-		$options = $this->appsetup->getOptions();
-
-		$config = $this->appsetup->getConfig();
-		$options = array_merge($options, [
-			"php_version" => [
-				"type" => "select",
-				"value" => $this->appcontext->getCurrentBackendTemplate($this->domain),
-				"options" => $this->appcontext->getSupportedPHP(
-					$config["server"]["php"]["supported"],
-				),
-			],
-		]);
-
-		if ($this->appsetup->withDatabase()) {
-			exec(HESTIA_CMD . "v-list-database-hosts json", $output, $return_var);
-			$db_hosts_tmp1 = json_decode(implode("", $output), true, flags: JSON_THROW_ON_ERROR);
-			$db_hosts_tmp2 = array_map(function ($host) {
-				return $host["HOST"];
-			}, $db_hosts_tmp1);
-			$db_hosts = array_values(array_unique($db_hosts_tmp2));
-			unset($output);
-			unset($db_hosts_tmp1);
-			unset($db_hosts_tmp2);
-
-			$this->database_config["database_host"]["options"] = $db_hosts;
-
-			$options = array_merge($options, $this->database_config);
-		}
-		return $options;
-	}
-
-	public function info() {
-		return $this->appsetup->info();
-	}
-
-	public function filterOptions(array $options) {
-		$filteredoptions = [];
-		array_walk($options, function ($value, $key) use (&$filteredoptions) {
-			if (strpos($key, $this->formNs() . "_") === 0) {
-				$option = str_replace($this->formNs() . "_", "", $key);
-				$filteredoptions[$option] = $value;
-			}
-		});
-		return $filteredoptions;
-	}
-
-	public function execute(array $options) {
-		$options = $this->filterOptions($options);
-
-		$random_num = (string) random_int(10000, 99999);
-		if ($this->appsetup->withDatabase() && !empty($options["database_create"])) {
-			if (empty($options["database_name"])) {
-				$options["database_name"] = $random_num;
-			}
-
-			if (empty($options["database_user"])) {
-				$options["database_user"] = $random_num;
-			}
-
-			if (empty($options["database_password"])) {
-				$options["database_password"] = bin2hex(random_bytes(10));
-			}
-
-			if (!$this->appcontext->checkDatabaseLimit()) {
-				$this->errors[] = _("Unable to add database! Limit reached!");
-				return false;
-			}
-
-			if (
-				!$this->appcontext->databaseAdd(
-					$options["database_name"],
-					$options["database_user"],
-					$options["database_password"],
-					"mysql",
-					$options["database_host"],
-				)
-			) {
-				$this->errors[] = "Error adding database";
-				return false;
-			}
-		}
-
-		if (empty($this->errors)) {
-			return $this->appsetup->install($options);
-		}
-	}
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+use Hestia\WebApp\InstallationTarget\TargetDatabase;
+use Hestia\WebApp\InstallationTarget\TargetDomain;
+use RuntimeException;
+
+use function array_filter;
+use function bin2hex;
+use function in_array;
+use function max;
+use function str_replace;
+use function str_starts_with;
+use function strtolower;
+
+class AppWizard
+{
+    public function __construct(
+        private readonly InstallerInterface $installer,
+        private readonly string $domain,
+        private readonly HestiaApp $appcontext,
+    ) {
+        if (!$appcontext->userOwnsDomain($domain)) {
+            throw new RuntimeException('User does not have access to domain [$domain]');
+        }
+    }
+
+    public function isDomainRootClean(): bool
+    {
+        $installationTarget = $this->getInstallationTarget($this->domain);
+        $files = $this->appcontext->listFiles($installationTarget->getDocRoot());
+
+        $filteredFiles = array_filter(
+            $files,
+            fn(string $file) => !in_array($file, ['index.html', 'robots.txt']),
+        );
+
+        return count($filteredFiles) <= 0;
+    }
+
+    public function formNamespace(): string
+    {
+        return 'webapp_';
+    }
+
+    /**
+     * @return mixed[]
+     */
+    public function getOptions(): array
+    {
+        $form = $this->installer->getConfig('form');
+        $info = $this->installer->getInfo();
+
+        $form = array_merge($form, [
+            'php_version' => [
+                'type' => 'select',
+                'value' => (string) max($info->supportedPHPVersions),
+                'options' => $info->supportedPHPVersions,
+            ],
+        ]);
+
+        if ($this->installer->getConfig('database') === true) {
+            $databaseName = $this->generateDatabaseName();
+
+            $databaseOptions = [
+                'database_create' => [
+                    'type' => 'boolean',
+                    'value' => true,
+                ],
+                'database_host' => [
+                    'type' => 'select',
+                    'options' => $this->appcontext->getDatabaseHosts('mysql'),
+                ],
+                'database_name' => [
+                    'type' => 'text',
+                    'value' => $databaseName,
+                ],
+                'database_user' => [
+                    'type' => 'text',
+                    'value' => $databaseName,
+                ],
+                'database_password' => [
+                    'type' => 'password',
+                    'placeholder' => 'auto',
+                ],
+            ];
+
+            $form = array_merge($form, $databaseOptions);
+        }
+
+        return $form;
+    }
+
+    public function applicationName(): string
+    {
+        return $this->installer->getInfo()->name;
+    }
+
+    /**
+     * @param mixed[] $options
+     * @return mixed[]
+     */
+    public function filterOptions(array $options): array
+    {
+        $filteredOptions = [];
+
+        foreach ($options as $key => $value) {
+            if (!str_starts_with($key, $this->formNamespace())) {
+                continue;
+            }
+
+            $filteredOptions[str_replace($this->formNamespace(), '', $key)] = $value;
+        }
+
+        return $filteredOptions;
+    }
+
+    public function execute(array $options): void
+    {
+        $target = $this->getInstallationTarget($this->domain);
+
+        $options = $this->filterOptions($options);
+
+        if ($this->installer->getConfig('database') === true) {
+            if (empty($options['database_name'])) {
+                $options['database_name'] = $this->generateDatabaseName();
+            }
+
+            if (empty($options['database_user'])) {
+                $options['database_user'] = $this->generateDatabaseName();
+            }
+
+            if (empty($options['database_password'])) {
+                $options['database_password'] = bin2hex(random_bytes(10));
+            }
+
+            $target->addTargetDatabase(
+                new TargetDatabase(
+                    $options['database_host'],
+                    $this->appcontext->user() . '_' . $options['database_name'],
+                    $this->appcontext->user() . '_' . $options['database_user'],
+                    $options['database_password'],
+                    !empty($options['database_create']),
+                ),
+            );
+        }
+
+        $this->installer->install($target, $options);
+    }
+
+    private function getInstallationTarget(string $domainName): InstallationTarget
+    {
+        $webDomain = $this->appcontext->getWebDomain($domainName);
+
+        if (empty($webDomain->domainPath) || !is_dir($webDomain->domainPath)) {
+            throw new RuntimeException(
+                sprintf(
+                    'Web domain path "%s" not found for domain "%s"',
+                    $webDomain->domainPath,
+                    $webDomain->domainName,
+                ),
+            );
+        }
+
+        return new InstallationTarget(
+            new TargetDomain(
+                $webDomain->domainName,
+                $webDomain->domainPath,
+                $webDomain->ipAddress,
+                $webDomain->isSslEnabled,
+            ),
+            TargetDatabase::noDatabase(),
+        );
+    }
+
+    private function generateDatabaseName(): string
+    {
+        // Make the database and user easy to recognise but hard to guess
+        $safeAppName = str_replace(' ', '_', strtolower($this->applicationName()));
+        $randomString = bin2hex(random_bytes(5));
+
+        return $safeAppName . '_' . $randomString;
+    }
 }

+ 181 - 0
web/src/app/WebApp/BaseSetup.php

@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\WebApp;
+
+use Hestia\System\HestiaApp;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+use Hestia\WebApp\InstallationTarget\TargetDatabase;
+use RuntimeException;
+
+use function basename;
+use function dirname;
+use function is_string;
+use function sprintf;
+use function str_replace;
+use function str_starts_with;
+
+abstract class BaseSetup implements InstallerInterface
+{
+    protected array $info;
+    protected array $config;
+
+    public function __construct(protected HestiaApp $appcontext)
+    {
+    }
+
+    public function getInfo(): InstallerInfo
+    {
+        $supportedPHPVersions = $this->appcontext->getSupportedPHPVersions(
+            $this->config['server']['php']['supported'],
+        );
+
+        return InstallerInfo::fromArray([
+            ...$this->info,
+            'supportedPHPVersions' => $supportedPHPVersions,
+        ]);
+    }
+
+    public function getConfig(string $section = ''): mixed
+    {
+        return !empty($section) ? $this->config[$section] : $this->config;
+    }
+
+    public function install(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->deleteFile($target->getDocRoot('robots.txt'));
+        $this->appcontext->deleteFile($target->getDocRoot('index.html'));
+
+        $this->retrieveResources($target, $options);
+        $this->setupDatabase($target->database);
+        $this->setupWebServer($target->domain->domainName, $options['php_version']);
+        $this->setupApplication($target, $options);
+    }
+
+    abstract protected function setupApplication(InstallationTarget $target, array $options): void;
+
+    private function setupWebServer(string $domainName, string $phpVersion): void
+    {
+        if ($_SESSION['WEB_SYSTEM'] === 'nginx') {
+            if (isset($this->config['server']['nginx']['template'])) {
+                $this->appcontext->changeWebTemplate(
+                    $domainName,
+                    $this->config['server']['nginx']['template'],
+                );
+            } else {
+                $this->appcontext->changeWebTemplate($domainName, 'default');
+            }
+        } else {
+            if (isset($this->config['server']['apache2']['template'])) {
+                $this->appcontext->changeWebTemplate(
+                    $domainName,
+                    $this->config['server']['apache2']['template'],
+                );
+            } else {
+                $this->appcontext->changeWebTemplate($domainName, 'default');
+            }
+        }
+        if ($_SESSION['WEB_BACKEND'] === 'php-fpm') {
+            if (isset($this->config['server']['php']['supported'])) {
+                $supportedPHPVersions = $this->appcontext->getSupportedPHPVersions(
+                    $this->config['server']['php']['supported'],
+                );
+                if (empty($supportedPHPVersions)) {
+                    throw new RuntimeException('Required PHP version is not supported');
+                }
+                //convert from x.x to PHP-x_x to accepted.
+                $this->appcontext->changeBackendTemplate(
+                    $domainName,
+                    'PHP-' . str_replace('.', '_', $phpVersion),
+                );
+            }
+        }
+    }
+
+    private function setupDatabase(TargetDatabase $database): void
+    {
+        if ($database->createDatabase) {
+            if (!$this->appcontext->checkDatabaseLimit()) {
+                throw new RuntimeException('Unable to add database! Limit reached!');
+            }
+
+            $userPrefix = $this->appcontext->user() . '_';
+
+            $databaseName = str_replace($userPrefix, '', $database->name);
+            $databaseUser = str_replace($userPrefix, '', $database->user);
+
+            $this->appcontext->databaseAdd(
+                $databaseName,
+                $databaseUser,
+                $database->password,
+                $database->host,
+            );
+        }
+    }
+
+    private function retrieveResources(InstallationTarget $target, array $options): void
+    {
+        foreach ($this->config['resources'] as $resourceType => $resourceData) {
+            $resourceLocation = $resourceData['src'];
+
+            if (!empty($resourceData['dst']) && is_string($resourceData['dst'])) {
+                $destinationPath = $target->getDocRoot($resourceData['dst']);
+            } else {
+                $destinationPath = $target->getDocRoot();
+            }
+
+            if ($resourceType === 'composer') {
+                $this->appcontext->runComposer($options['php_version'], [
+                    'create-project',
+                    '--no-progress',
+                    '--no-interaction',
+                    '--prefer-dist',
+                    $resourceData['src'],
+                    '-d',
+                    dirname($destinationPath),
+                    basename($destinationPath),
+                ]);
+
+                return;
+            }
+
+            if ($resourceType === 'wp') {
+                $this->appcontext->runWp($options['php_version'], [
+                    'core',
+                    'download',
+                    '--locale=' . $options['language'],
+                    '--version=' . $this->info['version'],
+                    '--path=' . $destinationPath,
+                ]);
+
+                return;
+            }
+
+            if ($resourceType === 'archive') {
+                if (
+                    !str_starts_with($resourceLocation, 'http://') &&
+                    !str_starts_with($resourceLocation, 'https://')
+                ) {
+                    // only unpack file archive
+                    $this->appcontext->archiveExtract($resourceLocation, $destinationPath);
+
+                    return;
+                }
+
+                // Download archive, unpack, delete download
+                $resourceLocation = $this->appcontext->downloadUrl(
+                    $resourceLocation,
+                    $destinationPath,
+                );
+
+                $this->appcontext->archiveExtract($resourceLocation, $destinationPath);
+                $this->appcontext->deleteFile($resourceLocation);
+
+                return;
+            }
+
+            throw new RuntimeException(sprintf('Unknown resource type "%s"', $resourceType));
+        }
+    }
+}

+ 29 - 0
web/src/app/WebApp/InstallationTarget/InstallationTarget.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\WebApp\InstallationTarget;
+
+class InstallationTarget
+{
+    public function __construct(
+        public readonly TargetDomain $domain,
+        public TargetDatabase $database,
+    ) {
+    }
+
+    public function addTargetDatabase(TargetDatabase $database): void
+    {
+        $this->database = $database;
+    }
+
+    public function getUrl(): string
+    {
+        return $this->domain->getUrl();
+    }
+
+    public function getDocRoot(string $appendedPath = ''): string
+    {
+        return $this->domain->getDocRoot($appendedPath);
+    }
+}

+ 22 - 0
web/src/app/WebApp/InstallationTarget/TargetDatabase.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\WebApp\InstallationTarget;
+
+class TargetDatabase
+{
+    public function __construct(
+        public readonly string $host,
+        public readonly string $name,
+        public readonly string $user,
+        public readonly string $password,
+        public readonly bool $createDatabase,
+    ) {
+    }
+
+    public static function noDatabase(): self
+    {
+        return new self('', '', '', '', false);
+    }
+}

+ 28 - 0
web/src/app/WebApp/InstallationTarget/TargetDomain.php

@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\WebApp\InstallationTarget;
+
+use Hestia\System\Util;
+
+class TargetDomain
+{
+    public function __construct(
+        public readonly string $domainName,
+        public readonly string $domainPath,
+        public readonly string $ipAddress,
+        public readonly bool $isSslEnabled,
+    ) {
+    }
+
+    public function getUrl(): string
+    {
+        return ($this->isSslEnabled ? 'https://' : 'http://') . $this->domainName;
+    }
+
+    public function getDocRoot(string $appendedPath = ''): string
+    {
+        return Util::joinPaths($this->domainPath, 'public_html', $appendedPath);
+    }
+}

+ 42 - 0
web/src/app/WebApp/InstallerInfo.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Hestia\WebApp;
+
+use Hestia\WebApp\InstallationTarget\TargetDatabase;
+use Hestia\WebApp\InstallationTarget\TargetDomain;
+
+class InstallerInfo
+{
+    /**
+     * @param string[] $supportedPHPVersions
+     */
+    private function __construct(
+        public readonly string $name,
+        public readonly string $group,
+        public readonly string $version,
+        public readonly string $thumbnail,
+        public readonly array $supportedPHPVersions,
+    ) {
+    }
+
+    public function isInstallable(): bool
+    {
+        return !empty($this->supportedPHPVersions);
+    }
+
+    /**
+     * @param mixed[] $info
+     */
+    public static function fromArray(array $info): self
+    {
+        return new self(
+            (string) $info['name'],
+            (string) $info['group'],
+            (string) $info['version'],
+            (string) $info['thumbnail'],
+            (array) $info['supportedPHPVersions'],
+        );
+    }
+}

+ 10 - 4
web/src/app/WebApp/InstallerInterface.php

@@ -1,10 +1,16 @@
 <?php
+
 declare(strict_types=1);
 
 namespace Hestia\WebApp;
 
-interface InstallerInterface {
-	public function install(array $options = null);
-	public function getDocRoot(string $append_relative_path = null): string;
-	public function withDatabase(): bool;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+
+interface InstallerInterface
+{
+    public function getInfo(): InstallerInfo;
+
+    public function getConfig(string $section = ''): mixed;
+
+    public function install(InstallationTarget $target, array $options): void;
 }

+ 0 - 202
web/src/app/WebApp/Installers/BaseSetup.php

@@ -1,202 +0,0 @@
-<?php
-
-namespace Hestia\WebApp\Installers;
-
-use Hestia\System\Util;
-use Hestia\System\HestiaApp;
-use Hestia\WebApp\InstallerInterface;
-use Hestia\Models\WebDomain;
-
-use Hestia\WebApp\Installers\Resources\ComposerResource;
-use Hestia\WebApp\Installers\Resources\WpResource;
-
-abstract class BaseSetup implements InstallerInterface {
-	protected $appInfo;
-	protected $config;
-	protected $domain;
-	protected $extractsubdir;
-	protected $AppDirInstall;
-	protected $appcontext;
-
-	public function setAppDirInstall(string $appDir) {
-		if (!empty($appDir)) {
-			if (strpos(".", $appDir) !== false) {
-				throw new \Exception("Invalid install folder");
-			}
-			if (!is_dir($this->getDocRoot($appDir))) {
-				$this->appcontext->runUser(
-					"v-add-fs-directory",
-					[$this->getDocRoot($appDir)],
-					$result,
-				);
-			}
-			$this->AppDirInstall = $appDir;
-		}
-	}
-	public function getAppDirInstall() {
-		return $this->AppDirInstall;
-	}
-
-	public function info() {
-		$this->appInfo["enabled"] = true;
-		if (isset($this->config["server"]["php"]["supported"])) {
-			$this->appInfo["php_support"] = $this->config["server"]["php"]["supported"];
-		} else {
-			$this->appInfo["php_support"] = [
-				"5.6",
-				"7.0",
-				"7.1",
-				"7.2",
-				"7.3",
-				"7.4",
-				"8.0",
-				"8.1",
-				"8.2",
-				"8.3",
-			];
-		}
-		return $this->appInfo;
-	}
-	public function __construct($domain, HestiaApp $appcontext) {
-		if (filter_var($domain, FILTER_VALIDATE_DOMAIN) === false) {
-			throw new \Exception("Invalid domain name");
-		}
-
-		$this->domain = $domain;
-		$this->appcontext = $appcontext;
-	}
-
-	public function getConfig($section = null) {
-		return !empty($section) ? $this->config[$section] : $this->config;
-	}
-
-	public function getOptions() {
-		return $this->getConfig("form");
-	}
-
-	public function withDatabase(): bool {
-		return $this->getConfig("database") === true;
-	}
-
-	public function getDocRoot($append_relative_path = null): string {
-		$domain_path = $this->appcontext->getWebDomainPath($this->domain);
-
-		if (empty($domain_path) || !is_dir($domain_path)) {
-			throw new \Exception("Error finding domain folder ($domain_path)");
-		}
-		if (!$this->AppDirInstall) {
-			return Util::join_paths($domain_path, "public_html", $append_relative_path);
-		} else {
-			return Util::join_paths(
-				$domain_path,
-				"public_html",
-				$this->AppDirInstall,
-				$append_relative_path,
-			);
-		}
-	}
-
-	public function retrieveResources($options) {
-		foreach ($this->getConfig("resources") as $res_type => $res_data) {
-			if (!empty($res_data["dst"]) && is_string($res_data["dst"])) {
-				$resource_destination = $this->getDocRoot($res_data["dst"]);
-			} else {
-				$resource_destination = $this->getDocRoot($this->extractsubdir);
-			}
-
-			if ($res_type === "composer") {
-				$res_data["php_version"] = $options["php_version"];
-				new ComposerResource(
-					$this->appcontext,
-					$res_data,
-					$resource_destination,
-					$options["php_version"],
-				);
-			} elseif ($res_type === "wp") {
-				new WpResource(
-					$this->appcontext,
-					$res_data,
-					$resource_destination,
-					$options,
-					$this->info(),
-				);
-			} else {
-				$this->appcontext->archiveExtract($res_data["src"], $resource_destination, 1);
-			}
-		}
-		return true;
-	}
-	public function setup(array $options = null) {
-		if ($_SESSION["WEB_SYSTEM"] == "nginx") {
-			if (isset($this->config["server"]["nginx"]["template"])) {
-				$this->appcontext->changeWebTemplate(
-					$this->domain,
-					$this->config["server"]["nginx"]["template"],
-				);
-			} else {
-				$this->appcontext->changeWebTemplate($this->domain, "default");
-			}
-		} else {
-			if (isset($this->config["server"]["apache2"]["template"])) {
-				$this->appcontext->changeWebTemplate(
-					$this->domain,
-					$this->config["server"]["apache2"]["template"],
-				);
-			} else {
-				$this->appcontext->changeWebTemplate($this->domain, "default");
-			}
-		}
-		if ($_SESSION["WEB_BACKEND"] == "php-fpm") {
-			if (isset($this->config["server"]["php"]["supported"])) {
-				$php_version = $this->appcontext->getSupportedPHP(
-					$this->config["server"]["php"]["supported"],
-				);
-				if (!$php_version) {
-					throw new \Exception("Required PHP version is not supported");
-				}
-				//convert from x.x to PHP-x_x	to accepted..
-				$this->appcontext->changeBackendTemplate(
-					$this->domain,
-					"PHP-" . str_replace(".", "_", $options["php_version"]),
-				);
-			}
-		}
-	}
-
-	public function install(array $options = null) {
-		$this->appcontext->runUser("v-delete-fs-file", [$this->getDocRoot("robots.txt")]);
-		$this->appcontext->runUser("v-delete-fs-file", [$this->getDocRoot("index.html")]);
-		return $this->retrieveResources($options);
-	}
-
-	public function cleanup() {
-		// Remove temporary folder
-		if (!empty($this->extractsubdir)) {
-			$this->appcontext->runUser(
-				"v-delete-fs-directory",
-				[$this->getDocRoot($this->extractsubdir)],
-				$result,
-			);
-		}
-	}
-
-	public function saveTempFile(string $data) {
-		$tmp_file = tempnam("/tmp", "hst.");
-		if (empty($tmp_file)) {
-			throw new \Exception("Error creating temp file");
-		}
-
-		if (file_put_contents($tmp_file, $data) > 0) {
-			chmod($tmp_file, 0644);
-			$user_tmp_file = Util::join_paths($this->appcontext->getUserHomeDir(), $tmp_file);
-			$this->appcontext->runUser("v-copy-fs-file", [$tmp_file, $user_tmp_file], $result);
-			unlink($tmp_file);
-			return $user_tmp_file;
-		}
-
-		if (file_exists($tmp_file)) {
-			unlink($tmp_file);
-		}
-		return false;
-	}
-}

+ 82 - 129
web/src/app/WebApp/Installers/DokuWiki/DokuWikiSetup.php

@@ -1,139 +1,92 @@
 <?php
 
-namespace Hestia\WebApp\Installers\DokuWiki;
-
-use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-
-class DokuWikiSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "DokuWiki",
-		"group" => "wiki",
-		"enabled" => true,
-		"version" => "2023-04-04a",
-		"thumbnail" => "dokuwiki-logo.svg",
-	];
-
-	protected $appname = "dokuwiki";
-	protected $extractsubdir = "/tmp-dokuwiki";
-
-	protected $config = [
-		"form" => [
-			"wiki_name" => "text",
-			"superuser" => "text",
-			"real_name" => "text",
-			"email" => "text",
-			"password" => "password",
-			"initial_ACL_policy" => [
-				"type" => "select",
-				"options" => [
-					"0: Open Wiki (read, write, upload for everyone)", // 0
-					"1: Public Wiki (read for everyone, write and upload for registered users)", // 1
-					"2: Closed Wiki (read, write, upload for registered users only)", // 3
-				],
-			],
-			"content_license" => [
-				"type" => "select",
-				"options" => [
-					"cc-zero: CC0 1.0 Universal",
-					"publicdomain: Public Domain",
-					"cc-by: CC Attribution 4.0 International",
-					"cc-by-sa: CC Attribution-Share Alike 4.0 International",
-					"gnufdl: GNU Free Documentation License 1.3",
-					"cc-by-nc: CC Attribution-Noncommercial 4.0 International",
-					"cc-by-nc-sa: CC Attribution-Noncommercial-Share Alike 4.0 International",
-					"0: Do not show any license information",
-				],
-			],
-		],
-		"resources" => [
-			"archive" => [
-				"src" =>
-					"https://github.com/dokuwiki/dokuwiki/releases/download/release-2023-04-04a/dokuwiki-2023-04-04a.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "default",
-			],
-			"php" => [
-				"supported" => ["7.3", "7.4", "8.0", "8.1"],
-			],
-		],
-	];
+declare(strict_types=1);
 
-	public function install(array $options = null, &$status = null) {
-		parent::install($options);
-		parent::setup($options);
-
-		//check if ssl is enabled
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
-
-		$sslEnabled = $status->json[$this->domain]["SSL"] == "no" ? 0 : 1;
-
-		$webDomain = ($sslEnabled ? "https://" : "http://") . $this->domain . "/";
+namespace Hestia\WebApp\Installers\DokuWiki;
 
-		$this->appcontext->runUser(
-			"v-copy-fs-directory",
-			[
-				$this->getDocRoot(
-					$this->extractsubdir . "/dokuwiki-" . $this->appInfo["version"] . "/.",
-				),
-				$this->getDocRoot(),
-			],
-			$status,
-		);
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+use function var_dump;
 
-		// enable htaccess
-		$this->appcontext->runUser(
-			"v-move-fs-file",
-			[$this->getDocRoot(".htaccess.dist"), $this->getDocRoot(".htaccess")],
-			$status,
-		);
+class DokuWikiSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'DokuWiki',
+        'group' => 'wiki',
+        'version' => 'latest',
+        'thumbnail' => 'dokuwiki-logo.svg',
+    ];
 
-		$installUrl = $webDomain . "install.php";
+    protected array $config = [
+        'form' => [
+            'wiki_name' => 'text',
+            'superuser' => 'text',
+            'real_name' => 'text',
+            'email' => 'text',
+            'password' => 'password',
+            'initial_ACL_policy' => [
+                'type' => 'select',
+                'options' => [
+                    '0: Open Wiki (read, write, upload for everyone)',
+                    '1: Public Wiki (read for everyone, write and upload for registered users)',
+                    '2: Closed Wiki (read, write, upload for registered users only)',
+                ],
+            ],
+            'content_license' => [
+                'type' => 'select',
+                'options' => [
+                    'cc-zero: CC0 1.0 Universal',
+                    'publicdomain: Public Domain',
+                    'cc-by: CC Attribution 4.0 International',
+                    'cc-by-sa: CC Attribution-Share Alike 4.0 International',
+                    'gnufdl: GNU Free Documentation License 1.3',
+                    'cc-by-nc: CC Attribution-Noncommercial 4.0 International',
+                    'cc-by-nc-sa: CC Attribution-Noncommercial-Share Alike 4.0 International',
+                    '0: Do not show any license information',
+                ],
+            ],
+        ],
+        'resources' => [
+            'archive' => [
+                'src' => 'https://download.dokuwiki.org/src/dokuwiki/dokuwiki-stable.tgz',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'default',
+            ],
+            'php' => [
+                'supported' => ['8.0', '8.1', '8.2', '8.3', '8.4'],
+            ],
+        ],
+    ];
 
-		$cmd =
-			"curl --request POST " .
-			($sslEnabled ? "" : "--insecure ") .
-			"--url $installUrl " .
-			"--header 'Content-Type: application/x-www-form-urlencoded' " .
-			"--data l=en " .
-			"--data 'd[title]=" .
-			rawurlencode($options["wiki_name"]) .
-			"' " .
-			"--data 'd[acl]=on' " .
-			"--data 'd[superuser]=" .
-			rawurlencode($options["superuser"]) .
-			"' " .
-			"--data 'd[fullname]=" .
-			rawurlencode($options["real_name"]) .
-			"' " .
-			"--data 'd[email]=" .
-			rawurlencode($options["email"]) .
-			"' " .
-			"--data 'd[password]=" .
-			rawurlencode($options["password"]) .
-			"' " .
-			"--data 'd[confirm]=" .
-			rawurlencode($options["password"]) .
-			"' " .
-			"--data 'd[policy]=" .
-			substr(rawurlencode($options["initial_ACL_policy"]), 0, 1) .
-			"' " .
-			"--data 'd[license]=" .
-			explode(":", rawurlencode($options["content_license"])[0]) .
-			"' " .
-			"--data submit=";
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        // Enable htaccess
+        $this->appcontext->moveFile(
+            $target->getDocRoot('.htaccess.dist'),
+            $target->getDocRoot('.htaccess'),
+        );
 
-		exec($cmd, $output, $return_var);
-		if ($return_var > 0) {
-			throw new \Exception(implode(PHP_EOL, $output));
-		}
-		// remove temp folder
-		$this->appcontext->runUser("v-delete-fs-file", [$this->getDocRoot("install.php")], $status);
-		$this->cleanup();
+        $this->appcontext->sendPostRequest(
+            $target->getUrl() . '/install.php',
+            [
+                'l' => 'en',
+                'd[title]' => $options['wiki_name'],
+                'd[acl]' => 'on',
+                'd[superuser]' => $options['superuser'],
+                'd[fullname]' => $options['real_name'],
+                'd[email]' => $options['email'],
+                'd[password]' => $options['password'],
+                'd[confirm]' => $options['password'],
+                'd[policy]' => substr($options['initial_ACL_policy'], 0, 1),
+                'd[license]' => explode(':', $options['content_license'])[0],
+                'submit' => '',
+            ],
+            ['Content-Type: application/x-www-form-urlencoded'],
+        );
 
-		return $status->code === 0;
-	}
+        $this->appcontext->deleteFile($target->getDocRoot('install.php'));
+    }
 }

+ 446 - 446
web/src/app/WebApp/Installers/DokuWiki/dokuwiki-logo.svg

@@ -1,423 +1,423 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<?xml version='1.0' encoding='UTF-8' standalone='no'?>
 <!-- Created with Inkscape (http://www.inkscape.org/) -->
 
 <svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:xlink="http://www.w3.org/1999/xlink"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="128.17094"
-   height="128.03864"
-   id="svg2"
-   sodipodi:version="0.32"
-   inkscape:version="0.48.1 "
-   sodipodi:docname="dokuwiki-logo.svg"
-   version="1.1">
+   xmlns:dc='http://purl.org/dc/elements/1.1/'
+   xmlns:cc='http://creativecommons.org/ns#'
+   xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+   xmlns:svg='http://www.w3.org/2000/svg'
+   xmlns='http://www.w3.org/2000/svg'
+   xmlns:xlink='http://www.w3.org/1999/xlink'
+   xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd'
+   xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape'
+   width='128.17094'
+   height='128.03864'
+   id='svg2'
+   sodipodi:version='0.32'
+   inkscape:version='0.48.1 '
+   sodipodi:docname='dokuwiki-logo.svg'
+   version='1.1'>
   <title
-     id="title3181">DokuWiki Logo</title>
+     id='title3181'>DokuWiki Logo</title>
   <defs
-     id="defs4">
+     id='defs4'>
     <linearGradient
-       id="linearGradient2624">
+       id='linearGradient2624'>
       <stop
-         style="stop-color:#3a9030;stop-opacity:0.83673471;"
-         offset="0"
-         id="stop2626" />
+         style='stop-color:#3a9030;stop-opacity:0.83673471;'
+         offset='0'
+         id='stop2626' />
       <stop
-         style="stop-color:#3d9c32;stop-opacity:0.79591835;"
-         offset="1"
-         id="stop2628" />
+         style='stop-color:#3d9c32;stop-opacity:0.79591835;'
+         offset='1'
+         id='stop2628' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2612">
+       id='linearGradient2612'>
       <stop
-         style="stop-color:#25901b;stop-opacity:0.83673471;"
-         offset="0"
-         id="stop2614" />
+         style='stop-color:#25901b;stop-opacity:0.83673471;'
+         offset='0'
+         id='stop2614' />
       <stop
-         style="stop-color:#25901b;stop-opacity:0.37755102;"
-         offset="1"
-         id="stop2616" />
+         style='stop-color:#25901b;stop-opacity:0.37755102;'
+         offset='1'
+         id='stop2616' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2600">
+       id='linearGradient2600'>
       <stop
-         style="stop-color:#e32525;stop-opacity:0.81632656;"
-         offset="0"
-         id="stop2602" />
+         style='stop-color:#e32525;stop-opacity:0.81632656;'
+         offset='0'
+         id='stop2602' />
       <stop
-         style="stop-color:#e32525;stop-opacity:0.5714286;"
-         offset="1"
-         id="stop2604" />
+         style='stop-color:#e32525;stop-opacity:0.5714286;'
+         offset='1'
+         id='stop2604' />
     </linearGradient>
     <marker
-       inkscape:stockid="TriangleOutL"
-       orient="auto"
-       refY="0"
-       refX="0"
-       id="TriangleOutL"
-       style="overflow:visible">
+       inkscape:stockid='TriangleOutL'
+       orient='auto'
+       refY='0'
+       refX='0'
+       id='TriangleOutL'
+       style='overflow:visible'>
       <path
-         id="path2488"
-         d="m 5.77,0 -8.65,5 0,-10 8.65,5 z"
-         style="fill-rule:evenodd;stroke:#000000;stroke-width:1pt;marker-start:none"
-         transform="scale(0.8,0.8)"
-         inkscape:connector-curvature="0" />
+         id='path2488'
+         d='m 5.77,0 -8.65,5 0,-10 8.65,5 z'
+         style='fill-rule:evenodd;stroke:#000000;stroke-width:1pt;marker-start:none'
+         transform='scale(0.8,0.8)'
+         inkscape:connector-curvature='0' />
     </marker>
     <marker
-       inkscape:stockid="Arrow2Lstart"
-       orient="auto"
-       refY="0"
-       refX="0"
-       id="Arrow2Lstart"
-       style="overflow:visible">
+       inkscape:stockid='Arrow2Lstart'
+       orient='auto'
+       refY='0'
+       refX='0'
+       id='Arrow2Lstart'
+       style='overflow:visible'>
       <path
-         id="path2571"
-         style="font-size:12px;fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round"
-         d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
-         transform="matrix(1.1,0,0,1.1,-5.5,0)"
-         inkscape:connector-curvature="0" />
+         id='path2571'
+         style='font-size:12px;fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round'
+         d='M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z'
+         transform='matrix(1.1,0,0,1.1,-5.5,0)'
+         inkscape:connector-curvature='0' />
     </marker>
     <linearGradient
-       id="linearGradient2408">
+       id='linearGradient2408'>
       <stop
-         id="stop2410"
-         offset="0"
-         style="stop-color:#000000;stop-opacity:0.17346939;" />
+         id='stop2410'
+         offset='0'
+         style='stop-color:#000000;stop-opacity:0.17346939;' />
       <stop
-         id="stop2412"
-         offset="1"
-         style="stop-color:#c7cec2;stop-opacity:0;" />
+         id='stop2412'
+         offset='1'
+         style='stop-color:#c7cec2;stop-opacity:0;' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2389">
+       id='linearGradient2389'>
       <stop
-         style="stop-color:#000000;stop-opacity:0.17346939;"
-         offset="0"
-         id="stop2391" />
+         style='stop-color:#000000;stop-opacity:0.17346939;'
+         offset='0'
+         id='stop2391' />
       <stop
-         style="stop-color:#c7cec2;stop-opacity:0;"
-         offset="1"
-         id="stop2393" />
+         style='stop-color:#c7cec2;stop-opacity:0;'
+         offset='1'
+         id='stop2393' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2370">
+       id='linearGradient2370'>
       <stop
-         style="stop-color:#fbfaf9;stop-opacity:1;"
-         offset="0"
-         id="stop2372" />
+         style='stop-color:#fbfaf9;stop-opacity:1;'
+         offset='0'
+         id='stop2372' />
       <stop
-         style="stop-color:#e9dac7;stop-opacity:1;"
-         offset="1"
-         id="stop2374" />
+         style='stop-color:#e9dac7;stop-opacity:1;'
+         offset='1'
+         id='stop2374' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2364">
+       id='linearGradient2364'>
       <stop
-         id="stop2366"
-         offset="0"
-         style="stop-color:#fbf6f0;stop-opacity:1;" />
+         id='stop2366'
+         offset='0'
+         style='stop-color:#fbf6f0;stop-opacity:1;' />
       <stop
-         id="stop2368"
-         offset="1"
-         style="stop-color:#e9dac7;stop-opacity:1;" />
+         id='stop2368'
+         offset='1'
+         style='stop-color:#e9dac7;stop-opacity:1;' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2348">
+       id='linearGradient2348'>
       <stop
-         style="stop-color:#fbf6f0;stop-opacity:1;"
-         offset="0"
-         id="stop2350" />
+         style='stop-color:#fbf6f0;stop-opacity:1;'
+         offset='0'
+         id='stop2350' />
       <stop
-         style="stop-color:#e9dac7;stop-opacity:1;"
-         offset="1"
-         id="stop2352" />
+         style='stop-color:#e9dac7;stop-opacity:1;'
+         offset='1'
+         id='stop2352' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2332">
+       id='linearGradient2332'>
       <stop
-         style="stop-color:#ede1ae;stop-opacity:1;"
-         offset="0"
-         id="stop2334" />
+         style='stop-color:#ede1ae;stop-opacity:1;'
+         offset='0'
+         id='stop2334' />
       <stop
-         style="stop-color:#fefdfa;stop-opacity:1;"
-         offset="1"
-         id="stop2336" />
+         style='stop-color:#fefdfa;stop-opacity:1;'
+         offset='1'
+         id='stop2336' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2249">
+       id='linearGradient2249'>
       <stop
-         style="stop-color:#00a423;stop-opacity:1;"
-         offset="0"
-         id="stop2251" />
+         style='stop-color:#00a423;stop-opacity:1;'
+         offset='0'
+         id='stop2251' />
       <stop
-         style="stop-color:#00b427;stop-opacity:1;"
-         offset="1"
-         id="stop2253" />
+         style='stop-color:#00b427;stop-opacity:1;'
+         offset='1'
+         id='stop2253' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2229">
+       id='linearGradient2229'>
       <stop
-         id="stop2231"
-         offset="0"
-         style="stop-color:#00b62b;stop-opacity:1;" />
+         id='stop2231'
+         offset='0'
+         style='stop-color:#00b62b;stop-opacity:1;' />
       <stop
-         id="stop2233"
-         offset="1"
-         style="stop-color:#a1d784;stop-opacity:1;" />
+         id='stop2233'
+         offset='1'
+         style='stop-color:#a1d784;stop-opacity:1;' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2213">
+       id='linearGradient2213'>
       <stop
-         style="stop-color:#000000;stop-opacity:1;"
-         offset="0"
-         id="stop2215" />
+         style='stop-color:#000000;stop-opacity:1;'
+         offset='0'
+         id='stop2215' />
       <stop
-         style="stop-color:#000000;stop-opacity:0;"
-         offset="1"
-         id="stop2217" />
+         style='stop-color:#000000;stop-opacity:0;'
+         offset='1'
+         id='stop2217' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2360">
+       id='linearGradient2360'>
       <stop
-         style="stop-color:#d69c00;stop-opacity:1;"
-         offset="0"
-         id="stop2362" />
+         style='stop-color:#d69c00;stop-opacity:1;'
+         offset='0'
+         id='stop2362' />
       <stop
-         style="stop-color:#ffe658;stop-opacity:1;"
-         offset="1"
-         id="stop2364" />
+         style='stop-color:#ffe658;stop-opacity:1;'
+         offset='1'
+         id='stop2364' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2352">
+       id='linearGradient2352'>
       <stop
-         id="stop2354"
-         offset="0"
-         style="stop-color:#ce411e;stop-opacity:1;" />
+         id='stop2354'
+         offset='0'
+         style='stop-color:#ce411e;stop-opacity:1;' />
       <stop
-         id="stop2356"
-         offset="1"
-         style="stop-color:#ecad8d;stop-opacity:1;" />
+         id='stop2356'
+         offset='1'
+         style='stop-color:#ecad8d;stop-opacity:1;' />
     </linearGradient>
     <linearGradient
-       id="linearGradient2336">
+       id='linearGradient2336'>
       <stop
-         style="stop-color:#8f2a15;stop-opacity:1;"
-         offset="0"
-         id="stop2338" />
+         style='stop-color:#8f2a15;stop-opacity:1;'
+         offset='0'
+         id='stop2338' />
       <stop
-         style="stop-color:#c8381b;stop-opacity:1;"
-         offset="1"
-         id="stop2340" />
+         style='stop-color:#c8381b;stop-opacity:1;'
+         offset='1'
+         id='stop2340' />
     </linearGradient>
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2336"
-       id="linearGradient2342"
-       x1="219.21262"
-       y1="189.01556"
-       x2="286.22665"
-       y2="189.01556"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2336'
+       id='linearGradient2342'
+       x1='219.21262'
+       y1='189.01556'
+       x2='286.22665'
+       y2='189.01556'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2352"
-       id="linearGradient2350"
-       x1="219.66267"
-       y1="192.73286"
-       x2="277.8761"
-       y2="192.73286"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2352'
+       id='linearGradient2350'
+       x1='219.66267'
+       y1='192.73286'
+       x2='277.8761'
+       y2='192.73286'
+       gradientUnits='userSpaceOnUse' />
     <radialGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2360"
-       id="radialGradient2366"
-       cx="224.41418"
-       cy="212.80016"
-       fx="224.41418"
-       fy="212.80016"
-       r="8.6813803"
-       gradientTransform="matrix(1,0,0,0.984179,0,3.366635)"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2360'
+       id='radialGradient2366'
+       cx='224.41418'
+       cy='212.80016'
+       fx='224.41418'
+       fy='212.80016'
+       r='8.6813803'
+       gradientTransform='matrix(1,0,0,0.984179,0,3.366635)'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2249"
-       id="linearGradient2227"
-       x1="192.03938"
-       y1="262.25757"
-       x2="263.67093"
-       y2="262.25757"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2249'
+       id='linearGradient2227'
+       x1='192.03938'
+       y1='262.25757'
+       x2='263.67093'
+       y2='262.25757'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2229"
-       id="linearGradient2247"
-       x1="191.75092"
-       y1="258.91571"
-       x2="255.6561"
-       y2="258.91571"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2229'
+       id='linearGradient2247'
+       x1='191.75092'
+       y1='258.91571'
+       x2='255.6561'
+       y2='258.91571'
+       gradientUnits='userSpaceOnUse' />
     <radialGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2360"
-       id="radialGradient2317"
-       cx="257.41144"
-       cy="274.64203"
-       fx="257.41144"
-       fy="274.64203"
-       r="7.1440549"
-       gradientTransform="matrix(1,0,0,1.631384,0,-173.4045)"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2360'
+       id='radialGradient2317'
+       cx='257.41144'
+       cy='274.64203'
+       fx='257.41144'
+       fy='274.64203'
+       r='7.1440549'
+       gradientTransform='matrix(1,0,0,1.631384,0,-173.4045)'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2360"
-       id="linearGradient2325"
-       x1="184.07063"
-       y1="246.35907"
-       x2="201.40646"
-       y2="246.35907"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2360'
+       id='linearGradient2325'
+       x1='184.07063'
+       y1='246.35907'
+       x2='201.40646'
+       y2='246.35907'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2332"
-       id="linearGradient2346"
-       x1="162.76369"
-       y1="184.99277"
-       x2="240.84924"
-       y2="289.50323"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2332'
+       id='linearGradient2346'
+       x1='162.76369'
+       y1='184.99277'
+       x2='240.84924'
+       y2='289.50323'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2348"
-       id="linearGradient2354"
-       x1="140.15784"
-       y1="303.78967"
-       x2="136.14151"
-       y2="195.87151"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2348'
+       id='linearGradient2354'
+       x1='140.15784'
+       y1='303.78967'
+       x2='136.14151'
+       y2='195.87151'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2370"
-       id="linearGradient2362"
-       x1="286.15598"
-       y1="262.28729"
-       x2="185.81258"
-       y2="172.32423"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2370'
+       id='linearGradient2362'
+       x1='286.15598'
+       y1='262.28729'
+       x2='185.81258'
+       y2='172.32423'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2389"
-       id="linearGradient2395"
-       x1="213.96568"
-       y1="220.07191"
-       x2="244.79126"
-       y2="265.40363"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2389'
+       id='linearGradient2395'
+       x1='213.96568'
+       y1='220.07191'
+       x2='244.79126'
+       y2='265.40363'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2408"
-       id="linearGradient2406"
-       x1="184.30582"
-       y1="241.52789"
-       x2="224.67441"
-       y2="307.52844"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2408'
+       id='linearGradient2406'
+       x1='184.30582'
+       y1='241.52789'
+       x2='224.67441'
+       y2='307.52844'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2600"
-       id="linearGradient2606"
-       x1="202.41772"
-       y1="222.05145"
-       x2="206.06017"
-       y2="210.3558"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2600'
+       id='linearGradient2606'
+       x1='202.41772'
+       y1='222.05145'
+       x2='206.06017'
+       y2='210.3558'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2612"
-       id="linearGradient2618"
-       x1="248.62152"
-       y1="234.52202"
-       x2="251.64362"
-       y2="213.12164"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2612'
+       id='linearGradient2618'
+       x1='248.62152'
+       y1='234.52202'
+       x2='251.64362'
+       y2='213.12164'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2624"
-       id="linearGradient2630"
-       x1="275.71765"
-       y1="251.56442"
-       x2="255.68353"
-       y2="217.94008"
-       gradientUnits="userSpaceOnUse" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2624'
+       id='linearGradient2630'
+       x1='275.71765'
+       y1='251.56442'
+       x2='255.68353'
+       y2='217.94008'
+       gradientUnits='userSpaceOnUse' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2352"
-       id="linearGradient2640"
-       gradientUnits="userSpaceOnUse"
-       x1="219.66267"
-       y1="192.73286"
-       x2="277.8761"
-       y2="192.73286" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2352'
+       id='linearGradient2640'
+       gradientUnits='userSpaceOnUse'
+       x1='219.66267'
+       y1='192.73286'
+       x2='277.8761'
+       y2='192.73286' />
     <linearGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2336"
-       id="linearGradient2643"
-       gradientUnits="userSpaceOnUse"
-       x1="219.21262"
-       y1="189.01556"
-       x2="286.22665"
-       y2="189.01556" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2336'
+       id='linearGradient2643'
+       gradientUnits='userSpaceOnUse'
+       x1='219.21262'
+       y1='189.01556'
+       x2='286.22665'
+       y2='189.01556' />
     <radialGradient
-       inkscape:collect="always"
-       xlink:href="#linearGradient2360"
-       id="radialGradient2647"
-       gradientUnits="userSpaceOnUse"
-       gradientTransform="matrix(1,0,0,0.984179,0,3.366635)"
-       cx="224.41418"
-       cy="212.80016"
-       fx="224.41418"
-       fy="212.80016"
-       r="8.6813803" />
+       inkscape:collect='always'
+       xlink:href='#linearGradient2360'
+       id='radialGradient2647'
+       gradientUnits='userSpaceOnUse'
+       gradientTransform='matrix(1,0,0,0.984179,0,3.366635)'
+       cx='224.41418'
+       cy='212.80016'
+       fx='224.41418'
+       fy='212.80016'
+       r='8.6813803' />
   </defs>
   <sodipodi:namedview
-     id="base"
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1.0"
-     inkscape:pageopacity="0.0"
-     inkscape:pageshadow="2"
-     inkscape:zoom="2.03"
-     inkscape:cx="35.144424"
-     inkscape:cy="83.160427"
-     inkscape:document-units="px"
-     inkscape:current-layer="layer3"
-     inkscape:window-width="1366"
-     inkscape:window-height="716"
-     inkscape:window-x="-8"
-     inkscape:window-y="-8"
-     showguides="true"
-     inkscape:guide-bbox="true"
-     showgrid="false"
-     fit-margin-top="0"
-     fit-margin-left="0"
-     fit-margin-right="0"
-     fit-margin-bottom="0"
-     inkscape:window-maximized="1"
-     inkscape:showpageshadow="false"
-     showborder="true"
-     borderlayer="false" />
+     id='base'
+     pagecolor='#ffffff'
+     bordercolor='#666666'
+     borderopacity='1.0'
+     inkscape:pageopacity='0.0'
+     inkscape:pageshadow='2'
+     inkscape:zoom='2.03'
+     inkscape:cx='35.144424'
+     inkscape:cy='83.160427'
+     inkscape:document-units='px'
+     inkscape:current-layer='layer3'
+     inkscape:window-width='1366'
+     inkscape:window-height='716'
+     inkscape:window-x='-8'
+     inkscape:window-y='-8'
+     showguides='true'
+     inkscape:guide-bbox='true'
+     showgrid='false'
+     fit-margin-top='0'
+     fit-margin-left='0'
+     fit-margin-right='0'
+     fit-margin-bottom='0'
+     inkscape:window-maximized='1'
+     inkscape:showpageshadow='false'
+     showborder='true'
+     borderlayer='false' />
   <metadata
-     id="metadata7">
+     id='metadata7'>
     <rdf:RDF>
       <cc:Work
-         rdf:about="">
+         rdf:about=''>
         <dc:format>image/svg+xml</dc:format>
         <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+           rdf:resource='http://purl.org/dc/dcmitype/StillImage' />
         <dc:title>DokuWiki Logo</dc:title>
         <dc:creator>
           <cc:Agent>
@@ -425,161 +425,161 @@
           </cc:Agent>
         </dc:creator>
         <cc:license
-           rdf:resource="http://www.gnu.org/licenses/gpl-2.0.html" />
+           rdf:resource='http://www.gnu.org/licenses/gpl-2.0.html' />
       </cc:Work>
     </rdf:RDF>
   </metadata>
   <g
-     inkscape:groupmode="layer"
-     id="layer3"
-     inkscape:label="paper"
-     style="display:inline"
-     transform="translate(-158.10602,-158.67323)">
+     inkscape:groupmode='layer'
+     id='layer3'
+     inkscape:label='paper'
+     style='display:inline'
+     transform='translate(-158.10602,-158.67323)'>
     <g
-       id="g1419"
-       transform="matrix(0.99993322,0,0,0.9959778,0.01483419,0.8957919)">
+       id='g1419'
+       transform='matrix(0.99993322,0,0,0.9959778,0.01483419,0.8957919)'>
       <g
-         id="g2376">
+         id='g2376'>
         <path
-           transform="matrix(0.989976,-0.141236,0.201069,0.979577,0,0)"
-           style="fill:url(#linearGradient2354);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.7216621px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;display:inline"
-           d="m 120.21543,196.43769 70.90655,-0.79226 -2.40261,109.05308 -71.71761,0.37344 3.21367,-108.63426 z"
-           id="rect1422"
-           sodipodi:nodetypes="ccccc"
-           inkscape:connector-curvature="0" />
+           transform='matrix(0.989976,-0.141236,0.201069,0.979577,0,0)'
+           style='fill:url(#linearGradient2354);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.7216621px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;display:inline'
+           d='m 120.21543,196.43769 70.90655,-0.79226 -2.40261,109.05308 -71.71761,0.37344 3.21367,-108.63426 z'
+           id='rect1422'
+           sodipodi:nodetypes='ccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2362);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;display:inline"
-           d="m 179.20033,182.08731 79.84173,-19.51687 26.61391,101.72428 -82.50312,21.58684 -23.95252,-103.79425 z"
-           id="rect1425"
-           sodipodi:nodetypes="ccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2362);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;display:inline'
+           d='m 179.20033,182.08731 79.84173,-19.51687 26.61391,101.72428 -82.50312,21.58684 -23.95252,-103.79425 z'
+           id='rect1425'
+           sodipodi:nodetypes='ccccc'
+           inkscape:connector-curvature='0' />
         <path
-           transform="matrix(0.995676,-0.09289891,0.08102261,0.996712,0,0)"
-           style="fill:url(#linearGradient2346);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00418305px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;display:inline"
-           d="m 159.01353,181.74387 85.58587,0.53396 0,110.47429 -84.53387,-2.5127 -1.052,-108.49555 z"
-           id="rect1419"
-           sodipodi:nodetypes="ccccc"
-           inkscape:connector-curvature="0" />
+           transform='matrix(0.995676,-0.09289891,0.08102261,0.996712,0,0)'
+           style='fill:url(#linearGradient2346);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00418305px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;display:inline'
+           d='m 159.01353,181.74387 85.58587,0.53396 0,110.47429 -84.53387,-2.5127 -1.052,-108.49555 z'
+           id='rect1419'
+           sodipodi:nodetypes='ccccc'
+           inkscape:connector-curvature='0' />
       </g>
       <path
-         id="text2382"
-         d="m 167.55116,214.00773 0,-20.1846 5.34962,0 0,2.37403 -2.48145,0 0,15.43654 2.48145,0 0,2.37403 -5.34962,0 m 7.34767,0 0,-20.1846 5.34961,0 0,2.37403 -2.48144,0 0,15.43654 2.48144,0 0,2.37403 -5.34961,0 m 7.36915,-20.1846 5.81153,0 c 1.31054,2e-5 2.30956,0.10028 2.99707,0.30078 0.92382,0.27216 1.71516,0.75555 2.37403,1.4502 0.65884,0.69468 1.16014,1.54689 1.50391,2.55664 0.34373,1.00262 0.51561,2.24155 0.51562,3.71681 -10e-6,1.29623 -0.16115,2.41342 -0.4834,3.35156 -0.39389,1.14584 -0.95607,2.07325 -1.68652,2.78223 -0.55145,0.53711 -1.29624,0.95606 -2.23438,1.25684 -0.70183,0.222 -1.63999,0.33301 -2.81446,0.33301 l -5.9834,0 0,-15.74807 m 3.17969,2.66407 0,10.43067 2.37402,0 c 0.88802,1e-5 1.52897,-0.0501 1.92286,-0.15039 0.51561,-0.1289 0.94172,-0.34732 1.27832,-0.65527 0.34374,-0.30794 0.62304,-0.81282 0.83789,-1.51465 0.21483,-0.70898 0.32226,-1.6722 0.32227,-2.88965 -1e-5,-1.21744 -0.10744,-2.15201 -0.32227,-2.80372 -0.21485,-0.65168 -0.51563,-1.16014 -0.90234,-1.52539 -0.38673,-0.36522 -0.87729,-0.61229 -1.47168,-0.74121 -0.44402,-0.10025 -1.31414,-0.15038 -2.61036,-0.15039 l -1.42871,0 m 14.96388,13.084 -3.75977,-15.74807 3.25489,0 2.37403,10.8174 2.87891,-10.8174 3.78125,0 2.76074,11.00002 2.417,-11.00002 3.20118,0 -3.82423,15.74807 -3.37305,0 -3.13672,-11.77345 -3.12598,11.77345 -3.44825,0 m 22.76272,-15.74807 0,20.1846 -5.34961,0 0,-2.37403 2.48145,0 0,-15.45803 -2.48145,0 0,-2.35254 5.34961,0 m 7.34767,0 0,20.1846 -5.34962,0 0,-2.37403 2.48145,0 0,-15.45803 -2.48145,0 0,-2.35254 5.34962,0"
-         style="font-size:12.0000124px;font-style:normal;font-weight:normal;line-height:125%;fill:#6184a3;fill-opacity:1;stroke:none;display:inline;font-family:Bitstream Vera Sans"
-         transform="matrix(0.995433,-0.09546066,0.09546066,0.995433,0,0)"
-         inkscape:connector-curvature="0" />
+         id='text2382'
+         d='m 167.55116,214.00773 0,-20.1846 5.34962,0 0,2.37403 -2.48145,0 0,15.43654 2.48145,0 0,2.37403 -5.34962,0 m 7.34767,0 0,-20.1846 5.34961,0 0,2.37403 -2.48144,0 0,15.43654 2.48144,0 0,2.37403 -5.34961,0 m 7.36915,-20.1846 5.81153,0 c 1.31054,2e-5 2.30956,0.10028 2.99707,0.30078 0.92382,0.27216 1.71516,0.75555 2.37403,1.4502 0.65884,0.69468 1.16014,1.54689 1.50391,2.55664 0.34373,1.00262 0.51561,2.24155 0.51562,3.71681 -10e-6,1.29623 -0.16115,2.41342 -0.4834,3.35156 -0.39389,1.14584 -0.95607,2.07325 -1.68652,2.78223 -0.55145,0.53711 -1.29624,0.95606 -2.23438,1.25684 -0.70183,0.222 -1.63999,0.33301 -2.81446,0.33301 l -5.9834,0 0,-15.74807 m 3.17969,2.66407 0,10.43067 2.37402,0 c 0.88802,1e-5 1.52897,-0.0501 1.92286,-0.15039 0.51561,-0.1289 0.94172,-0.34732 1.27832,-0.65527 0.34374,-0.30794 0.62304,-0.81282 0.83789,-1.51465 0.21483,-0.70898 0.32226,-1.6722 0.32227,-2.88965 -1e-5,-1.21744 -0.10744,-2.15201 -0.32227,-2.80372 -0.21485,-0.65168 -0.51563,-1.16014 -0.90234,-1.52539 -0.38673,-0.36522 -0.87729,-0.61229 -1.47168,-0.74121 -0.44402,-0.10025 -1.31414,-0.15038 -2.61036,-0.15039 l -1.42871,0 m 14.96388,13.084 -3.75977,-15.74807 3.25489,0 2.37403,10.8174 2.87891,-10.8174 3.78125,0 2.76074,11.00002 2.417,-11.00002 3.20118,0 -3.82423,15.74807 -3.37305,0 -3.13672,-11.77345 -3.12598,11.77345 -3.44825,0 m 22.76272,-15.74807 0,20.1846 -5.34961,0 0,-2.37403 2.48145,0 0,-15.45803 -2.48145,0 0,-2.35254 5.34961,0 m 7.34767,0 0,20.1846 -5.34962,0 0,-2.37403 2.48145,0 0,-15.45803 -2.48145,0 0,-2.35254 5.34962,0'
+         style='font-size:12.0000124px;font-style:normal;font-weight:normal;line-height:125%;fill:#6184a3;fill-opacity:1;stroke:none;display:inline;font-family:Bitstream Vera Sans'
+         transform='matrix(0.995433,-0.09546066,0.09546066,0.995433,0,0)'
+         inkscape:connector-curvature='0' />
       <g
-         id="g2632"
-         style="display:inline">
+         id='g2632'
+         style='display:inline'>
         <path
-           style="fill:url(#linearGradient2606);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;marker-end:none"
-           d="m 174.75585,201.60224 c -6.04576,2.46667 -10.16789,4.4194 -12.88454,6.35064 -2.71665,1.93124 -3.19257,4.60007 -3.24631,6.26587 -0.0269,0.8329 0.0809,1.77774 0.63189,2.44014 0.55103,0.6624 1.80769,1.87421 2.75794,2.38558 1.90049,1.02274 7.5417,2.42901 10.51899,3.07308 11.90917,2.57627 26.80568,1.68117 26.80568,1.68117 1.69307,1.2452 2.83283,2.82434 3.269,4.26902 4.5766,-1.88674 11.81084,-6.58439 13.15657,-8.57706 -5.45142,-4.19955 -10.79692,-6.33346 -16.51317,-8.30847 -1.59867,-0.71918 -2.87956,-1.22649 -0.71773,2.55635 0.98506,2.47275 0.85786,5.05143 0.57176,7.41825 0,0 -16.52749,0.40678 -28.23838,-2.1266 -2.92772,-0.63334 -5.46627,-0.95523 -7.21875,-1.89832 -0.87624,-0.47154 -1.48296,-0.8208 -1.91578,-1.3411 -0.43282,-0.5203 -0.2196,-1.29055 -0.20128,-1.85858 0.0366,-1.13607 0.25336,-1.67063 2.86177,-3.52492 2.60841,-1.85429 5.65407,-3.36195 11.65936,-5.81211 -0.0877,-1.29125 -0.29025,-2.5059 -1.29702,-2.99294 z"
-           id="path2414"
-           sodipodi:nodetypes="csssssccccccssssscc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2606);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;marker-end:none'
+           d='m 174.75585,201.60224 c -6.04576,2.46667 -10.16789,4.4194 -12.88454,6.35064 -2.71665,1.93124 -3.19257,4.60007 -3.24631,6.26587 -0.0269,0.8329 0.0809,1.77774 0.63189,2.44014 0.55103,0.6624 1.80769,1.87421 2.75794,2.38558 1.90049,1.02274 7.5417,2.42901 10.51899,3.07308 11.90917,2.57627 26.80568,1.68117 26.80568,1.68117 1.69307,1.2452 2.83283,2.82434 3.269,4.26902 4.5766,-1.88674 11.81084,-6.58439 13.15657,-8.57706 -5.45142,-4.19955 -10.79692,-6.33346 -16.51317,-8.30847 -1.59867,-0.71918 -2.87956,-1.22649 -0.71773,2.55635 0.98506,2.47275 0.85786,5.05143 0.57176,7.41825 0,0 -16.52749,0.40678 -28.23838,-2.1266 -2.92772,-0.63334 -5.46627,-0.95523 -7.21875,-1.89832 -0.87624,-0.47154 -1.48296,-0.8208 -1.91578,-1.3411 -0.43282,-0.5203 -0.2196,-1.29055 -0.20128,-1.85858 0.0366,-1.13607 0.25336,-1.67063 2.86177,-3.52492 2.60841,-1.85429 5.65407,-3.36195 11.65936,-5.81211 -0.0877,-1.29125 -0.29025,-2.5059 -1.29702,-2.99294 z'
+           id='path2414'
+           sodipodi:nodetypes='csssssccccccssssscc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2618);fill-opacity:1;fill-rule:evenodd;stroke:none"
-           d="m 269.62539,220.7482 c -1.43576,-0.13963 -2.58044,0.30288 -2.56084,1.50218 0.94391,0.85652 1.34942,2.43518 1.48562,3.14008 0.1362,0.7049 0.0359,1.21914 -0.48562,1.89004 -1.043,1.3418 -3.12498,1.56875 -6.5006,2.72063 -6.75124,2.30377 -16.89306,2.52561 -27.90689,3.84639 -22.02767,2.64157 -39.03164,3.76107 -39.03164,3.76107 1.98346,-4.64758 6.32828,-4.41197 6.34903,-8.20969 0.27376,-0.89755 -3.14597,-1.31638 -5.09943,-0.10731 -4.26694,3.70137 -7.59152,6.75353 -10.69418,10.51311 l 1.88795,3.08438 c 0,0 26.13006,-2.88973 48.19776,-5.5361 11.03385,-1.32318 20.95601,-1.99856 27.80968,-4.33728 3.42683,-1.16936 5.95975,-1.49022 7.6409,-3.51958 0.63172,-0.76256 1.35238,-3.04699 1.06804,-4.73369 -0.21951,-1.30213 -1.14979,-3.09774 -2.15978,-4.01423 z"
-           id="path2608"
-           sodipodi:nodetypes="ccsssscccccssssc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2618);fill-opacity:1;fill-rule:evenodd;stroke:none'
+           d='m 269.62539,220.7482 c -1.43576,-0.13963 -2.58044,0.30288 -2.56084,1.50218 0.94391,0.85652 1.34942,2.43518 1.48562,3.14008 0.1362,0.7049 0.0359,1.21914 -0.48562,1.89004 -1.043,1.3418 -3.12498,1.56875 -6.5006,2.72063 -6.75124,2.30377 -16.89306,2.52561 -27.90689,3.84639 -22.02767,2.64157 -39.03164,3.76107 -39.03164,3.76107 1.98346,-4.64758 6.32828,-4.41197 6.34903,-8.20969 0.27376,-0.89755 -3.14597,-1.31638 -5.09943,-0.10731 -4.26694,3.70137 -7.59152,6.75353 -10.69418,10.51311 l 1.88795,3.08438 c 0,0 26.13006,-2.88973 48.19776,-5.5361 11.03385,-1.32318 20.95601,-1.99856 27.80968,-4.33728 3.42683,-1.16936 5.95975,-1.49022 7.6409,-3.51958 0.63172,-0.76256 1.35238,-3.04699 1.06804,-4.73369 -0.21951,-1.30213 -1.14979,-3.09774 -2.15978,-4.01423 z'
+           id='path2608'
+           sodipodi:nodetypes='ccsssscccccssssc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2630);fill-opacity:1;fill-rule:evenodd;stroke:none"
-           d="m 254.36185,220.33948 c -6.84997,3.24198 -7.15311,8.60912 -5.95953,12.79884 1.19358,4.18972 5.26293,8.75677 9.32121,12.40608 8.11656,7.29861 12.06046,9.33163 12.06046,9.33163 -3.71515,-0.10342 -7.89887,-1.41174 -8.13315,0.49304 -0.9483,2.97582 11.49137,3.47486 17.43787,2.70205 -1.39456,-7.57836 -3.79323,-13.21546 -7.73151,-14.90312 -1.68464,-0.14804 0.31242,4.72441 0.76985,9.39604 0,0 -3.62454,-1.73122 -11.60519,-8.90762 -3.99032,-3.5882 -7.37386,-7.3421 -8.47319,-11.20099 -1.09933,-3.85889 0.0776,-6.1205 4.95082,-9.53176 0.92816,-0.99528 -1.28985,-2.45913 -2.63764,-2.58419 z"
-           id="path2620"
-           sodipodi:nodetypes="csscccccsscc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2630);fill-opacity:1;fill-rule:evenodd;stroke:none'
+           d='m 254.36185,220.33948 c -6.84997,3.24198 -7.15311,8.60912 -5.95953,12.79884 1.19358,4.18972 5.26293,8.75677 9.32121,12.40608 8.11656,7.29861 12.06046,9.33163 12.06046,9.33163 -3.71515,-0.10342 -7.89887,-1.41174 -8.13315,0.49304 -0.9483,2.97582 11.49137,3.47486 17.43787,2.70205 -1.39456,-7.57836 -3.79323,-13.21546 -7.73151,-14.90312 -1.68464,-0.14804 0.31242,4.72441 0.76985,9.39604 0,0 -3.62454,-1.73122 -11.60519,-8.90762 -3.99032,-3.5882 -7.37386,-7.3421 -8.47319,-11.20099 -1.09933,-3.85889 0.0776,-6.1205 4.95082,-9.53176 0.92816,-0.99528 -1.28985,-2.45913 -2.63764,-2.58419 z'
+           id='path2620'
+           sodipodi:nodetypes='csscccccsscc'
+           inkscape:connector-curvature='0' />
       </g>
       <path
-         sodipodi:nodetypes="cccccc"
-         id="rect2386"
-         d="m 213.96569,234.57806 2.18756,-14.42897 15.21982,6.08793 21.49387,29.94828 -20.40591,9.21832 -18.49534,-30.82556 z"
-         style="fill:url(#linearGradient2395);fill-opacity:1;stroke:none;display:inline"
-         inkscape:connector-curvature="0" />
+         sodipodi:nodetypes='cccccc'
+         id='rect2386'
+         d='m 213.96569,234.57806 2.18756,-14.42897 15.21982,6.08793 21.49387,29.94828 -20.40591,9.21832 -18.49534,-30.82556 z'
+         style='fill:url(#linearGradient2395);fill-opacity:1;stroke:none;display:inline'
+         inkscape:connector-curvature='0' />
       <g
-         id="g2649"
-         style="display:inline">
+         id='g2649'
+         style='display:inline'>
         <path
-           style="fill:url(#radialGradient2647);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
-           d="m 232.55816,219.5295 -15.92827,0.32199 3.08809,-15.15716 12.84018,14.83517 z"
-           id="path1443"
-           sodipodi:nodetypes="cccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#radialGradient2647);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1'
+           d='m 232.55816,219.5295 -15.92827,0.32199 3.08809,-15.15716 12.84018,14.83517 z'
+           id='path1443'
+           sodipodi:nodetypes='cccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:#812310;fill-opacity:1;fill-rule:evenodd;stroke:none"
-           d="m 221.60041,219.29315 -4.41205,0.0782 0.85429,-3.98263 3.55776,3.90445 z"
-           id="path1452"
-           sodipodi:nodetypes="cccc"
-           inkscape:connector-curvature="0" />
+           style='fill:#812310;fill-opacity:1;fill-rule:evenodd;stroke:none'
+           d='m 221.60041,219.29315 -4.41205,0.0782 0.85429,-3.98263 3.55776,3.90445 z'
+           id='path1452'
+           sodipodi:nodetypes='cccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2643);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
-           d="m 269.44172,159.27421 0.098,8.91471 8.0581,8.72344 7.75906,0.7992 -52.80669,41.84092 -6.66532,-3.30696 -5.08243,-5.618 -1.08987,-5.91194 49.72911,-45.44137 z"
-           id="rect1437"
-           sodipodi:nodetypes="ccccccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2643);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1'
+           d='m 269.44172,159.27421 0.098,8.91471 8.0581,8.72344 7.75906,0.7992 -52.80669,41.84092 -6.66532,-3.30696 -5.08243,-5.618 -1.08987,-5.91194 49.72911,-45.44137 z'
+           id='rect1437'
+           sodipodi:nodetypes='ccccccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2640);fill-opacity:1;fill-rule:evenodd;stroke:none"
-           d="m 268.94766,168.32844 8.3426,8.82719 -51.1007,38.68262 -4.9197,-5.4436 47.6778,-42.06621 z"
-           id="rect1446"
-           sodipodi:nodetypes="ccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2640);fill-opacity:1;fill-rule:evenodd;stroke:none'
+           d='m 268.94766,168.32844 8.3426,8.82719 -51.1007,38.68262 -4.9197,-5.4436 47.6778,-42.06621 z'
+           id='rect1446'
+           sodipodi:nodetypes='ccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:#ffe965;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1;display:inline"
-           d="m 285.33776,177.73216 -8.16219,-0.86619 -7.7518,-8.67862 0.0132,-9.14293 8.36213,0.75209 7.18862,9.57682 0.35007,8.35883 z"
-           id="path1440"
-           sodipodi:nodetypes="ccccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:#ffe965;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1;display:inline'
+           d='m 285.33776,177.73216 -8.16219,-0.86619 -7.7518,-8.67862 0.0132,-9.14293 8.36213,0.75209 7.18862,9.57682 0.35007,8.35883 z'
+           id='path1440'
+           sodipodi:nodetypes='ccccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:#cb391c;fill-opacity:1;fill-rule:evenodd;stroke:none"
-           d="m 280.72049,168.46367 0.1644,4.05654 -3.81335,-0.71676 -2.87504,-3.18901 -0.28089,-3.53393 3.85447,-0.16637 2.95041,3.54953 z"
-           id="path1449"
-           sodipodi:nodetypes="ccccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:#cb391c;fill-opacity:1;fill-rule:evenodd;stroke:none'
+           d='m 280.72049,168.46367 0.1644,4.05654 -3.81335,-0.71676 -2.87504,-3.18901 -0.28089,-3.53393 3.85447,-0.16637 2.95041,3.54953 z'
+           id='path1449'
+           sodipodi:nodetypes='ccccccc'
+           inkscape:connector-curvature='0' />
       </g>
       <g
-         id="g2657"
-         style="display:inline">
+         id='g2657'
+         style='display:inline'>
         <path
-           style="fill:url(#linearGradient2406);fill-opacity:1;stroke:none"
-           d="m 183.88617,256.82796 0.99991,-16.30721 17.2878,8.44012 26.05488,38.00946 -29.28095,-1.13363 -15.06164,-29.00874 z"
-           id="rect2397"
-           sodipodi:nodetypes="cccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2406);fill-opacity:1;stroke:none'
+           d='m 183.88617,256.82796 0.99991,-16.30721 17.2878,8.44012 26.05488,38.00946 -29.28095,-1.13363 -15.06164,-29.00874 z'
+           id='rect2397'
+           sodipodi:nodetypes='cccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2325);fill-opacity:1;stroke:#000000;stroke-linejoin:round;stroke-opacity:1;display:inline"
-           d="m 200.90647,238.44836 -8.04601,15.77386 -7.05577,-13.57337 15.10178,-2.20049 z"
-           id="rect2207"
-           sodipodi:nodetypes="cccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2325);fill-opacity:1;stroke:#000000;stroke-linejoin:round;stroke-opacity:1;display:inline'
+           d='m 200.90647,238.44836 -8.04601,15.77386 -7.05577,-13.57337 15.10178,-2.20049 z'
+           id='rect2207'
+           sodipodi:nodetypes='cccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2227);fill-opacity:1;stroke:#000000;stroke-linejoin:round;stroke-opacity:1"
-           d="m 201.05389,238.55401 62.11704,24.91912 -7.88689,3.21429 -4.35152,9.30976 1.1716,9.96396 -59.31453,-31.72759 -0.49402,-7.36382 3.09592,-5.82826 5.6624,-2.48746 z"
-           id="rect1328"
-           sodipodi:nodetypes="ccccccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2227);fill-opacity:1;stroke:#000000;stroke-linejoin:round;stroke-opacity:1'
+           d='m 201.05389,238.55401 62.11704,24.91912 -7.88689,3.21429 -4.35152,9.30976 1.1716,9.96396 -59.31453,-31.72759 -0.49402,-7.36382 3.09592,-5.82826 5.6624,-2.48746 z'
+           id='rect1328'
+           sodipodi:nodetypes='ccccccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#radialGradient2317);fill-opacity:1;stroke:#000000;stroke-linejoin:round;stroke-opacity:1;display:inline"
-           d="m 255.27801,266.53504 7.9241,-3.04772 0.85337,10.24037 -3.9011,8.28983 -8.04601,3.77919 -1.341,-9.63083 4.51064,-9.63084 z"
-           id="rect2204"
-           sodipodi:nodetypes="ccccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#radialGradient2317);fill-opacity:1;stroke:#000000;stroke-linejoin:round;stroke-opacity:1;display:inline'
+           d='m 255.27801,266.53504 7.9241,-3.04772 0.85337,10.24037 -3.9011,8.28983 -8.04601,3.77919 -1.341,-9.63083 4.51064,-9.63084 z'
+           id='rect2204'
+           sodipodi:nodetypes='ccccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:url(#linearGradient2247);fill-opacity:1;stroke:none;display:inline"
-           d="m 195.7549,241.421 59.13059,24.7962 -4.5917,9.76614 -57.48995,-29.00967 2.95106,-5.55267 z"
-           id="rect2210"
-           sodipodi:nodetypes="ccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:url(#linearGradient2247);fill-opacity:1;stroke:none;display:inline'
+           d='m 195.7549,241.421 59.13059,24.7962 -4.5917,9.76614 -57.48995,-29.00967 2.95106,-5.55267 z'
+           id='rect2210'
+           sodipodi:nodetypes='ccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:#00b527;fill-opacity:1;stroke:none"
-           d="m 255.02263,275.21029 2.08411,-4.1069 2.96459,-1.06995 0.69433,3.37197 -1.76759,3.85723 -3.15516,1.38315 -0.82028,-3.4355 z"
-           id="rect2308"
-           sodipodi:nodetypes="ccccccc"
-           inkscape:connector-curvature="0" />
+           style='fill:#00b527;fill-opacity:1;stroke:none'
+           d='m 255.02263,275.21029 2.08411,-4.1069 2.96459,-1.06995 0.69433,3.37197 -1.76759,3.85723 -3.15516,1.38315 -0.82028,-3.4355 z'
+           id='rect2308'
+           sodipodi:nodetypes='ccccccc'
+           inkscape:connector-curvature='0' />
         <path
-           style="fill:#258209;fill-opacity:1;stroke:none;display:inline"
-           d="m 186.56849,241.00362 3.54963,-0.47312 -2.02297,3.53926 -1.52666,-3.06614 z"
-           id="rect2327"
-           sodipodi:nodetypes="cccc"
-           inkscape:connector-curvature="0" />
+           style='fill:#258209;fill-opacity:1;stroke:none;display:inline'
+           d='m 186.56849,241.00362 3.54963,-0.47312 -2.02297,3.53926 -1.52666,-3.06614 z'
+           id='rect2327'
+           sodipodi:nodetypes='cccc'
+           inkscape:connector-curvature='0' />
       </g>
     </g>
   </g>

+ 5 - 0
web/src/app/WebApp/Installers/Dolibarr/.htaccess

@@ -0,0 +1,5 @@
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteCond %{REQUEST_URI} !^/htdocs/
+    RewriteRule ^(.*)$ /htdocs/$1 [QSA,L]
+</IfModule>

+ 112 - 133
web/src/app/WebApp/Installers/Dolibarr/DolibarrSetup.php

@@ -1,138 +1,117 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Dolibarr;
-
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-
-class DolibarrSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Dolibarr",
-		"group" => "CRM",
-		"enabled" => true,
-		"version" => "20.0.2",
-		"thumbnail" => "dolibarr-thumb.png",
-	];
-
-	protected $appname = "dolibarr";
-
-	protected $config = [
-		"form" => [
-			"dolibarr_account_username" => ["value" => "admin"],
-			"dolibarr_account_password" => "password",
-			"language" => [
-				"type" => "select",
-				"options" => [
-					"en_EN" => "English",
-					"es_ES" => "Spanish",
-					"fr_FR" => "French",
-					"de_DE" => "German",
-					"pt_PT" => "Portuguese",
-					"it_IT" => "Italian",
-				],
-				"default" => "en_EN",
-			],
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" => "https://github.com/Dolibarr/dolibarr/archive/refs/tags/20.0.2.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "dolibarr",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1", "8.2", "8.3"],
-			],
-		],
-	];
-
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
-
-		$this->appcontext->runUser(
-			"v-copy-fs-directory",
-			[$this->getDocRoot($this->extractsubdir . "/dolibarr-20.0.2/."), $this->getDocRoot()],
-			$status,
-		);
-
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
+declare(strict_types=1);
 
-		$sslEnabled = $status->json[$this->domain]["SSL"] == "no" ? false : true;
-		$webDomain = ($sslEnabled ? "https://" : "http://") . $this->domain;
-
-		$language = $options["language"] ?? "en_EN";
-		$username = rawurlencode($options["dolibarr_account_username"]);
-		$password = rawurlencode($options["dolibarr_account_password"]);
-		$databaseUser = rawurlencode($this->appcontext->user() . "_" . $options["database_user"]);
-		$databasePassword = rawurlencode($options["database_password"]);
-		$databaseName = rawurlencode($this->appcontext->user() . "_" . $options["database_name"]);
-
-		$this->appcontext->runUser(
-			"v-copy-fs-file",
-			[
-				$this->getDocRoot("htdocs/conf/conf.php.example"),
-				$this->getDocRoot("htdocs/conf/conf.php"),
-			],
-			$status,
-		);
-
-		$this->appcontext->runUser(
-			"v-change-fs-file-permission",
-			[$this->getDocRoot("htdocs/conf/conf.php"), "666"],
-			$status,
-		);
-
-		$cmd =
-			"curl --request POST " .
-			($sslEnabled ? "" : "--insecure ") .
-			"--url $webDomain/install/step1.php " .
-			"--data 'testpost=ok&action=set" .
-			"&main_dir=" .
-			rawurlencode($this->getDocRoot("htdocs")) .
-			"&main_data_dir=" .
-			rawurlencode($this->getDocRoot("documents")) .
-			"&main_url=" .
-			rawurlencode($webDomain) .
-			"&db_name=$databaseName" .
-			"&db_type=mysqli" .
-			"&db_host=localhost" .
-			"&db_port=3306" .
-			"&db_prefix=llx_" .
-			"&db_user=$databaseUser" .
-			"&db_pass=$databasePassword" .
-			"&selectlang=$language' && " .
-			"curl --request POST " .
-			($sslEnabled ? "" : "--insecure ") .
-			"--url $webDomain/install/step2.php " .
-			"--data 'testpost=ok&action=set" .
-			"&dolibarr_main_db_character_set=utf8" .
-			"&dolibarr_main_db_collation=utf8_unicode_ci" .
-			"&selectlang=$language' && " .
-			"curl --request POST " .
-			($sslEnabled ? "" : "--insecure ") .
-			"--url $webDomain/install/step4.php " .
-			"--data 'testpost=ok&action=set" .
-			"&dolibarrpingno=checked" .
-			"&selectlang=$language' && " .
-			"curl --request POST " .
-			($sslEnabled ? "" : "--insecure ") .
-			"--url $webDomain/install/step5.php " .
-			"--data 'testpost=ok&action=set" .
-			"&login=$username" .
-			"&pass=$password" .
-			"&pass_verif=$password" .
-			"&selectlang=$language'";
-
-		exec($cmd, $output, $return_var);
-		if ($return_var > 0) {
-			throw new \Exception(implode(PHP_EOL, $output));
-		}
-
-		$this->cleanup();
+namespace Hestia\WebApp\Installers\Dolibarr;
 
-		return $status->code === 0;
-	}
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+use function file_get_contents;
+
+class DolibarrSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Dolibarr',
+        'group' => 'CRM',
+        'version' => '20.0.2',
+        'thumbnail' => 'dolibarr-thumb.png',
+    ];
+
+    protected array $config = [
+        'form' => [
+            'dolibarr_account_username' => ['value' => 'admin'],
+            'dolibarr_account_password' => 'password',
+            'language' => [
+                'type' => 'select',
+                'options' => [
+                    'en_EN' => 'English',
+                    'es_ES' => 'Spanish',
+                    'fr_FR' => 'French',
+                    'de_DE' => 'German',
+                    'pt_PT' => 'Portuguese',
+                    'it_IT' => 'Italian',
+                ],
+                'default' => 'en_EN',
+            ],
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' => 'https://github.com/Dolibarr/dolibarr/archive/refs/tags/20.0.2.zip',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'dolibarr',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
+
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->copyDirectory(
+            $target->getDocRoot('/dolibarr-' . $this->info['version'] . '/.'),
+            $target->getDocRoot(),
+        );
+
+        $language = $options['language'] ?? 'en_EN';
+
+        $this->appcontext->moveFile(
+            $target->getDocRoot('htdocs/conf/conf.php.example'),
+            $target->getDocRoot('htdocs/conf/conf.php'),
+        );
+
+        $this->appcontext->changeFilePermissions(
+            $target->getDocRoot('htdocs/conf/conf.php'),
+            '666',
+        );
+
+        $this->appcontext->addDirectory($target->getDocRoot('documents'));
+
+        $this->appcontext->createFile(
+            $target->getDocRoot('.htaccess'),
+            file_get_contents(__DIR__ . '/.htaccess'),
+        );
+
+        $this->appcontext->sendPostRequest($target->getUrl() . '/install/step1.php', [
+            'testpost' => 'ok',
+            'action' => 'set',
+            'main_dir' => $target->getDocRoot('htdocs'),
+            'main_data_dir' => $target->getDocRoot('documents'),
+            'main_url' => $target->getUrl(),
+            'db_type' => 'mysqli',
+            'db_host' => $target->database->host,
+            'db_port' => '3306',
+            'db_prefix' => 'llx_',
+            'db_name' => $target->database->name,
+            'db_user' => $target->database->user,
+            'db_pass' => $target->database->password,
+            'selectlang' => $language,
+        ]);
+
+        $this->appcontext->sendPostRequest($target->getUrl() . '/install/step2.php', [
+            'testpost' => 'ok',
+            'action' => 'set',
+            'dolibarr_main_db_character_set' => 'utf8',
+            'dolibarr_main_db_collation' => 'utf8_unicode_ci',
+            'selectlang' => $language,
+        ]);
+
+        $this->appcontext->sendPostRequest($target->getUrl() . '/install/step4.php', [
+            'testpost' => 'ok',
+            'action' => 'set',
+            'dolibarrpingno' => 'checked',
+            'selectlang' => $language,
+        ]);
+
+        $this->appcontext->sendPostRequest($target->getUrl() . '/install/step5.php', [
+            'testpost' => 'ok',
+            'login' => $options['dolibarr_account_username'],
+            'pass' => $options['dolibarr_account_password'],
+            'selectlang' => $language,
+        ]);
+    }
 }

+ 68 - 80
web/src/app/WebApp/Installers/Drupal/DrupalSetup.php

@@ -1,92 +1,80 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Hestia\WebApp\Installers\Drupal;
 
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-class DrupalSetup extends BaseSetup {
-	protected $appname = "drupal";
+use function sprintf;
 
-	protected $appInfo = [
-		"name" => "Drupal",
-		"group" => "cms",
-		"enabled" => "yes",
-		"version" => "latest",
-		"thumbnail" => "drupal-thumb.png",
-	];
+class DrupalSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Drupal',
+        'group' => 'cms',
+        'version' => 'latest',
+        'thumbnail' => 'drupal-thumb.png',
+    ];
 
-	protected $config = [
-		"form" => [
-			"username" => ["type" => "text", "value" => "admin"],
-			"password" => "password",
-			"email" => "text",
-		],
-		"database" => true,
-		"resources" => [
-			"composer" => ["src" => "drupal/recommended-project", "dst" => "/"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "drupal-composer",
-			],
-			"php" => [
-				"supported" => ["8.1", "8.2", "8.3"],
-			],
-		],
-	];
+    protected array $config = [
+        'form' => [
+            'username' => ['type' => 'text', 'value' => 'admin'],
+            'password' => 'password',
+            'email' => 'text',
+        ],
+        'database' => true,
+        'resources' => [
+            'composer' => ['src' => 'drupal/recommended-project', 'dst' => '/'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'drupal-composer',
+            ],
+            'php' => [
+                'supported' => ['8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
-		$this->appcontext->runComposer(
-			["require", "-d " . $this->getDocRoot(), "drush/drush"],
-			$status2,
-			["version" => 2, "php_version" => $options["php_version"]],
-		);
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->createFile(
+            $target->getDocRoot('.htaccess'),
+            '<IfModule mod_rewrite.c>
+                    RewriteEngine On
+                    RewriteRule ^(.*)$ web/$1 [L]
+            </IfModule>',
+        );
 
-		$htaccess_rewrite = '
-<IfModule mod_rewrite.c>
-		RewriteEngine On
-		RewriteRule ^(.*)$ web/$1 [L]
-</IfModule>';
+        $this->appcontext->runComposer($options['php_version'], [
+            'require',
+            '-d',
+            $target->getDocRoot(),
+            'drush/drush',
+        ]);
 
-		$tmp_configpath = $this->saveTempFile($htaccess_rewrite);
-		$this->appcontext->runUser(
-			"v-move-fs-file",
-			[$tmp_configpath, $this->getDocRoot(".htaccess")],
-			$result,
-		);
+        $databaseUrl = sprintf(
+            'mysql://%s:%s@%s:3306/%s',
+            $target->database->user,
+            $target->database->password,
+            $target->database->host,
+            $target->database->name,
+        );
 
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				quoteshellarg($this->getDocRoot("/vendor/drush/drush/drush")),
-				"site-install",
-				"standard",
-				"--db-url=" .
-				quoteshellarg(
-					"mysql://" .
-						$this->appcontext->user() .
-						"_" .
-						$options["database_user"] .
-						":" .
-						$options["database_password"] .
-						"@" .
-						$options["database_host"] .
-						":3306/" .
-						$this->appcontext->user() .
-						"_" .
-						$options["database_name"],
-				),
-				"--account-name=" . quoteshellarg($options["username"]),
-				"--account-pass=" . quoteshellarg($options["password"]),
-				"--site-name=Drupal",
-				"--site-mail=" . quoteshellarg($options["email"]),
-			],
-			$status,
-		);
-		return $status->code === 0;
-	}
+        $this->appcontext->runPHP(
+            $options['php_version'],
+            $target->getDocRoot('/vendor/drush/drush/drush.php'),
+            [
+                'site-install',
+                'standard',
+                '--db-url=' . $databaseUrl,
+                '--account-name=' . $options['username'],
+                '--account-pass=' . $options['password'],
+                '--site-name=Drupal', // Sadly even when escaped spaces are splitted up
+                '--site-mail=' . $options['email'],
+            ],
+        );
+    }
 }

+ 131 - 0
web/src/app/WebApp/Installers/Flarum/.htaccess

@@ -0,0 +1,131 @@
+<IfModule mod_rewrite.c>
+  RewriteEngine on
+
+  # Ensure the Authorization HTTP header is available to PHP
+  RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+
+  # Uncomment the following lines if you are not using a `public` directory
+  # to prevent sensitive resources from being exposed.
+  # <!-- BEGIN EXPOSED RESOURCES PROTECTION -->
+  RewriteRule /\.git / [F,L]
+  RewriteRule ^auth\.json$ / [F,L]
+  RewriteRule ^composer\.(lock|json)$ / [F,L]
+  RewriteRule ^config.php$ / [F,L]
+  RewriteRule ^flarum$ / [F,L]
+  RewriteRule ^storage/(.*)?$ / [F,L]
+  RewriteRule ^vendor/(.*)?$ / [F,L]
+  # <!-- END EXPOSED RESOURCES PROTECTION -->
+
+  # Pass all requests to public folder
+  RewriteRule ^(.*)$ public/$1 [QSA,L]
+</IfModule>
+
+# Disable directory listings
+Options -Indexes
+
+# MultiViews can mess up our rewriting scheme
+Options -MultiViews
+
+# The following directives are based on best practices from H5BP Apache Server Configs
+# https://github.com/h5bp/server-configs-apache
+
+# Expire rules for static content
+<IfModule mod_expires.c>
+  ExpiresActive on
+  ExpiresDefault                                      "access plus 1 month"
+  ExpiresByType text/css                              "access plus 1 year"
+  ExpiresByType application/atom+xml                  "access plus 1 hour"
+  ExpiresByType application/rdf+xml                   "access plus 1 hour"
+  ExpiresByType application/rss+xml                   "access plus 1 hour"
+  ExpiresByType application/json                      "access plus 0 seconds"
+  ExpiresByType application/ld+json                   "access plus 0 seconds"
+  ExpiresByType application/schema+json               "access plus 0 seconds"
+  ExpiresByType application/vnd.geo+json              "access plus 0 seconds"
+  ExpiresByType application/vnd.api+json              "access plus 0 seconds"
+  ExpiresByType application/xml                       "access plus 0 seconds"
+  ExpiresByType text/calendar                         "access plus 0 seconds"
+  ExpiresByType text/xml                              "access plus 0 seconds"
+  ExpiresByType image/vnd.microsoft.icon              "access plus 1 week"
+  ExpiresByType image/x-icon                          "access plus 1 week"
+  ExpiresByType text/html                             "access plus 0 seconds"
+  ExpiresByType application/javascript                "access plus 1 year"
+  ExpiresByType application/x-javascript              "access plus 1 year"
+  ExpiresByType text/javascript                       "access plus 1 year"
+  ExpiresByType application/manifest+json             "access plus 1 week"
+  ExpiresByType application/x-web-app-manifest+json   "access plus 0 seconds"
+  ExpiresByType text/cache-manifest                   "access plus 0 seconds"
+  ExpiresByType text/markdown                         "access plus 0 seconds"
+  ExpiresByType audio/ogg                             "access plus 1 month"
+  ExpiresByType image/bmp                             "access plus 1 month"
+  ExpiresByType image/gif                             "access plus 1 month"
+  ExpiresByType image/jpeg                            "access plus 1 month"
+  ExpiresByType image/png                             "access plus 1 month"
+  ExpiresByType image/svg+xml                         "access plus 1 month"
+  ExpiresByType image/webp                            "access plus 1 month"
+  ExpiresByType video/mp4                             "access plus 1 month"
+  ExpiresByType video/ogg                             "access plus 1 month"
+  ExpiresByType video/webm                            "access plus 1 month"
+  ExpiresByType application/wasm                      "access plus 1 year"
+  ExpiresByType font/collection                       "access plus 1 month"
+  ExpiresByType application/vnd.ms-fontobject         "access plus 1 month"
+  ExpiresByType font/eot                              "access plus 1 month"
+  ExpiresByType font/opentype                         "access plus 1 month"
+  ExpiresByType font/otf                              "access plus 1 month"
+  ExpiresByType application/x-font-ttf                "access plus 1 month"
+  ExpiresByType font/ttf                              "access plus 1 month"
+  ExpiresByType application/font-woff                 "access plus 1 month"
+  ExpiresByType application/x-font-woff               "access plus 1 month"
+  ExpiresByType font/woff                             "access plus 1 month"
+  ExpiresByType application/font-woff2                "access plus 1 month"
+  ExpiresByType font/woff2                            "access plus 1 month"
+  ExpiresByType text/x-cross-domain-policy            "access plus 1 week"
+</IfModule>
+
+# Gzip compression
+<IfModule mod_deflate.c>
+  <IfModule mod_filter.c>
+    AddOutputFilterByType DEFLATE "application/atom+xml" \
+                                  "application/javascript" \
+                                  "application/json" \
+                                  "application/ld+json" \
+                                  "application/manifest+json" \
+                                  "application/rdf+xml" \
+                                  "application/rss+xml" \
+                                  "application/schema+json" \
+                                  "application/vnd.geo+json" \
+                                  "application/vnd.ms-fontobject" \
+                                  "application/wasm" \
+                                  "application/x-font-ttf" \
+                                  "application/x-javascript" \
+                                  "application/x-web-app-manifest+json" \
+                                  "application/xhtml+xml" \
+                                  "application/xml" \
+                                  "font/collection" \
+                                  "font/eot" \
+                                  "font/opentype" \
+                                  "font/otf" \
+                                  "font/ttf" \
+                                  "image/bmp" \
+                                  "image/svg+xml" \
+                                  "image/vnd.microsoft.icon" \
+                                  "image/x-icon" \
+                                  "text/cache-manifest" \
+                                  "text/calendar" \
+                                  "text/css" \
+                                  "text/html" \
+                                  "text/javascript" \
+                                  "text/plain" \
+                                  "text/markdown" \
+                                  "text/vcard" \
+                                  "text/vnd.rim.location.xloc" \
+                                  "text/vtt" \
+                                  "text/x-component" \
+                                  "text/x-cross-domain-policy" \
+                                  "text/xml"
+    </IfModule>
+</IfModule>
+
+# Fix for https://httpoxy.org vulnerability
+<IfModule mod_headers.c>
+  RequestHeader unset Proxy
+</IfModule>

+ 56 - 203
web/src/app/WebApp/Installers/Flarum/FlarumSetup.php

@@ -1,208 +1,61 @@
 <?php
-namespace Hestia\WebApp\Installers\Flarum;
-
-use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
-
-class FlarumSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Flarum",
-		"group" => "forum",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "fl-thumb.png",
-	];
-
-	protected $appname = "flarum";
-
-	protected $config = [
-		"form" => [
-			"forum_title" => ["type" => "text", "value" => "Flarum Forum"],
-			"admin_username" => ["value" => "fladmin"],
-			"admin_email" => "text",
-			"admin_password" => "password",
-			"install_directory" => ["type" => "text", "value" => "", "placeholder" => "/"],
-		],
-		"database" => true,
-		"resources" => [
-			"composer" => ["src" => "flarum/flarum"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "flarum",
-			],
-			"php" => [
-				"supported" => ["8.0", "8.1", "8.2"],
-			],
-		],
-	];
-
-	// Our updateFile routine done the 'Hestia way'
-	public function updateFile($file, $search, $replace) {
-		$result = null;
-		$this->appcontext->runUser("v-open-fs-file", [$file], $result);
-		foreach ($result->raw as $line_num => $line) {
-			if (strpos($line, $search) !== false) {
-				$result->raw[$line_num] = str_replace($search, $replace, $line);
-			}
-		}
-		$tmp = $this->saveTempFile(implode("\r\n", $result->raw));
-		if (!$this->appcontext->runUser("v-move-fs-file", [$tmp, $file], $result)) {
-			throw new \Exception("Error updating file in: " . $tmp . " " . $result->text);
-		}
-		return $result;
-	}
 
-	public function install(array $options = null): bool {
-		parent::setAppDirInstall($options["install_directory"]);
-		parent::install($options);
-		parent::setup($options);
-		$result = null;
+declare(strict_types=1);
 
-		// Move public folder content (https://docs.flarum.org/install/#customizing-paths)
-		if (
-			!$this->appcontext->runUser(
-				"v-list-fs-directory",
-				[$this->getDocRoot("public")],
-				$result,
-			)
-		) {
-			throw new \Exception(
-				"Error listing folder at: " . $this->getDocRoot("public") . $result->text,
-			);
-		}
-		foreach ($result->raw as $line_num => $line) {
-			$detail = explode("|", $line);
-			$type = $detail[0];
-			$name = end($detail);
-			if ($name != "") {
-				if ($type == "d") {
-					// Directory
-					if (
-						!$this->appcontext->runUser(
-							"v-move-fs-directory",
-							[
-								$this->getDocRoot("public") . "/" . $name,
-								$this->getDocRoot() . "/" . $name,
-							],
-							$result,
-						)
-					) {
-						throw new \Exception(
-							"Error moving folder at: " .
-								$this->getDocRoot("public") .
-								"/" .
-								$name .
-								$result->text,
-						);
-					}
-				} else {
-					if (
-						!$this->appcontext->runUser(
-							"v-move-fs-file",
-							[
-								$this->getDocRoot("public") . "/" . $name,
-								$this->getDocRoot() . "/" . $name,
-							],
-							$result,
-						)
-					) {
-						throw new \Exception(
-							"Error moving file at: " .
-								$this->getDocRoot("public") .
-								"/" .
-								$name .
-								$result->text,
-						);
-					}
-				}
-			}
-		}
-		if (
-			!$this->appcontext->runUser(
-				"v-delete-fs-directory",
-				[$this->getDocRoot("public")],
-				$result,
-			)
-		) {
-			throw new \Exception(
-				"Error deleting folder at: " . $this->getDocRoot("public") . $result->text,
-			);
-		}
-
-		// Not using 'public'; enable protection rewrite rules and update paths
-		$result = $this->updateFile(
-			$this->getDocRoot(".htaccess"),
-			"# RewriteRule ",
-			"RewriteRule ",
-		);
-		$result = $this->updateFile(
-			$this->getDocRoot("index.php"),
-			'$site = require \'../site.php\';',
-			'$site = require \'./site.php\';',
-		);
-		$result = $this->updateFile(
-			$this->getDocRoot("site.php"),
-			"'public' => __DIR__.'/public',",
-			"'public' => __DIR__,",
-		);
-
-		// POST install
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
-
-		$sslEnabled = $status->json[$this->domain]["SSL"] == "no" ? 0 : 1;
-		$webDomain = ($sslEnabled ? "https://" : "http://") . $this->domain;
-		$webPort = $sslEnabled ? "443" : "80";
-
-		$mysql_host = $options["database_host"];
-		$mysql_database = addcslashes(
-			$this->appcontext->user() . "_" . $options["database_name"],
-			"\\'",
-		);
-		$mysql_username = addcslashes(
-			$this->appcontext->user() . "_" . $options["database_user"],
-			"\\'",
-		);
-		$mysql_password = addcslashes($options["database_password"], "\\'");
-		$table_prefix = addcslashes(Util::generate_string(5, false) . "_", "\\'");
-		$subfolder = $options["install_directory"];
-		if (substr($subfolder, 0, 1) != "/") {
-			$subfolder = "/" . $subfolder;
-		}
-
-		$cmd = implode(" ", [
-			"/usr/bin/curl",
-			"--location",
-			"--post301",
-			"--insecure",
-			"--resolve " .
-			quoteshellarg(
-				$this->domain . ":$webPort:" . $this->appcontext->getWebDomainIp($this->domain),
-			),
-			quoteshellarg($webDomain . $subfolder . "/index.php"),
-			"--data-binary " .
-			quoteshellarg(
-				http_build_query([
-					"forumTitle" => $options["forum_title"],
-					"mysqlHost" => $mysql_host,
-					"mysqlDatabase" => $mysql_database,
-					"mysqlUsername" => $mysql_username,
-					"mysqlPassword" => $mysql_password,
-					"tablePrefix" => $table_prefix,
-					"adminUsername" => $options["admin_username"],
-					"adminEmail" => $options["admin_email"],
-					"adminPassword" => $options["admin_password"],
-					"adminPasswordConfirmation" => $options["admin_password"],
-				]),
-			),
-		]);
-		exec($cmd, $output, $return_var);
+namespace Hestia\WebApp\Installers\Flarum;
 
-		// Report any errors
-		if ($return_var > 0) {
-			throw new \Exception(implode(PHP_EOL, $output));
-		}
-		return $result->code === 0 && $return_var === 0;
-	}
+use Hestia\System\Util;
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+
+class FlarumSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Flarum',
+        'group' => 'forum',
+        'version' => 'latest',
+        'thumbnail' => 'fl-thumb.png',
+    ];
+
+    protected array $config = [
+        'form' => [
+            'forum_title' => ['type' => 'text', 'value' => 'Flarum Forum'],
+            'admin_username' => ['value' => 'fladmin'],
+            'admin_email' => 'text',
+            'admin_password' => 'password',
+        ],
+        'database' => true,
+        'resources' => [
+            'composer' => ['src' => 'flarum/flarum'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'flarum-composer',
+            ],
+            'php' => [
+                'supported' => ['8.2', '8.3'],
+            ],
+        ],
+    ];
+
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->createFile(
+            $target->getDocRoot('.htaccess'),
+            file_get_contents(__DIR__ . '/.htaccess'),
+        );
+
+        $this->appcontext->sendPostRequest($target->getUrl(), [
+            'forumTitle' => $options['forum_title'],
+            'mysqlHost' => $target->database->host,
+            'mysqlDatabase' => $target->database->name,
+            'mysqlUsername' => $target->database->user,
+            'mysqlPassword' => $target->database->password,
+            'tablePrefix' => 'fl' . Util::generateString(5, false),
+            'adminUsername' => $options['admin_username'],
+            'adminEmail' => $options['admin_email'],
+            'adminPassword' => $options['admin_password'],
+            'adminPasswordConfirmation' => $options['admin_password'],
+        ]);
+    }
 }

+ 58 - 66
web/src/app/WebApp/Installers/Grav/GravSetup.php

@@ -1,76 +1,68 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Grav;
+declare(strict_types=1);
 
-use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
+namespace Hestia\WebApp\Installers\Grav;
 
-class GravSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Grav",
-		"group" => "cms",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "grav-symbol.svg",
-	];
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-	protected $appname = "grav";
+class GravSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Grav',
+        'group' => 'cms',
+        'version' => 'latest',
+        'thumbnail' => 'grav-symbol.svg',
+    ];
 
-	protected $config = [
-		"form" => [
-			"admin" => ["type" => "boolean", "value" => false, "label" => "Create admin account"],
-			"username" => ["text" => "admin"],
-			"password" => "password",
-			"email" => "text",
-		],
-		"database" => false,
-		"resources" => [
-			"composer" => ["src" => "getgrav/grav", "dst" => "/"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "grav",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1"],
-			],
-		],
-	];
+    protected array $config = [
+        'form' => [
+            'admin' => ['type' => 'boolean', 'value' => false, 'label' => 'Create admin account'],
+            'username' => ['text' => 'admin'],
+            'password' => 'password',
+            'email' => 'text',
+        ],
+        'database' => false,
+        'resources' => [
+            'composer' => ['src' => 'getgrav/grav', 'dst' => '/'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'grav',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1' . '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-	public function install(array $options = null) {
-		parent::install($options);
-		parent::setup($options);
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        if ($options['admin'] == true) {
+            chdir($target->getDocRoot());
 
-		if ($options["admin"] == true) {
-			chdir($this->getDocRoot());
+            $this->appcontext->runPHP($options['php_version'], $target->getDocRoot('/bin/gpm'), [
+                'install',
+                'admin',
+            ]);
 
-			$this->appcontext->runUser(
-				"v-run-cli-cmd",
-				[
-					"/usr/bin/php" . $options["php_version"],
-					$this->getDocRoot("/bin/gpm"),
-					"install admin",
-				],
-				$status,
-			);
-			$this->appcontext->runUser(
-				"v-run-cli-cmd",
-				[
-					"/usr/bin/php" . $options["php_version"],
-					$this->getDocRoot("/bin/plugin"),
-					"login new-user",
-					"-u " . $options["username"],
-					"-p " . $options["password"],
-					"-e " . $options["email"],
-					"-P a",
-					"-N " . $options["username"],
-					"-l en",
-				],
-				$status,
-			);
-			return $status->code === 0;
-		} else {
-			return true;
-		}
-	}
+            $this->appcontext->runPHP($options['php_version'], $target->getDocRoot('/bin/plugin'), [
+                'login',
+                'new-user',
+                '-u',
+                $options['username'],
+                '-p',
+                $options['password'],
+                '-e',
+                $options['email'],
+                '-P',
+                'a',
+                '-N',
+                $options['username'],
+                '-l',
+                'en',
+            ]);
+        }
+    }
 }

Файловите разлики са ограничени, защото са твърде много
+ 1 - 9
web/src/app/WebApp/Installers/Grav/grav-symbol.svg


+ 71 - 103
web/src/app/WebApp/Installers/Joomla/JoomlaSetup.php

@@ -1,113 +1,81 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Hestia\WebApp\Installers\Joomla;
 
 use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
-
-class JoomlaSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Joomla",
-		"group" => "cms",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "joomla_thumb.png",
-	];
-
-	protected $appname = "joomla";
-	protected $config = [
-		"form" => [
-			"site_name" => [
-				"type" => "text",
-				"value" => "Joomla Site",
-				"placeholder" => "Joomla Site",
-			],
-			"admin_username" => [
-				"type" => "text",
-				"value" => "admin",
-				"placeholder" => "Admin Username",
-			],
-			"admin_password" => [
-				"type" => "password",
-				"value" => "",
-				"placeholder" => "Admin Password",
-			],
-			"admin_email" => [
-				"type" => "text",
-				"value" => "admin@example.com",
-				"placeholder" => "Admin Email",
-			],
-			"install_directory" => [
-				"type" => "text",
-				"value" => "",
-				"placeholder" => "/",
-			],
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" => "https://www.joomla.org/latest",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "joomla",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1", "8.2"],
-			],
-		],
-	];
-
-	public function install(array $options = null): bool {
-		$installDir =
-			rtrim($this->getDocRoot(), "/") . "/" . ltrim($options["install_directory"] ?? "", "/");
-		parent::setAppDirInstall($options["install_directory"] ?? "");
-		parent::install($options);
-		parent::setup($options);
-
-		if (!is_dir($installDir)) {
-			throw new \Exception("Installation directory does not exist: " . $installDir);
-		}
-
-		// Database credentials
-		$dbHost = $options["database_host"];
-		$dbName = $this->appcontext->user() . "_" . $options["database_name"];
-		$dbUser = $this->appcontext->user() . "_" . $options["database_user"];
-		$dbPass = $options["database_password"];
-
-		// Site and admin credentials
-		$siteName = $options["site_name"];
-		$adminUsername = $options["admin_username"];
-		$adminPassword = $options["admin_password"];
-		$adminEmail = $options["admin_email"];
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-		// Initialize Joomla using the CLI
-		$cliCmd = [
-			"/usr/bin/php",
-			quoteshellarg("$installDir/installation/joomla.php"),
-			"install",
-			"--site-name=" . quoteshellarg($siteName),
-			"--admin-user=" . quoteshellarg($adminUsername),
-			"--admin-username=" . quoteshellarg($adminUsername),
-			"--admin-password=" . quoteshellarg($adminPassword),
-			"--admin-email=" . quoteshellarg($adminEmail),
-			"--db-user=" . quoteshellarg($dbUser),
-			"--db-pass=" . quoteshellarg($dbPass),
-			"--db-name=" . quoteshellarg($dbName),
-			"--db-prefix=" . quoteshellarg(Util::generate_string(5, false) . "_"),
-			"--db-host=" . quoteshellarg($dbHost),
-			"--db-type=mysqli",
-		];
+class JoomlaSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Joomla',
+        'group' => 'cms',
+        'version' => '5.2.3',
+        'thumbnail' => 'joomla_thumb.png',
+    ];
 
-		$status = null;
-		$this->appcontext->runUser("v-run-cli-cmd", $cliCmd, $status);
+    protected array $config = [
+        'form' => [
+            'admin_username' => [
+                'type' => 'text',
+                'value' => 'admin',
+                'placeholder' => 'Admin Username',
+            ],
+            'admin_password' => [
+                'type' => 'password',
+                'value' => '',
+                'placeholder' => 'Admin Password',
+            ],
+            'admin_email' => [
+                'type' => 'text',
+                'value' => '',
+                'placeholder' => 'Admin Email',
+            ],
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' => 'https://downloads.joomla.org/cms/'
+                    . 'joomla5/5-2-3/Joomla_5-2-3-Stable-Full_Package.zip?format=zip',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'joomla',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-		if ($status->code !== 0) {
-			throw new \Exception("Failed to install Joomla using CLI: " . $status->text);
-		}
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->moveFile(
+            $target->getDocRoot('htaccess.txt'),
+            $target->getDocRoot('.htaccess'),
+        );
 
-		return true;
-	}
+        $this->appcontext->runPHP(
+            $options['php_version'],
+            $target->getDocRoot('installation/joomla.php'),
+            [
+                'install',
+                '--site-name=Joomla',
+                '--admin-user=' . $options['admin_username'],
+                '--admin-username=' . $options['admin_username'],
+                '--admin-password=' . $options['admin_password'],
+                '--admin-email=' . $options['admin_email'],
+                '--db-user=' . $target->database->user,
+                '--db-pass=' . $target->database->password,
+                '--db-name=' . $target->database->name,
+                '--db-host=' . $target->database->host,
+                '--db-type=mysqli',
+                '--no-interaction',
+            ],
+        );
+    }
 }

+ 4 - 0
web/src/app/WebApp/Installers/Laravel/.htaccess

@@ -0,0 +1,4 @@
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteRule ^(.*)$ public/$1 [QSA,L]
+</IfModule>

+ 38 - 50
web/src/app/WebApp/Installers/Laravel/LaravelSetup.php

@@ -1,55 +1,43 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Laravel;
-
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-
-class LaravelSetup extends BaseSetup {
-	protected $appname = "laravel";
-
-	protected $appInfo = [
-		"name" => "Laravel",
-		"group" => "framework",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "laravel-thumb.png",
-	];
-
-	protected $config = [
-		"form" => [],
-		"database" => true,
-		"resources" => [
-			"composer" => ["src" => "laravel/laravel", "dst" => "/"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "laravel",
-			],
-			"php" => [
-				"supported" => ["8.1", "8.2", "8.3", "8.4"],
-			],
-		],
-	];
+declare(strict_types=1);
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
-
-		$result = null;
-
-		$htaccess_rewrite = '
-<IfModule mod_rewrite.c>
-		RewriteEngine On
-		RewriteRule ^(.*)$ public/$1 [L]
-</IfModule>';
-
-		$tmp_configpath = $this->saveTempFile($htaccess_rewrite);
-		$this->appcontext->runUser(
-			"v-move-fs-file",
-			[$tmp_configpath, $this->getDocRoot(".htaccess")],
-			$result,
-		);
+namespace Hestia\WebApp\Installers\Laravel;
 
-		return $result->code === 0;
-	}
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+use function file_get_contents;
+
+class LaravelSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Laravel',
+        'group' => 'framework',
+        'version' => 'latest',
+        'thumbnail' => 'laravel-thumb.png',
+    ];
+
+    protected array $config = [
+        'form' => [],
+        'database' => false,
+        'resources' => [
+            'composer' => ['src' => 'laravel/laravel', 'dst' => '/'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'laravel',
+            ],
+            'php' => [
+                'supported' => ['8.1', '8.2', '8.3', '8.4'],
+            ],
+        ],
+    ];
+
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->createFile(
+            $target->getDocRoot('.htaccess'),
+            file_get_contents(__DIR__ . '/.htaccess'),
+        );
+    }
 }

Файловите разлики са ограничени, защото са твърде много
+ 0 - 19
web/src/app/WebApp/Installers/MediaWiki/MediaWiki-2020-logo.svg


+ 59 - 82
web/src/app/WebApp/Installers/MediaWiki/MediaWikiSetup.php

@@ -1,92 +1,69 @@
 <?php
 
-namespace Hestia\WebApp\Installers\MediaWiki;
-
-use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
-
-class MediaWikiSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "MediaWiki",
-		"group" => "cms",
-		"enabled" => true,
-		"version" => "1.42.3",
-		"thumbnail" => "MediaWiki-2020-logo.svg", //Max size is 300px by 300px
-	];
-
-	protected $appname = "mediawiki";
-	protected $extractsubdir = "/tmp-mediawiki";
+declare(strict_types=1);
 
-	protected $config = [
-		"form" => [
-			"admin_username" => ["type" => "text", "value" => "admin"],
-			"admin_password" => "password",
-			"language" => ["type" => "text", "value" => "en"],
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" => "https://releases.wikimedia.org/mediawiki/1.42/mediawiki-1.42.3.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "default",
-			],
-			"php" => [
-				"supported" => ["8.0", "8.1", "8.2"],
-			],
-		],
-	];
-
-	public function install(array $options = null) {
-		parent::install($options);
-		parent::setup($options);
-
-		//check if ssl is enabled
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
-
-		if ($status->code !== 0) {
-			throw new \Exception("Cannot list domain");
-		}
+namespace Hestia\WebApp\Installers\MediaWiki;
 
-		$sslEnabled = $status->json[$this->domain]["SSL"] == "no" ? 0 : 1;
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-		$webDomain = ($sslEnabled ? "https://" : "http://") . $this->domain;
+class MediaWikiSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'MediaWiki',
+        'group' => 'cms',
+        'version' => '1.43.0',
+        'thumbnail' => 'MediaWiki-2020-logo.svg', //Max size is 300px by 300px
+    ];
 
-		$this->appcontext->runUser(
-			"v-copy-fs-directory",
-			[$this->getDocRoot($this->extractsubdir . "/mediawiki-1.42.3/."), $this->getDocRoot()],
-			$result,
-		);
+    protected array $config = [
+        'form' => [
+            'admin_username' => ['type' => 'text', 'value' => 'admin'],
+            'admin_password' => 'password',
+            'language' => ['type' => 'text', 'value' => 'en'],
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' => 'https://releases.wikimedia.org/mediawiki/1.43/mediawiki-1.43.0.zip',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'default',
+            ],
+            'php' => [
+                'supported' => ['8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				quoteshellarg($this->getDocRoot("maintenance/install.php")),
-				"--dbserver=" . quoteshellarg($options["database_host"]),
-				"--dbname=" .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_name"]),
-				"--installdbuser=" .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_user"]),
-				"--installdbpass=" . quoteshellarg($options["database_password"]),
-				"--dbuser=" .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_user"]),
-				"--dbpass=" . quoteshellarg($options["database_password"]),
-				"--server=" . quoteshellarg($webDomain),
-				"--scriptpath=", // must NOT be /
-				"--lang=" . quoteshellarg($options["language"]),
-				"--pass=" . quoteshellarg($options["admin_password"]),
-				"MediaWiki", // A Space here would trigger the next argument and preemptively set the admin username
-				quoteshellarg($options["admin_username"]),
-			],
-			$status,
-		);
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->copyDirectory(
+            $target->getDocRoot('/mediawiki-1.43.0/.'),
+            $target->getDocRoot(),
+        );
 
-		$this->cleanup();
+        $this->appcontext->runPHP(
+            $options['php_version'],
+            $target->getDocRoot('maintenance/install.php'),
+            [
+                '--dbserver=' . $target->database->host,
+                '--dbname=' . $target->database->name,
+                '--installdbuser=' . $target->database->user,
+                '--installdbpass=' . $target->database->password,
+                '--dbuser=' . $target->database->name,
+                '--dbpass=' . $target->database->password,
+                '--server=' . $target->getUrl(),
+                '--scriptpath=', // must NOT be /
+                '--lang=' . $options['language'],
+                '--pass=' . $options['admin_password'],
+                'Media Wiki',
+                $options['admin_username'],
+            ],
+        );
 
-		return $status->code === 0;
-	}
+        $this->appcontext->deleteDirectory($target->getDocRoot('/mediawiki-1.43.0/'));
+    }
 }

+ 43 - 45
web/src/app/WebApp/Installers/NamelessMC/NamelessMCSetup.php

@@ -1,53 +1,51 @@
 <?php
 
-namespace Hestia\WebApp\Installers\NamelessMC;
-
-use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
+declare(strict_types=1);
 
-class NamelessMCSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "NamelessMC",
-		"group" => "cms",
-		"enabled" => true,
-		"version" => "2.1.2",
-		"thumbnail" => "namelessmc.png",
-	];
+namespace Hestia\WebApp\Installers\NamelessMC;
 
-	protected $appname = "namelessmc";
-	protected $config = [
-		"form" => [
-			"protocol" => [
-				"type" => "select",
-				"options" => ["http", "https"],
-				"value" => "https",
-			],
-		],
-		"database" => false,
-		"resources" => [
-			"archive" => [
-				"src" =>
-					"https://github.com/NamelessMC/Nameless/releases/download/v2.1.2/nameless-deps-dist.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "namelessmc",
-			],
-			"apache2" => [
-				"template" => "namelessmc",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1"],
-			],
-		],
-	];
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-	public function install(array $options = null) {
-		parent::install($options);
+class NamelessMCSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'NamelessMC',
+        'group' => 'cms',
+        'version' => '2.1.2',
+        'thumbnail' => 'namelessmc.png',
+    ];
 
-		$status = 0;
+    protected array $config = [
+        'form' => [
+            'protocol' => [
+                'type' => 'select',
+                'options' => ['http', 'https'],
+                'value' => 'https',
+            ],
+        ],
+        'database' => false,
+        'resources' => [
+            'archive' => [
+                'src' =>
+                    'https://github.com/NamelessMC/Nameless/releases/download/v2.1.2/nameless-deps-dist.zip',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'namelessmc',
+            ],
+            'apache2' => [
+                'template' => 'namelessmc',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1'],
+            ],
+        ],
+    ];
 
-		return $status === 0;
-	}
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        // Nothing to do after installation of resources
+    }
 }

+ 61 - 77
web/src/app/WebApp/Installers/Nextcloud/NextcloudSetup.php

@@ -1,89 +1,73 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Nextcloud;
-
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
+declare(strict_types=1);
 
-class NextcloudSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Nextcloud",
-		"group" => "cloud",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "nextcloud-thumb.png",
-	];
+namespace Hestia\WebApp\Installers\Nextcloud;
 
-	protected $appname = "nextcloud";
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-	protected $config = [
-		"form" => [
-			"username" => ["value" => "admin"],
-			"password" => "password",
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => ["src" => "https://download.nextcloud.com/server/releases/latest.tar.bz2"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "owncloud",
-			],
-			"php" => [
-				"supported" => ["8.0", "8.1", "8.2"],
-			],
-		],
-	];
+class NextcloudSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Nextcloud',
+        'group' => 'cloud',
+        'version' => 'latest',
+        'thumbnail' => 'nextcloud-thumb.png',
+    ];
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
+    protected array $config = [
+        'form' => [
+            'username' => ['value' => 'admin'],
+            'password' => 'password',
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => ['src' => 'https://download.nextcloud.com/server/releases/latest.tar.bz2'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'owncloud',
+            ],
+            'php' => [
+                'supported' => ['8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-		// install nextcloud
-		$php_version = $this->appcontext->getSupportedPHP(
-			$this->config["server"]["php"]["supported"],
-		);
+    protected function setupApplication(InstallationTarget $target, array $options): void
+    {
+        $this->appcontext->runPHP($options['php_version'], $target->getDocRoot('occ'), [
+            'maintenance:install',
+            '--database',
+            'mysql',
+            '--database-name',
+            $target->database->name,
+            '--database-host',
+            $target->database->host,
+            '--database-user',
+            $target->database->user,
+            '--database-pass',
+            $target->database->password,
+            '--admin-user',
+            $options['username'],
+            '--admin-pass',
+            $options['password'],
+        ]);
 
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				quoteshellarg($this->getDocRoot("occ")),
-				"maintenance:install",
-				"--database mysql",
-				"--database-name " .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_name"]),
-				"--database-host " . quoteshellarg($options["database_host"]),
-				"--database-user " .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_user"]),
-				"--database-pass " . quoteshellarg($options["database_password"]),
-				"--admin-user " . quoteshellarg($options["username"]),
-				"--admin-pass " . quoteshellarg($options["password"]),
-			],
-			$status,
-		);
+        $this->appcontext->runPHP($options['php_version'], $target->getDocRoot('occ'), [
+            'config:system:set',
+            'trusted_domains',
+            '2',
+            '--value=' . $target->domain->domainName,
+        ]);
 
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				quoteshellarg($this->getDocRoot("occ")),
-				"config:system:set",
-				"trusted_domains 2 --value=" . quoteshellarg($this->domain),
-			],
-			$status,
-		);
+        // Bump minimum memory limit to 512M
+        $phpIni = $target->getDocRoot('.user.ini');
 
-		// Bump minimum memory limit to 512M
-		$result = null;
-		$file = $this->getDocRoot(".user.ini");
-		$this->appcontext->runUser("v-open-fs-file", [$file], $result);
-		array_push($result->raw, "memory_limit=512M");
-		$tmp = $this->saveTempFile(implode("\r\n", $result->raw));
-		if (!$this->appcontext->runUser("v-move-fs-file", [$tmp, $file], $result)) {
-			throw new \Exception("Error updating file in: " . $tmp . " " . $result->text);
-		}
+        $contents = $this->appcontext->readFile($phpIni);
+        $contents .= 'memory_limit=512M\r\n';
 
-		return $status->code === 0;
-	}
+        $this->appcontext->createFile($phpIni, $contents);
+    }
 }

+ 80 - 121
web/src/app/WebApp/Installers/OpenCart/OpenCartSetup.php

@@ -1,135 +1,94 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Opencart;
+declare(strict_types=1);
 
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
+namespace Hestia\WebApp\Installers\OpenCart;
 
-class OpenCartSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "OpenCart",
-		"group" => "ecommerce",
-		"enabled" => true,
-		"version" => "4.0.2.2",
-		"thumbnail" => "opencart-thumb.png",
-	];
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-	protected $appname = "opencart";
-	protected $extractsubdir = "/tmp-opencart";
+class OpenCartSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'OpenCart',
+        'group' => 'ecommerce',
+        'version' => '4.0.2.2',
+        'thumbnail' => 'opencart-thumb.png',
+    ];
 
-	protected $config = [
-		"form" => [
-			"opencart_account_username" => ["value" => "ocadmin"],
-			"opencart_account_email" => "text",
-			"opencart_account_password" => "password",
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" =>
-					"https://github.com/opencart/opencart/releases/download/4.0.2.2/opencart-4.0.2.2.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "opencart",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1", "8.2"],
-			],
-		],
-	];
+    protected array $config = [
+        'form' => [
+            'opencart_account_username' => ['value' => 'ocadmin'],
+            'opencart_account_email' => 'text',
+            'opencart_account_password' => 'password',
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' =>
+                    'https://github.com/opencart/opencart/releases/download/4.0.2.2/opencart-4.0.2.2.zip',
+                'dst' => '/tmp-prestashop',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'opencart',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
+    protected function setupApplication(InstallationTarget $target, array $options = null): void
+    {
+        $extractDirectory = $this->config['resources']['archive']['dst'];
 
-		$this->appcontext->runUser(
-			"v-copy-fs-directory",
-			[$this->getDocRoot($this->extractsubdir . "/upload/."), $this->getDocRoot()],
-			$result,
-		);
+        $this->appcontext->copyDirectory(
+            $target->getDocRoot($extractDirectory . '/upload/.'),
+            $target->getDocRoot(),
+        );
 
-		$this->appcontext->runUser("v-copy-fs-file", [
-			$this->getDocRoot("config-dist.php"),
-			$this->getDocRoot("config.php"),
-		]);
-		$this->appcontext->runUser("v-copy-fs-file", [
-			$this->getDocRoot("admin/config-dist.php"),
-			$this->getDocRoot("admin/config.php"),
-		]);
-		$this->appcontext->runUser("v-copy-fs-file", [
-			$this->getDocRoot(".htaccess.txt"),
-			$this->getDocRoot(".htaccess"),
-		]);
-		#Check if SSL is enabled
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
+        $this->appcontext->moveFile(
+            $target->getDocRoot('config-dist.php'),
+            $target->getDocRoot('config.php'),
+        );
 
-		if ($status->code !== 0) {
-			throw new \Exception("Cannot list domain");
-		}
-		if ($status->json[$this->domain]["SSL"] == "no") {
-			$protocol = "http://";
-		} else {
-			$protocol = "https://";
-		}
+        $this->appcontext->moveFile(
+            $target->getDocRoot('admin/config-dist.php'),
+            $target->getDocRoot('admin/config.php'),
+        );
 
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				quoteshellarg($this->getDocRoot("/install/cli_install.php")),
-				"install",
-				"--db_hostname " . quoteshellarg($options["database_host"]),
-				"--db_username " .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_user"]),
-				"--db_password " . quoteshellarg($options["database_password"]),
-				"--db_database " .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_name"]),
-				"--username " . quoteshellarg($options["opencart_account_username"]),
-				"--password " . quoteshellarg($options["opencart_account_password"]),
-				"--email " . quoteshellarg($options["opencart_account_email"]),
-				"--http_server " . quoteshellarg($protocol . $this->domain . "/"),
-			],
-			$status,
-		);
+        $this->appcontext->moveFile(
+            $target->getDocRoot('.htaccess.txt'),
+            $target->getDocRoot('.htaccess'),
+        );
 
-		// After install, 'storage' folder must be moved to a location where the web server is not allowed to serve file
-		// - Opencart Nginx template and Apache ".htaccess" forbids acces to /storage folder
-		$this->appcontext->runUser(
-			"v-move-fs-directory",
-			[$this->getDocRoot("system/storage"), $this->getDocRoot()],
-			$result,
-		);
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			["sed", "-i", "s/'storage\//'..\/storage\// ", $this->getDocRoot("config.php")],
-			$status,
-		);
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			["sed", "-i", "s/'storage\//'..\/storage\// ", $this->getDocRoot("admin/config.php")],
-			$status,
-		);
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			["sed", "-i", "s/\^system\/storage\//^\/storage\// ", $this->getDocRoot(".htaccess")],
-			$status,
-		);
+        $this->appcontext->runPHP(
+            $options['php_version'],
+            $target->getDocRoot('/install/cli_install.php'),
+            [
+                'install',
+                '--db_hostname',
+                $target->database->host,
+                '--db_username',
+                $target->database->user,
+                '--db_password',
+                $target->database->password,
+                '--db_database',
+                $target->database->name,
+                '--username',
+                $options['opencart_account_username'],
+                '--password',
+                $options['opencart_account_password'],
+                '--email',
+                $options['opencart_account_email'],
+                '--http_server',
+                $target->getUrl() . '/',
+            ],
+        );
 
-		$this->appcontext->runUser("v-change-fs-file-permission", [
-			$this->getDocRoot("config.php"),
-			"640",
-		]);
-		$this->appcontext->runUser("v-change-fs-file-permission", [
-			$this->getDocRoot("admin/config.php"),
-			"640",
-		]);
-
-		// remove install folder
-		$this->appcontext->runUser("v-delete-fs-directory", [$this->getDocRoot("/install")]);
-		$this->cleanup();
-
-		return $status->code === 0;
-	}
+        $this->appcontext->deleteDirectory($target->getDocRoot('/install'));
+        $this->appcontext->deleteDirectory($target->getDocRoot($extractDirectory));
+    }
 }

+ 63 - 83
web/src/app/WebApp/Installers/PrestaShop/PrestaShopSetup.php

@@ -1,94 +1,74 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Prestashop;
+declare(strict_types=1);
 
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
+namespace Hestia\WebApp\Installers\PrestaShop;
 
-class PrestaShopSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "PrestaShop",
-		"group" => "ecommerce",
-		"enabled" => true,
-		"version" => "8.1.0",
-		"thumbnail" => "prestashop-thumb.png",
-	];
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-	protected $appname = "prestashop";
-	protected $extractsubdir = "/tmp-prestashop";
+class PrestaShopSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'PrestaShop',
+        'group' => 'ecommerce',
+        'version' => '8.1.0',
+        'thumbnail' => 'prestashop-thumb.png',
+    ];
 
-	protected $config = [
-		"form" => [
-			"prestashop_account_first_name" => ["value" => "John"],
-			"prestashop_account_last_name" => ["value" => "Doe"],
-			"prestashop_account_email" => "text",
-			"prestashop_account_password" => "password",
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" =>
-					"https://github.com/PrestaShop/PrestaShop/releases/download/8.1.0/prestashop_8.1.0.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "prestashop",
-			],
-			"php" => [
-				"supported" => ["8.0", "8.1"],
-			],
-		],
-	];
+    protected array $config = [
+        'form' => [
+            'prestashop_account_first_name' => ['value' => ''],
+            'prestashop_account_last_name' => ['value' => ''],
+            'prestashop_account_email' => 'text',
+            'prestashop_account_password' => 'password',
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' =>
+                    'https://github.com/PrestaShop/PrestaShop/releases/download/8.2.0/prestashop_8.2.0.zip',
+                'dst' => '/tmp-prestashop',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'prestashop',
+            ],
+            'php' => [
+                'supported' => ['8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
-		$this->appcontext->archiveExtract(
-			$this->getDocRoot($this->extractsubdir . "/prestashop.zip"),
-			$this->getDocRoot(),
-		);
-		//check if ssl is enabled
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
+    protected function setupApplication(InstallationTarget $target, array $options = null): void
+    {
+        $extractDirectory = $this->config['resources']['archive']['dst'];
 
-		if ($status->code !== 0) {
-			throw new \Exception("Cannot list domain");
-		}
+        $this->appcontext->archiveExtract(
+            $target->getDocRoot($extractDirectory . '/prestashop.zip'),
+            $target->getDocRoot(),
+        );
 
-		if ($status->json[$this->domain]["SSL"] == "no") {
-			$ssl_enabled = 0;
-		} else {
-			$ssl_enabled = 1;
-		}
+        $this->appcontext->runPHP(
+            $options['php_version'],
+            $target->getDocRoot('/install/index_cli.php'),
+            [
+                '--db_server=' . $target->database->host,
+                '--db_user=' . $target->database->user,
+                '--db_password=' . $target->database->password,
+                '--db_name=' . $target->database->name,
+                '--firstname=' . $options['prestashop_account_first_name'],
+                '--lastname=' . $options['prestashop_account_last_name'],
+                '--password=' . $options['prestashop_account_password'],
+                '--email=' . $options['prestashop_account_email'],
+                '--domain=' . $target->domain->domainName,
+                '--ssl=' . $target->domain->isSslEnabled ? 1 : 0,
+            ],
+        );
 
-		$php_version = $this->appcontext->getSupportedPHP(
-			$this->config["server"]["php"]["supported"],
-		);
-
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				quoteshellarg($this->getDocRoot("/install/index_cli.php")),
-				"--db_server=" . quoteshellarg($options["database_host"]),
-				"--db_user=" .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_user"]),
-				"--db_password=" . quoteshellarg($options["database_password"]),
-				"--db_name=" .
-				quoteshellarg($this->appcontext->user() . "_" . $options["database_name"]),
-				"--firstname=" . quoteshellarg($options["prestashop_account_first_name"]),
-				"--lastname=" . quoteshellarg($options["prestashop_account_last_name"]),
-				"--password=" . quoteshellarg($options["prestashop_account_password"]),
-				"--email=" . quoteshellarg($options["prestashop_account_email"]),
-				"--domain=" . quoteshellarg($this->domain),
-				"--ssl=" . (int) $ssl_enabled,
-			],
-			$status,
-		);
-
-		// remove install folder
-		$this->appcontext->runUser("v-delete-fs-directory", [$this->getDocRoot("/install")]);
-		$this->cleanup();
-		return $status->code === 0;
-	}
+        // remove install folder
+        $this->appcontext->deleteDirectory($target->getDocRoot('/install'));
+        $this->appcontext->deleteDirectory($target->getDocRoot($extractDirectory));
+    }
 }

+ 0 - 37
web/src/app/WebApp/Installers/Resources/ComposerResource.php

@@ -1,37 +0,0 @@
-<?php
-
-namespace Hestia\WebApp\Installers\Resources;
-
-use Hestia\System\HestiaApp;
-
-class ComposerResource {
-	private $project;
-	private $folder;
-	private $appcontext;
-
-	public function __construct(HestiaApp $appcontext, $data, $destination) {
-		$this->folder = dirname($destination);
-		$this->project = basename($destination);
-		$this->appcontext = $appcontext;
-		if (empty($data["version"])) {
-			$data["version"] = 2;
-		}
-
-		$this->appcontext->runComposer(
-			[
-				"create-project",
-				"--no-progress",
-				"--prefer-dist",
-				$data["src"],
-				"-d " . $this->folder,
-				$this->project,
-			],
-			$status,
-			$data,
-		);
-
-		if ($status->code !== 0) {
-			throw new \Exception("Error fetching Composer resource: " . $status->text);
-		}
-	}
-}

+ 0 - 28
web/src/app/WebApp/Installers/Resources/WpResource.php

@@ -1,28 +0,0 @@
-<?php
-
-namespace Hestia\WebApp\Installers\Resources;
-
-use Hestia\System\HestiaApp;
-
-class WpResource {
-	private $appcontext;
-	private $options;
-
-	public function __construct(HestiaApp $appcontext, $data, $destination, $options, $appinfo) {
-		$this->appcontext = $appcontext;
-		$this->appcontext->runWp(
-			[
-				"core",
-				"download",
-				"--locale=" . $options["language"],
-				"--version=" . $appinfo["version"],
-				"--path=" . $destination,
-			],
-			$status,
-		);
-
-		if ($status->code !== 0) {
-			throw new \Exception("Error fetching WP resource: " . $status->text);
-		}
-	}
-}

+ 4 - 0
web/src/app/WebApp/Installers/Symfony/.htaccess

@@ -0,0 +1,4 @@
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteRule ^(.*)$ public/$1 [QSA,L]
+</IfModule>

+ 52 - 57
web/src/app/WebApp/Installers/Symfony/SymfonySetup.php

@@ -1,62 +1,57 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Symfony;
-
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-
-class SymfonySetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Symfony",
-		"group" => "framework",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "symfony-thumb.png",
-	];
-
-	protected $appname = "symfony";
-
-	protected $config = [
-		"form" => [],
-		"database" => true,
-		"resources" => [
-			"composer" => ["src" => "symfony/website-skeleton", "dst" => "/"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "symfony4-5",
-			],
-			"php" => [
-				"supported" => ["8.2", "8.3", "8.4"],
-			],
-		],
-	];
+declare(strict_types=1);
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		$result = null;
-
-		$htaccess_rewrite = '
-<IfModule mod_rewrite.c>
-		RewriteEngine On
-		RewriteRule ^(.*)$ public/$1 [L]
-</IfModule>';
-
-		$this->appcontext->runComposer(
-			["config", "-d " . $this->getDocRoot(), "extra.symfony.allow-contrib", "true"],
-			$result,
-		);
-		$this->appcontext->runComposer(
-			["require", "-d " . $this->getDocRoot(), "symfony/apache-pack"],
-			$result,
-		);
-
-		$tmp_configpath = $this->saveTempFile($htaccess_rewrite);
-		$this->appcontext->runUser(
-			"v-move-fs-file",
-			[$tmp_configpath, $this->getDocRoot(".htaccess")],
-			$result,
-		);
+namespace Hestia\WebApp\Installers\Symfony;
 
-		return $result->code === 0;
-	}
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+use function file_get_contents;
+
+class SymfonySetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Symfony',
+        'group' => 'framework',
+        'version' => 'latest',
+        'thumbnail' => 'symfony-thumb.png',
+    ];
+
+    protected array $config = [
+        'form' => [],
+        'database' => false,
+        'resources' => [
+            'composer' => ['src' => 'symfony/website-skeleton', 'dst' => '/'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'symfony4-5',
+            ],
+            'php' => [
+                'supported' => ['8.2', '8.3', '8.4'],
+            ],
+        ],
+    ];
+
+    protected function setupApplication(InstallationTarget $target, array $options = null): void
+    {
+        $this->appcontext->createFile(
+            $target->getDocRoot('.htaccess'),
+            file_get_contents(__DIR__ . '/.htaccess'),
+        );
+
+        $this->appcontext->runComposer($options['php_version'], [
+            'config',
+            '-d',
+            $target->getDocRoot(),
+            'extra.symfony.allow-contrib',
+            'true',
+        ]);
+        $this->appcontext->runComposer($options['php_version'], [
+            'require',
+            '-d',
+            $target->getDocRoot(),
+            'symfony/apache-pack',
+        ]);
+    }
 }

+ 56 - 89
web/src/app/WebApp/Installers/ThirtyBees/ThirtyBeesSetup.php

@@ -1,98 +1,65 @@
 <?php
 
-namespace Hestia\WebApp\Installers\ThirtyBees;
-
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-
-class ThirtyBeesSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "ThirtyBees",
-		"group" => "ecommerce",
-		"enabled" => true,
-		"version" => "1.5.1",
-		"thumbnail" => "thirtybees-thumb.png",
-	];
-
-	protected $appname = "thirtybees";
-	protected $extractsubdir = ".";
-
-	protected $config = [
-		"form" => [
-			"thirtybees_account_first_name" => ["value" => "John"],
-			"thirtybees_account_last_name" => ["value" => "Doe"],
-			"thirtybees_account_email" => "text",
-			"thirtybees_account_password" => "password",
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" =>
-					"https://github.com/thirtybees/thirtybees/releases/download/1.5.1/thirtybees-v1.5.1-php7.4.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "prestashop",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0"],
-			],
-		],
-	];
+declare(strict_types=1);
 
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
-
-		try {
-			$this->retrieveResources($options);
-		} catch (\Exception $e) {
-			// Registrar el error pero continuar con la instalación
-			error_log("Error durante la descarga o extracción: " . $e->getMessage());
-		}
-
-		// Verificación del estado SSL del dominio
-		$status = null;
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
-
-		if ($status->code !== 0) {
-			throw new \Exception("No se puede listar el dominio");
-		}
+namespace Hestia\WebApp\Installers\ThirtyBees;
 
-		$ssl_enabled = $status->json[$this->domain]["SSL"] == "no" ? 0 : 1;
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
 
-		$php_version = $this->appcontext->getSupportedPHP(
-			$this->config["server"]["php"]["supported"],
-		);
+class ThirtyBeesSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'ThirtyBees',
+        'group' => 'ecommerce',
+        'version' => '1.5.1',
+        'thumbnail' => 'thirtybees-thumb.png',
+    ];
 
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				$this->getDocRoot("/install/index_cli.php"),
-				"--db_user=" . $this->appcontext->user() . "_" . $options["database_user"],
-				"--db_password=" . $options["database_password"],
-				"--db_name=" . $this->appcontext->user() . "_" . $options["database_name"],
-				"--firstname=" . $options["thirtybees_account_first_name"],
-				"--lastname=" . $options["thirtybees_account_last_name"],
-				"--password=" . $options["thirtybees_account_password"],
-				"--email=" . $options["thirtybees_account_email"],
-				"--domain=" . $this->domain,
-				"--ssl=" . $ssl_enabled,
-			],
-			$status,
-		);
+    protected array $config = [
+        'form' => [
+            'thirtybees_account_first_name' => ['value' => ''],
+            'thirtybees_account_last_name' => ['value' => ''],
+            'thirtybees_account_email' => 'text',
+            'thirtybees_account_password' => 'password',
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' =>
+                    'https://github.com/thirtybees/thirtybees/' .
+                    'releases/download/1.6.0/thirtybees-v1.6.0-php7.4.zip',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'prestashop',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
 
-		// Delete install directory
-		$installDir = $this->getDocRoot() . "/install";
-		if (is_dir($installDir)) {
-			$this->appcontext->runUser("v-delete-fs-directory", [$installDir]);
-		} else {
-			error_log(
-				"No se pudo encontrar el directorio de instalación para eliminar: " . $installDir,
-			);
-		}
+    protected function setupApplication(InstallationTarget $target, array $options = null): void
+    {
+        $this->appcontext->runPHP(
+            $options['php_version'],
+            $target->getDocRoot('/install/index_cli.php'),
+            [
+                '--db_server=' . $target->database->host,
+                '--db_user=' . $target->database->user,
+                '--db_password=' . $target->database->password,
+                '--db_name=' . $target->database->name,
+                '--firstname=' . $options['thirtybees_account_first_name'],
+                '--lastname=' . $options['thirtybees_account_last_name'],
+                '--password=' . $options['thirtybees_account_password'],
+                '--email=' . $options['thirtybees_account_email'],
+                '--domain=' . $target->domain->domainName,
+                '--ssl=' . $target->domain->isSslEnabled,
+            ],
+        );
 
-		return $status->code === 0;
-	}
+        $this->appcontext->deleteDirectory($target->getDocRoot('/install'));
+    }
 }

+ 49 - 60
web/src/app/WebApp/Installers/Vvveb/VvvebSetup.php

@@ -1,65 +1,54 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Vvveb;
-
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-
-class VvvebSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "Vvveb",
-		"group" => "cms",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "vvveb-symbol.svg",
-	];
-
-	protected $appname = "vvveb";
+declare(strict_types=1);
 
-	protected $config = [
-		"form" => [
-			"vvveb_account_username" => ["value" => "admin"],
-			"vvveb_account_email" => "text",
-			"vvveb_account_password" => "password",
-		],
-		"database" => true,
-		"resources" => [
-			"archive" => [
-				"src" => "https://www.vvveb.com/latest.zip",
-			],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "vvveb",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"],
-			],
-		],
-	];
-
-	public function install(array $options = null): bool {
-		parent::install($options);
-		parent::setup($options);
-
-		$this->appcontext->runUser(
-			"v-run-cli-cmd",
-			[
-				"/usr/bin/php" . $options["php_version"],
-				$this->getDocRoot("/cli.php"),
-				"install",
-				"host=" . addcslashes("localhost", "\\'"),
-				"user=" . $this->appcontext->user() . "_" . $options["database_user"],
-				"password=" . $options["database_password"],
-				"database=" . $this->appcontext->user() . "_" . $options["database_name"],
-				"admin[user]=" . $options["vvveb_account_username"],
-				"admin[password]=" . $options["vvveb_account_password"],
-				"admin[email]=" . $options["vvveb_account_email"],
-			],
-			$status,
-		);
-
-		$this->cleanup();
+namespace Hestia\WebApp\Installers\Vvveb;
 
-		return $status->code === 0;
-	}
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+
+class VvvebSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'Vvveb',
+        'group' => 'cms',
+        'version' => 'latest',
+        'thumbnail' => 'vvveb-symbol.svg',
+    ];
+
+    protected array $config = [
+        'form' => [
+            'vvveb_account_username' => ['value' => 'admin'],
+            'vvveb_account_email' => 'text',
+            'vvveb_account_password' => 'password',
+        ],
+        'database' => true,
+        'resources' => [
+            'archive' => [
+                'src' => 'https://www.vvveb.com/latest.zip',
+            ],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'vvveb',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'],
+            ],
+        ],
+    ];
+
+    protected function setupApplication(InstallationTarget $target, array $options = null): void
+    {
+        $this->appcontext->runPHP($options['php_version'], $target->getDocRoot('/cli.php'), [
+            'install',
+            'host=' . $target->database->host,
+            'user=' . $target->database->user,
+            'password=' . $target->database->password,
+            'database=' . $target->database->name,
+            'admin[user]=' . $options['vvveb_account_username'],
+            'admin[password]=' . $options['vvveb_account_password'],
+            'admin[email]=' . $options['vvveb_account_email'],
+        ]);
+    }
 }

+ 4 - 4
web/src/app/WebApp/Installers/Vvveb/vvveb-symbol.svg

@@ -1,5 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 79.375 79.375">
-    <path d="M44.121-13.047h-1.764l-7.829-15.657h2.406l6.257 12.834 5.807-12.834h2.31l-7.187 15.657" style="fill:#50b450;fill-opacity:1;stroke:none;"  transform="translate(-12.79 59.978)"/>
-    <path style="fill:#444;fill-opacity:1;stroke:none;" d="M62.81-29.287c-2.589 0-4.632.781-6.13 2.342-1.497 1.562-2.245 3.53-2.245 5.903 0 2.503.664 4.46 1.99 5.872 1.433 1.518 3.507 2.277 6.223 2.277 1.84 0 3.444-.364 4.813-1.091 1.54-.813 2.662-2.32 3.368-4.524h-2.181c-.963 2.46-2.94 3.69-5.935 3.69-3.85 0-5.914-1.818-6.192-5.454-.007-.638-.03-1.303.063-1.924.235-1.647.91-2.92 2.022-3.818 1.112-.899 2.514-1.35 4.203-1.35 1.883 0 3.369.536 4.46 1.606.94.92 1.474 2.107 1.603 3.562l-3.634.065v1.903l5.945-.076v-1.316c0-2.353-.771-4.213-2.311-5.582-1.519-1.39-3.539-2.085-6.063-2.085zM74.322-31.915v11.51a8.552 8.552 0 0 0 2.119 4.854v-.004c.097.11.202.207.303.31.305.308.625.6.973.856v.004c1.334.972 2.94 1.46 4.82 1.46 2.63 0 4.684-.792 6.16-2.375 1.39-1.476 2.086-3.442 2.086-5.902 0-2.481-.686-4.48-2.055-6-1.475-1.625-3.528-2.437-6.159-2.437-2.63 0-4.673.843-6.128 2.533v-4.81zm8.376 4.233c1.946 0 3.454.619 4.523 1.86.984 1.133 1.476 2.662 1.476 4.587 0 1.882-.504 3.39-1.509 4.524-1.048 1.219-2.534 1.83-4.46 1.83-1.903 0-3.432-.59-4.587-1.766-1.134-1.177-1.7-2.673-1.7-4.49 0-2.033.514-3.626 1.54-4.781 1.048-1.177 2.62-1.764 4.717-1.764z" transform="translate(-12.79 59.978)"/>
-    <path d="M34.003-12.863H32.24L24.41-28.52h2.406l6.257 12.833s1.426 2.546.93 2.824M23.852-12.752h-1.764l-7.829-15.657h2.407l6.256 12.834s1.426 2.545.93 2.823" style="fill:#50b450;" transform="translate(-12.79 59.978)"/>
+<svg xmlns='http://www.w3.org/2000/svg' width='300' height='300' viewBox='0 0 79.375 79.375'>
+    <path d='M44.121-13.047h-1.764l-7.829-15.657h2.406l6.257 12.834 5.807-12.834h2.31l-7.187 15.657' style='fill:#50b450;fill-opacity:1;stroke:none;'  transform='translate(-12.79 59.978)'/>
+    <path style='fill:#444;fill-opacity:1;stroke:none;' d='M62.81-29.287c-2.589 0-4.632.781-6.13 2.342-1.497 1.562-2.245 3.53-2.245 5.903 0 2.503.664 4.46 1.99 5.872 1.433 1.518 3.507 2.277 6.223 2.277 1.84 0 3.444-.364 4.813-1.091 1.54-.813 2.662-2.32 3.368-4.524h-2.181c-.963 2.46-2.94 3.69-5.935 3.69-3.85 0-5.914-1.818-6.192-5.454-.007-.638-.03-1.303.063-1.924.235-1.647.91-2.92 2.022-3.818 1.112-.899 2.514-1.35 4.203-1.35 1.883 0 3.369.536 4.46 1.606.94.92 1.474 2.107 1.603 3.562l-3.634.065v1.903l5.945-.076v-1.316c0-2.353-.771-4.213-2.311-5.582-1.519-1.39-3.539-2.085-6.063-2.085zM74.322-31.915v11.51a8.552 8.552 0 0 0 2.119 4.854v-.004c.097.11.202.207.303.31.305.308.625.6.973.856v.004c1.334.972 2.94 1.46 4.82 1.46 2.63 0 4.684-.792 6.16-2.375 1.39-1.476 2.086-3.442 2.086-5.902 0-2.481-.686-4.48-2.055-6-1.475-1.625-3.528-2.437-6.159-2.437-2.63 0-4.673.843-6.128 2.533v-4.81zm8.376 4.233c1.946 0 3.454.619 4.523 1.86.984 1.133 1.476 2.662 1.476 4.587 0 1.882-.504 3.39-1.509 4.524-1.048 1.219-2.534 1.83-4.46 1.83-1.903 0-3.432-.59-4.587-1.766-1.134-1.177-1.7-2.673-1.7-4.49 0-2.033.514-3.626 1.54-4.781 1.048-1.177 2.62-1.764 4.717-1.764z' transform='translate(-12.79 59.978)'/>
+    <path d='M34.003-12.863H32.24L24.41-28.52h2.406l6.257 12.833s1.426 2.546.93 2.824M23.852-12.752h-1.764l-7.829-15.657h2.407l6.256 12.834s1.426 2.545.93 2.823' style='fill:#50b450;' transform='translate(-12.79 59.978)'/>
 </svg>

+ 105 - 260
web/src/app/WebApp/Installers/WordPress/WordPressSetup.php

@@ -1,265 +1,110 @@
 <?php
 
-namespace Hestia\WebApp\Installers\Wordpress;
+declare(strict_types=1);
 
-use Hestia\System\Util;
-use Hestia\WebApp\Installers\BaseSetup as BaseSetup;
-use function Hestiacp\quoteshellarg\quoteshellarg;
-
-class WordpressSetup extends BaseSetup {
-	protected $appInfo = [
-		"name" => "WordPress",
-		"group" => "cms",
-		"enabled" => true,
-		"version" => "latest",
-		"thumbnail" => "wp-thumb.png",
-	];
-
-	protected $appname = "wordpress";
-	protected $config = [
-		"form" => [
-			//'protocol' => [
-			//  'type' => 'select',
-			//  'options' => ['http','https'],
-			//],
-
-			"site_name" => ["type" => "text", "value" => "WordPress Blog"],
-			"username" => ["value" => "wpadmin"],
-			"email" => "text",
-			"password" => "password",
-			"install_directory" => ["type" => "text", "value" => "/", "placeholder" => "/"],
-			"language" => [
-				"type" => "select",
-				"value" => "en_US",
-				"options" => [
-					"cs_CZ" => "Czech",
-					"de_DE" => "German",
-					"es_ES" => "Spanish",
-					"en_US" => "English",
-					"fr_FR" => "French",
-					"hu_HU" => "Hungarian",
-					"it_IT" => "Italian",
-					"ja" => "Japanese",
-					"nl_NL" => "Dutch",
-					"pt_PT" => "Portuguese",
-					"pt_BR" => "Portuguese (Brazil)",
-					"sk_SK" => "Slovak",
-					"sr_RS" => "Serbian",
-					"sv_SE" => "Swedish",
-					"tr_TR" => "Turkish",
-					"ru_RU" => "Russian",
-					"uk" => "Ukrainian",
-					"zh-CN" => "Simplified Chinese (China)",
-					"zh_TW" => "Traditional Chinese",
-				],
-			],
-		],
-		"database" => true,
-		"resources" => [
-			"wp" => ["src" => "https://wordpress.org/latest.tar.gz"],
-		],
-		"server" => [
-			"nginx" => [
-				"template" => "wordpress",
-			],
-			"php" => [
-				"supported" => ["7.4", "8.0", "8.1", "8.2", "8.3"],
-			],
-		],
-	];
-
-	public function install(array $options = null) {
-		parent::setAppDirInstall($options["install_directory"]);
-		parent::install($options);
-		parent::setup($options);
-
-		$this->appcontext->runUser(
-			"v-open-fs-file",
-			[$this->getDocRoot("wp-config-sample.php")],
-			$result,
-		);
-		foreach ($result->raw as $line_num => $line) {
-			if (str_starts_with($line, '$table_prefix =')) {
-				$result->raw[$line_num] = sprintf(
-					"\$table_prefix = %s;\r\n",
-					var_export("wp_" . Util::generate_string(5, false) . "_", true),
-				);
-				continue;
-			}
-			if (!preg_match('/^define\(\s*\'([A-Z_]+)\',([ ]+)/', $line, $match)) {
-				continue;
-			}
-			$constant = $match[1];
-			$padding = $match[2];
-			switch ($constant) {
-				case "DB_NAME":
-					$result->raw[$line_num] =
-						"define( " .
-						var_export($constant, true) .
-						"," .
-						str_repeat(" ", strlen($padding)) .
-						var_export(
-							$this->appcontext->user() . "_" . $options["database_name"],
-							true,
-						) .
-						" );";
-					break;
-				case "DB_USER":
-					$result->raw[$line_num] =
-						"define( " .
-						var_export($constant, true) .
-						"," .
-						str_repeat(" ", strlen($padding)) .
-						var_export(
-							$this->appcontext->user() . "_" . $options["database_user"],
-							true,
-						) .
-						" );";
-					break;
-				case "DB_PASSWORD":
-					$result->raw[$line_num] =
-						"define( " .
-						var_export($constant, true) .
-						"," .
-						str_repeat(" ", strlen($padding)) .
-						var_export($options["database_password"], true) .
-						" );";
-					break;
-				case "DB_HOST":
-					$result->raw[$line_num] =
-						"define( " .
-						var_export($constant, true) .
-						"," .
-						str_repeat(" ", strlen($padding)) .
-						var_export($options["database_host"], true) .
-						" );";
-					break;
-				case "DB_CHARSET":
-					$result->raw[$line_num] =
-						"define( " .
-						var_export($constant, true) .
-						"," .
-						str_repeat(" ", strlen($padding)) .
-						var_export("utf8mb4", true) .
-						" );";
-
-					break;
-				case "AUTH_KEY":
-				case "SECURE_AUTH_KEY":
-				case "LOGGED_IN_KEY":
-				case "NONCE_KEY":
-				case "AUTH_SALT":
-				case "SECURE_AUTH_SALT":
-				case "LOGGED_IN_SALT":
-				case "NONCE_SALT":
-					$result->raw[$line_num] =
-						"define( " .
-						var_export($constant, true) .
-						"," .
-						str_repeat(" ", strlen($padding)) .
-						var_export(Util::generate_string(64), true) .
-						" );";
-					break;
-			}
-		}
-
-		$tmp_configpath = $this->saveTempFile(implode("\r\n", $result->raw));
+namespace Hestia\WebApp\Installers\WordPress;
 
-		if (
-			!$this->appcontext->runUser(
-				"v-move-fs-file",
-				[$tmp_configpath, $this->getDocRoot("wp-config.php")],
-				$result,
-			)
-		) {
-			throw new \Exception(
-				"Error installing config file in: " .
-					$tmp_configpath .
-					" to:" .
-					$this->getDocRoot("wp-config.php") .
-					$result->text,
-			);
-		}
-
-		$this->appcontext->downloadUrl(
-			"https://raw.githubusercontent.com/roots/wp-password-bcrypt/master/wp-password-bcrypt.php",
-			null,
-			$plugin_output,
-		);
-		$this->appcontext->runUser(
-			"v-add-fs-directory",
-			[$this->getDocRoot("wp-content/mu-plugins/")],
-			$result,
-		);
-		if (
-			!$this->appcontext->runUser(
-				"v-copy-fs-file",
-				[
-					$plugin_output->file,
-					$this->getDocRoot("wp-content/mu-plugins/wp-password-bcrypt.php"),
-				],
-				$result,
-			)
-		) {
-			throw new \Exception(
-				"Error installing wp-password-bcrypt file in: " .
-					$plugin_output->file .
-					" to:" .
-					$this->getDocRoot("wp-content/mu-plugins/wp-password-bcrypt.php") .
-					$result->text,
-			);
-		}
-
-		$this->appcontext->runUser("v-list-web-domain", [$this->domain, "json"], $status);
-
-		$sslEnabled = $status->json[$this->domain]["SSL"] == "no" ? 0 : 1;
-		$webDomain = ($sslEnabled ? "https://" : "http://") . $this->domain . "/";
-		$webPort = $sslEnabled ? "443" : "80";
-
-		if (substr($options["install_directory"], 0, 1) == "/") {
-			$options["install_directory"] = substr($options["install_directory"], 1);
-		}
-		if (substr($options["install_directory"], -1, 1) == "/") {
-			$options["install_directory"] = substr(
-				$options["install_directory"],
-				0,
-				strlen($options["install_directory"]) - 1,
-			);
-		}
-		$cmd = implode(" ", [
-			"/usr/bin/curl",
-			"--location",
-			"--post301",
-			"--insecure",
-			"--resolve " .
-			quoteshellarg(
-				$this->domain . ":$webPort:" . $this->appcontext->getWebDomainIp($this->domain),
-			),
-			quoteshellarg(
-				$webDomain . $options["install_directory"] . "/wp-admin/install.php?step=2",
-			),
-			"--data-binary " .
-			quoteshellarg(
-				http_build_query([
-					"weblog_title" => $options["site_name"],
-					"user_name" => $options["username"],
-					"admin_password" => $options["password"],
-					"admin_password2" => $options["password"],
-					"admin_email" => $options["email"],
-				]),
-			),
-		]);
-
-		exec($cmd, $output, $return_var);
-
-		if (
-			strpos(implode(PHP_EOL, $output), "Error establishing a database connection") !== false
-		) {
-			throw new \Exception("Error establishing a database connection");
-		}
-		if ($return_var > 0) {
-			throw new \Exception(implode(PHP_EOL, $output));
-		}
-		return $return_var === 0;
-	}
+use Hestia\System\Util;
+use Hestia\WebApp\BaseSetup;
+use Hestia\WebApp\InstallationTarget\InstallationTarget;
+
+use function file_get_contents;
+
+class WordPressSetup extends BaseSetup
+{
+    protected array $info = [
+        'name' => 'WordPress',
+        'group' => 'cms',
+        'version' => 'latest',
+        'thumbnail' => 'wp-thumb.png',
+    ];
+
+    protected array $config = [
+        'form' => [
+            'site_name' => ['type' => 'text', 'value' => 'WordPress Blog'],
+            'username' => ['value' => 'wpadmin'],
+            'email' => 'text',
+            'password' => 'password',
+            'language' => [
+                'type' => 'select',
+                'value' => 'en_US',
+                'options' => [
+                    'cs_CZ' => 'Czech',
+                    'de_DE' => 'German',
+                    'es_ES' => 'Spanish',
+                    'en_US' => 'English',
+                    'fr_FR' => 'French',
+                    'hu_HU' => 'Hungarian',
+                    'it_IT' => 'Italian',
+                    'ja' => 'Japanese',
+                    'nl_NL' => 'Dutch',
+                    'pt_PT' => 'Portuguese',
+                    'pt_BR' => 'Portuguese (Brazil)',
+                    'sk_SK' => 'Slovak',
+                    'sr_RS' => 'Serbian',
+                    'sv_SE' => 'Swedish',
+                    'tr_TR' => 'Turkish',
+                    'ru_RU' => 'Russian',
+                    'uk' => 'Ukrainian',
+                    'zh-CN' => 'Simplified Chinese (China)',
+                    'zh_TW' => 'Traditional Chinese',
+                ],
+            ],
+        ],
+        'database' => true,
+        'resources' => [
+            'wp' => ['src' => 'https://wordpress.org/latest.tar.gz'],
+        ],
+        'server' => [
+            'nginx' => [
+                'template' => 'wordpress',
+            ],
+            'php' => [
+                'supported' => ['7.4', '8.0', '8.1', '8.2', '8.3'],
+            ],
+        ],
+    ];
+
+    protected function setupApplication(InstallationTarget $target, array $options = null): void
+    {
+        $this->appcontext->runWp($options['php_version'], [
+            'config',
+            'create',
+            '--dbname=' . $target->database->name,
+            '--dbuser=' . $target->database->user,
+            '--dbpass=' . $target->database->password,
+            '--dbhost=' . $target->database->host,
+            '--dbprefix=' . 'wp_' . Util::generateString(5, false) . '_',
+            '--dbcharset=utf8mb4',
+            '--locale=' . $options['language'],
+            '--path=' . $target->getDocRoot(),
+        ]);
+
+        $wpPasswordBcryptContents = file_get_contents(
+            'https://raw.githubusercontent.com/roots/wp-password-bcrypt/master/wp-password-bcrypt.php',
+        );
+
+        $this->appcontext->addDirectory($target->getDocRoot('wp-content/mu-plugins/'));
+
+        $this->appcontext->createFile(
+            $target->getDocRoot('wp-content/mu-plugins/wp-password-bcrypt.php'),
+            $wpPasswordBcryptContents,
+        );
+
+        // WordPress CLI seems to have a bug that when site name has a space it will be seen as an
+        // extra argument. Even when properly escaped. For now just install with install.php
+        $this->appcontext->sendPostRequest(
+            $target->getUrl() .
+                '/' .
+                $options['install_directory'] .
+                '/wp-admin/install.php?step=2',
+            [
+                'weblog_title' => $options['site_name'],
+                'user_name' => $options['username'],
+                'admin_password' => $options['password'],
+                'admin_password2' => $options['password'],
+                'admin_email' => $options['email'],
+            ],
+        );
+    }
 }

+ 6 - 4
web/src/composer.json

@@ -4,10 +4,12 @@
             "Hestia\\": "app/"
         }
     },
-    "require-dev": {
-        "filp/whoops": "2.17.0"
-    },
     "require": {
-        "symfony/console": "^7.2.1"
+        "symfony/console": "^7.2.1",
+        "composer": "*",
+        "symfony/process": "^7.2"
+    },
+    "require-dev": {
+        "squizlabs/php_codesniffer": "^3.11"
     }
 }

+ 124 - 98
web/src/composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "89da4dc24afd20ffd2d04271dc677d4c",
+    "content-hash": "029b3e4de61752791ca87d90ad37d700",
     "packages": [
         {
             "name": "psr/container",
@@ -171,12 +171,12 @@
             },
             "type": "library",
             "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
                 "branch-alias": {
                     "dev-main": "3.5-dev"
-                },
-                "thanks": {
-                    "name": "symfony/contracts",
-                    "url": "https://github.com/symfony/contracts"
                 }
             },
             "autoload": {
@@ -537,6 +537,67 @@
             ],
             "time": "2024-09-09T11:45:10+00:00"
         },
+        {
+            "name": "symfony/process",
+            "version": "v7.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/process.git",
+                "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e",
+                "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Process\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Executes commands in sub-processes",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/process/tree/v7.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-06T14:24:19+00:00"
+        },
         {
             "name": "symfony/service-contracts",
             "version": "v3.5.1",
@@ -561,12 +622,12 @@
             },
             "type": "library",
             "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
                 "branch-alias": {
                     "dev-main": "3.5-dev"
-                },
-                "thanks": {
-                    "name": "symfony/contracts",
-                    "url": "https://github.com/symfony/contracts"
                 }
             },
             "autoload": {
@@ -710,133 +771,98 @@
     ],
     "packages-dev": [
         {
-            "name": "filp/whoops",
-            "version": "2.17.0",
+            "name": "squizlabs/php_codesniffer",
+            "version": "3.11.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/filp/whoops.git",
-                "reference": "075bc0c26631110584175de6523ab3f1652eb28e"
+                "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+                "reference": "ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/075bc0c26631110584175de6523ab3f1652eb28e",
-                "reference": "075bc0c26631110584175de6523ab3f1652eb28e",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10",
+                "reference": "ba05f990e79cbe69b9f35c8c1ac8dca7eecc3a10",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1 || ^8.0",
-                "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+                "ext-simplexml": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": ">=5.4.0"
             },
             "require-dev": {
-                "mockery/mockery": "^1.0",
-                "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
-                "symfony/var-dumper": "^4.0 || ^5.0"
-            },
-            "suggest": {
-                "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
-                "whoops/soap": "Formats errors as SOAP responses"
+                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
             },
+            "bin": [
+                "bin/phpcbf",
+                "bin/phpcs"
+            ],
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.7-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Whoops\\": "src/Whoops/"
+                    "dev-master": "3.x-dev"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "MIT"
+                "BSD-3-Clause"
             ],
             "authors": [
                 {
-                    "name": "Filipe Dobreira",
-                    "homepage": "https://github.com/filp",
-                    "role": "Developer"
+                    "name": "Greg Sherwood",
+                    "role": "Former lead"
+                },
+                {
+                    "name": "Juliette Reinders Folmer",
+                    "role": "Current lead"
+                },
+                {
+                    "name": "Contributors",
+                    "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
                 }
             ],
-            "description": "php error handling for cool kids",
-            "homepage": "https://filp.github.io/whoops/",
+            "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+            "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
             "keywords": [
-                "error",
-                "exception",
-                "handling",
-                "library",
-                "throwable",
-                "whoops"
+                "phpcs",
+                "standards",
+                "static analysis"
             ],
             "support": {
-                "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.17.0"
+                "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+                "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+                "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+                "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
             },
             "funding": [
                 {
-                    "url": "https://github.com/denis-sokolov",
+                    "url": "https://github.com/PHPCSStandards",
                     "type": "github"
-                }
-            ],
-            "time": "2025-01-25T12:00:00+00:00"
-        },
-        {
-            "name": "psr/log",
-            "version": "3.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/log.git",
-                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
-                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.0.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Log\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
+                },
                 {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
+                    "url": "https://github.com/jrfnl",
+                    "type": "github"
+                },
+                {
+                    "url": "https://opencollective.com/php_codesniffer",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://thanks.dev/phpcsstandards",
+                    "type": "thanks_dev"
                 }
             ],
-            "description": "Common interface for logging libraries",
-            "homepage": "https://github.com/php-fig/log",
-            "keywords": [
-                "log",
-                "psr",
-                "psr-3"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/log/tree/3.0.2"
-            },
-            "time": "2024-09-11T13:17:53+00:00"
+            "time": "2025-01-23T17:04:15+00:00"
         }
     ],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": {},
+    "stability-flags": [],
     "prefer-stable": false,
     "prefer-lowest": false,
-    "platform": {},
-    "platform-dev": {},
+    "platform": {
+        "composer": "*"
+    },
+    "platform-dev": [],
     "plugin-api-version": "2.6.0"
 }

+ 1 - 15
web/src/init.php

@@ -2,18 +2,4 @@
 
 declare(strict_types=1);
 
-$loader = require_once __DIR__ .
-	DIRECTORY_SEPARATOR .
-	"vendor" .
-	DIRECTORY_SEPARATOR .
-	"autoload.php";
-
-#
-# Dev-debugging: Html error handler
-# https://github.com/filp/whoops
-# install:
-# cd $HESTIA/web/src; composer require filp/whoops
-#
-# $whoops = new \Whoops\Run;
-# $whoops->prependHandler(new \Whoops\Handler\PrettyPageHandler);
-# $whoops->register();
+require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';

+ 5 - 5
web/templates/pages/list_webapps.php

@@ -18,14 +18,14 @@
 		<div class="cards">
 			<!-- List available web apps -->
 			<?php foreach ($v_web_apps as $webapp): ?>
-				<div class="card <?= $webapp["enabled"] ? "" : "disabled" ?>">
+				<div class="card <?= $webapp->isInstallable() ? "" : "disabled" ?>">
 					<div class="card-thumb">
-						<img src="/src/app/WebApp/Installers/<?= $webapp["name"] ?>/<?= $webapp["thumbnail"] ?>" alt="<?= $webapp["name"] ?>">
+						<img src="/src/app/WebApp/Installers/<?= $webapp->name ?>/<?= $webapp->thumbnail ?>" alt="<?= $webapp->name ?>">
 					</div>
 					<div class="card-content">
-						<p class="card-title"><?= $webapp["name"] ?></p>
-						<p class="u-mb10"><?= _("Version") ?>: <?= $webapp["version"] ?></p>
-						<a class="button" href="/add/webapp/?app=<?= $webapp["name"] ?>&domain=<?= htmlentities($v_domain) ?>">
+						<p class="card-title"><?= $webapp->name ?></p>
+						<p class="u-mb10"><?= _("Version") ?>: <?= $webapp->version ?></p>
+						<a class="button" href="/add/webapp/?app=<?= $webapp->name ?>&domain=<?= htmlentities($v_domain) ?>">
 							<?= _("Setup") ?>
 						</a>
 					</div>

+ 2 - 2
web/templates/pages/setup_webapp.php

@@ -24,7 +24,7 @@
 			<input type="hidden" name="ok" value="true">
 
 			<div class="form-container">
-				<h1 class="u-mb20"><?= sprintf(_("Install %s"), $WebappInstaller->info()["name"]) ?></h1>
+				<h1 class="u-mb20"><?= sprintf(_("Install %s"), $WebappInstaller->applicationName()) ?></h1>
 				<?php show_alert_message($_SESSION); ?>
 				<?php if (!$WebappInstaller->isDomainRootClean()) { ?>
 					<div class="alert alert-info u-mb10" role="alert">
@@ -37,7 +37,7 @@
 					</div>
 				<?php } ?>
 				<?php foreach ($WebappInstaller->getOptions() as $form_name => $form_control) {
-					$field_name = $WebappInstaller->formNs() . "_" . $form_name;
+					$field_name = $WebappInstaller->formNamespace() . $form_name;
 					$field_type = $form_control;
 					$field_value = "";
 					$field_label =

Някои файлове не бяха показани, защото твърде много файлове са промени