name : ServerconfigmakerModel.php
<?php
/**
 * @package   admintools
 * @copyright Copyright (c)2010-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace Akeeba\Component\AdminTools\Administrator\Model;

defined('_JEXEC') or die;

use Akeeba\Component\AdminTools\Administrator\Controller\ControlpanelController;
use Akeeba\Component\AdminTools\Administrator\Controller\ServerconfigmakerController;
use Akeeba\Component\AdminTools\Administrator\Helper\ServerTechnology;
use Akeeba\Component\AdminTools\Administrator\Helper\Storage;
use Akeeba\Component\AdminTools\Administrator\Mixin\RunPluginsTrait;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\Filesystem\File;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Form\FormFactoryAwareInterface;
use Joomla\CMS\Form\FormFactoryAwareTrait;
use Joomla\CMS\Form\FormFactoryInterface;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\MVC\Model\FormBehaviorTrait;
use Joomla\CMS\MVC\Model\FormModelInterface;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Uri\Uri;

#[\AllowDynamicProperties]
abstract class ServerconfigmakerModel extends BaseDatabaseModel implements FormFactoryAwareInterface, FormModelInterface
{
	use FormBehaviorTrait;
	use FormFactoryAwareTrait;
	use RunPluginsTrait;

	/**
	 * The current configuration of this feature
	 *
	 * @var  object
	 */
	protected $config = null;

	/**
	 * The Admin Tools configuration key under which we'll save $config as a JSON-encoded string
	 *
	 * @var  string
	 */
	protected $configKey = '';

	/**
	 * The methods which are allowed to call the saveConfiguration method. Each line is in the format:
	 * Full\Class\Name::methodName
	 *
	 * @var  array
	 */
	protected $allowedCallersForSave = [];

	/**
	 * The methods which are allowed to call the writeConfigFile method. Each line is in the format:
	 * Full\Class\Name::methodName
	 *
	 * @var  array
	 */
	protected $allowedCallersForWrite = [];

	/**
	 * The methods which are allowed to call the makeConfigFile method. Each line is in the format:
	 * Full\Class\Name::methodName
	 *
	 * @var  array
	 */
	protected $allowedCallersForMake = [];

	/**
	 * These configuration keys are handled by subforms
	 *
	 * @var   string[]
	 * @since 7.0.0
	 */
	protected $subformConfigKeys = [
		'bepexdirs', 'fepexdirs', 'exceptionfiles', 'exceptiondirs', 'fullaccessdirs', 'httpsurls', 'proxy_ips',
	];

	/**
	 * Configuration keys which SHOULD contain arrays but MIGHT contain strings or objects
	 *
	 * @var   array
	 * @since 7.0.11
	 */
	protected $arrayConfigKeys = [
		'hoggeragents', 'httpsurls', 'exceptionfiles', 'exceptiondirs', 'fullaccessdirs',
		'exceptionfiles', 'exceptiondirs', 'fullaccessdirs', 'bepextypes', 'fepextypes',
		'restrictpip_custom',
	];

	/**
	 * Maps events to plugin groups.
	 *
	 * @var    array
	 * @since  7.0.0
	 */
	protected $events_map = null;

	/**
	 * The base name of the configuration file being saved by this feature, e.g. ".htaccess". The file is always saved
	 * in the site's root. Any old files under that name are renamed with a .admintools suffix.
	 *
	 * @var string
	 */
	protected $configFileName = '';

	/**
	 * Stores the version of the server engine.
	 *
	 * @var string
	 */
	protected $serverVersion = '';

	/**
	 * Should I put the config file in the public root?
	 *
	 * On Joomla 5 the public root (where the .htaccess and web.config file is written) may be different to the
	 * Joomla! installation root.
	 *
	 * For the .htaccess and web.config Maker we must always place the generated file in the public root, be it
	 * JPATH_ROOT or JPATH_PUBLIC since the server always looks for the file there.
	 *
	 * For the NginX Conf Maker it makes more sense to always use JPATH_ROOT. The server only looks for this file where
	 * the user explicitly tells it to, therefore it is safer to put in a non-public directory. The NginX Conf Maker
	 * sets this flag to false, communicating that.
	 *
	 * @var   bool
	 * @since 7.4.3
	 */
	protected $usePublicRoot = true;

	public function __construct($config = [], MVCFactoryInterface $factory = null, FormFactoryInterface $formFactory = null)
	{
		$config['events_map'] = $config['events_map'] ?? [];

		$this->events_map = array_merge(
			['validate' => 'content'],
			$config['events_map']
		);

		parent::__construct($config, $factory);

		$this->setFormFactory($formFactory);

		// Set up the allowed callers
		$classParts = explode('\\', get_class($this));
		$viewName   = substr(array_pop($classParts), 0, -5);

		$this->allowedCallersForSave = [
			ServerconfigmakerController::class . '::reset',
			ServerconfigmakerController::class . '::saveOrApply',
			'Akeeba\Component\AdminTools\Administrator\Controller\\' . $viewName . 'Controller::reset',
			'Akeeba\Component\AdminTools\Administrator\Controller\\' . $viewName . 'Controller::saveOrApply',
            'Akeeba\Component\AdminTools\Administrator\Model\HtaccessmakerModel::includePhpHandlers',
			QuickstartModel::class . '::applyPreferences',
			QuickstartModel::class . '::applyHtmaker',
			ExportimportModel::class . '::importData',
			'Akeeba\Component\AdminTools\Administrator\CliCommand\ServerConfiguration\ServerConfigurationSet::doExecute',
			'Akeeba\Component\AdminTools\Administrator\CliCommand\ServerConfiguration\ServerConfigurationMake::doExecute',
			ControlpanelModel::class . '::serverConfigUpdateFrontendDirectories',
		];

		$this->allowedCallersForWrite = [
			ServerconfigmakerController::class . '::apply',
			ServerconfigmakerController::class . '::saveOrApply',
			'Akeeba\Component\AdminTools\Administrator\Controller\\' . $viewName . 'Controller::apply',
			'Akeeba\Component\AdminTools\Administrator\Controller\\' . $viewName . 'Controller::saveOrApply',
			ControlpanelController::class . '::regenerateServerConfig',
			QuickstartModel::class . '::applyPreferences',
			QuickstartModel::class . '::applyHtmaker',
			'Akeeba\Component\AdminTools\Administrator\CliCommand\ServerConfiguration\ServerConfigurationSet::doExecute',
			'Akeeba\Component\AdminTools\Administrator\CliCommand\ServerConfiguration\ServerConfigurationMake::doExecute',
			ControlpanelModel::class . '::serverConfigUpdateFrontendDirectories',
		];

		$this->allowedCallersForMake = [
			ServerconfigmakerController::class . '::apply',
			ServerconfigmakerController::class . '::saveOrApply',
			'Akeeba\Component\AdminTools\Administrator\Controller\\' . $viewName . 'Controller::apply',
			'Akeeba\Component\AdminTools\Administrator\Controller\\' . $viewName . 'Controller::saveOrApply',
			ControlpanelController::class . '::regenerateServerConfig',
			ControlpanelModel::class . '::serverConfigEdited',
			self::class . '::writeConfigFile',
			QuickstartModel::class . '::applyPreferences',
			QuickstartModel::class . '::applyHtmaker',
			'Akeeba\Component\AdminTools\Administrator\View\\' . $viewName . '\HtmlView::onBeforeMain',
			'Akeeba\Component\AdminTools\Administrator\View\\' . $viewName . '\HtmlView::onBeforePreview',
			'Akeeba\Component\AdminTools\Administrator\CliCommand\ServerConfiguration\ServerConfigurationSet::doExecute',
			'Akeeba\Component\AdminTools\Administrator\CliCommand\ServerConfiguration\ServerConfigurationMake::doExecute',
			ControlpanelModel::class . '::getServerConfigInformation',
		];
	}

	/**
	 * Return the list of keys that are arrays
	 *
	 * @return array|string[]
	 */
	public function getArrayConfigKeys() : array
	{
		return $this->arrayConfigKeys;
	}

	/**
	 * Returns the list of configuration keys that are handled by a subform (ie they have array values)
	 *
	 * @return string[]
	 */
	public function getSubformConfigKeys(): array
	{
		return $this->subformConfigKeys;
	}

	/**
	 * Forces the server engine to a specific version
	 *
	 * @param string $version
	 *
	 * @return void
	 */
	public function setServerVersion(string $version)
	{
		$this->serverVersion = $version;
	}

	/**
	 * Method to validate the form data.
	 *
	 * @param   Form         $form   The form to validate against.
	 * @param   array        $data   The data to validate.
	 * @param   string|null  $group  The name of the field group to validate.
	 *
	 * @return  array|null  Array of filtered data if valid, null otherwise.
	 *
	 * @throws  Exception
	 * @see     FormRule
	 * @see     InputFilter
	 * @since   7.0.0
	 */
	public function validate(Form $form, array $data, ?string $group = null): ?array
	{
		// Include the plugins for the delete events.
		PluginHelper::importPlugin($this->events_map['validate']);

		$this->triggerPluginEvent('onContentBeforeValidateData', [$form, &$data]);

		// Filter and validate the form data.
		$data   = $form->filter($data);
		$return = $form->validate($data, $group);

		// Check for an error.
		if ($return instanceof Exception)
		{
			if (!method_exists($this, 'setError'))
			{
				throw new \RuntimeException($return->getMessage());
			}

			/** @noinspection PhpDeprecationInspection only called when deprecated code is not removed */
			$this->setError($return->getMessage());

			return null;
		}

		// Check the validation results.
		if ($return === false)
		{
			// Get the validation messages from the form.
			foreach ($form->getErrors() as $message)
			{
				if (!method_exists($this, 'setError'))
				{
					throw new \RuntimeException($message);
				}

				/** @noinspection PhpDeprecationInspection only called when deprecated code is not removed */
				$this->setError($message);
			}

			return null;
		}

		return $data;
	}

	/**
	 * @inheritDoc
	 */
	public function getForm($data = [], $loadData = true)
	{
		$form = $this->loadForm(
			'com_admintools.' . $this->getName(),
			$this->getName(),
			[
				'control'   => false,
				'load_data' => $loadData,
			]
		) ?: false;

		if (empty($form))
		{
			return false;
		}

		return $form;
	}

	/**
	 * Get the configuration file name.
	 *
	 * @param   bool  $absolutePath  Should I return the absolute path to it? Default: false (returns basename only).
	 *
	 * @return  string
	 */
	public function getConfigFileName(bool $absolutePath = false): string
	{
		if ($absolutePath)
		{
			$publicRoot = ($this->usePublicRoot && defined('JPATH_PUBLIC')) ? JPATH_PUBLIC : JPATH_ROOT;

			return $publicRoot . '/' . $this->configFileName;
		}

		return $this->configFileName;
	}

	/**
	 * Loads the feature's configuration from the database
	 *
	 * @param   bool  $withDefaults Should I load the defaults as well?
	 *
	 * @return  object
	 * @throws  Exception
	 */
	public function loadConfiguration(bool $withDefaults = true)
	{
		if (!is_null($this->config))
		{
			return $this->config;
		}

		$params      = Storage::getInstance();
		$savedConfig = $params->getValue($this->configKey, '') ?: '';

		if (function_exists('base64_encode') && function_exists('base64_encode'))
		{
			$savedConfig = @base64_decode($savedConfig) ?: '[]';
		}

		$savedConfig  = @json_decode($savedConfig, true) ?: [];

		if ($withDefaults)
		{
			$this->config = array_merge($this->getDefaultConfig(), $savedConfig ?: []);
		}
		else
		{
			$this->config = $savedConfig ?: [];
		}

		$this->handlePotentialArrayKeys($this->config);

		return $this->config;
	}

	/**
	 * Get the default configuration from the XML form's default value attributes
	 *
	 * @return  array
	 * @throws  Exception
	 * @since   7.0.0
	 */
	public function getDefaultConfig(): array
	{
		$form     = $this->getForm([], false);
		$defaults = [];

		foreach ($form->getFieldsets() as $fieldset)
		{
			foreach ($form->getFieldset($fieldset->name) as $name => $field)
			{
				$value = $field->getAttribute('default');

				if (in_array($name, $this->subformConfigKeys))
				{
					$value = array_map('trim', explode(',', $value));
				}

				$defaults[$name] = $value;
			}
		}

		/**
		 * Finally, I get to fill in some values which cannot be placed in a static XML configuration file.
		 * These are things like the domain name, relative path to the site's root etc.
		 */
		$app      = Factory::getApplication();
		$uri      = Uri::getInstance(Uri::root(false));
		$rootPath = Uri::root(true);

		$defaults['httphost']    = $uri->toString(['host', 'port']);
		$defaults['httpshost']   = $uri->toString(['host', 'port']);
		$defaults['rewritebase'] = $rootPath;

		return $defaults;
	}

	/**
	 * Save the configuration to the database
	 *
	 * @param   object|array  $data          The data to save
	 * @param   bool          $withDefaults  Should I get the default values before saving the configuration?
	 */
	public function saveConfiguration($data, bool $withDefaults = true)
	{
		// Make sure we are called by an expected caller
		ServerTechnology::checkCaller($this->allowedCallersForSave);

		$data            = is_object($data) ? (array) $data : $data;
		$defaultConfig   = ($withDefaults ? $this->getDefaultConfig() : []);
		$knownConfigKeys = array_keys($defaultConfig);
		$data            = array_filter($data, function ($key) use ($knownConfigKeys) {
			return in_array($key, $knownConfigKeys);
		}, ARRAY_FILTER_USE_KEY);
		$config          = array_merge($defaultConfig, $data);

		$this->handlePotentialArrayKeys($config);

		// Make sure nobody tried to add the php extension to the list of allowed extension
		$filterPhpExtension = function ($v) {
			return strtolower($v) != 'php';
		};

		$config['bepextypes'] = array_filter($config['bepextypes'], $filterPhpExtension);
		$config['fepextypes'] = array_filter($config['fepextypes'], $filterPhpExtension);

		$this->config = $config;

		$config = json_encode($this->config);

		// This keeps Registry from happily corrupting our data :@
		if (function_exists('base64_encode') && function_exists('base64_encode'))
		{
			$config = base64_encode($config);
		}

		$params = Storage::getInstance();

		$params->setValue($this->configKey, $config);
		$params->setValue('quickstart', 1);

		$params->save();
	}

	/**
	 * Create and return the configuration file's contents. This is the heart of these features.
	 *
	 * @return  string
	 */
	abstract public function makeConfigFile();

	/**
	 * Make the configuration file and write it to the disk
	 *
	 * @return  bool
	 */
	public function writeConfigFile(): bool
	{
		// Make sure we are called by an expected caller
		ServerTechnology::checkCaller($this->allowedCallersForWrite);

		/**
		 * On Joomla 5 the public root (where the .htaccess and web.config file is written) may be different to the
		 * Joomla! installation root. However, for the NginX Conf Maker it makes more sense to always use JPATH_ROOT,
		 * as the file has to be explicitly included by the server's configuration, i.e. the server IS NOT necessarily
		 * looking for it in the public root.
		 */
		$htaccessPath = $this->getConfigFileName(true);
		$backupPath   = $htaccessPath . '.admintools';

		if (@file_exists($htaccessPath))
		{
			try
			{
				File::copy(basename($htaccessPath), basename($backupPath), dirname($htaccessPath));
			}
			catch (Exception $e)
			{
				// Swallow
			}
		}

		$configFileContents = $this->makeConfigFile();

		/**
		 * Convert CRLF to LF before saving the file. This would work around an issue with Windows browsers using CRLF
		 * line endings in text areas which would then be transferred verbatim to the output file. Most servers don't
		 * mind, but NginX will break hard when it sees the CR in the configuration file.
		 */
		$configFileContents = str_replace("\r\n", "\n", $configFileContents);

		try
		{
			return File::write($htaccessPath, $configFileContents);
		}
		catch (Exception $e)
		{
			return false;
		}
	}

	/**
	 * Given a server configuration file, strips out header comments and create an hash of the remaining contents
	 *
	 * @param   string  $contents
	 *
	 * @return string
	 */
	public function getConfigHash(string $contents): string
	{
		// Get the lines of the configuration
		$lines = explode("\n", $contents);

		// Trim the lines and convert comments to empty lines
		$lines = array_map(function ($line) {
			$line = trim($line);

			if (substr($line, 0, 1) === '#')
			{
				$line = '';
			}

			return $line;
		}, $lines);

		// Remove empty lines
		$lines = array_filter($lines, function ($line) {
			return !empty($line);
		});

		// Get the MD5 of the normalised contents
		return hash('md5', implode("\n", $lines));
	}

	/**
	 * Checks if current redirection rules do match the URL saved inside the live_site variable. For example:
	 * - live_site: www.example.com - Redirect www to non-www   WRONG!
	 * - live_site: www.example.com - Redirect non-www to www   CORRECT!
	 *
	 * @return bool Are the live_site variable and current redirection rules compatible?
	 */
	public function enableRedirects(): bool
	{
		$live_site = Factory::getApplication()->get('live_site', '');

		// No value set (90% of cases), we're good to go
		if (!$live_site)
		{
			return true;
		}

		// The user put the protocol in the live site? That's an hard no
		if (stripos($live_site, 'http') !== false)
		{
			return false;
		}

		$config = $this->loadConfiguration();

		// No redirection set? We're good to go
		if (!$config->wwwredir)
		{
			return true;
		}

		// Got www site and a redirect from www to non-www, that's wrong
		if ((stripos($live_site, 'www.') === 0) && ($config->wwwredir === 2))
		{
			return false;
		}

		// Got non-www site and a redirect from non-www to www, that's wrong
		if ((stripos($live_site, 'www.') === false) && ($config->wwwredir === 1))
		{
			return false;
		}

		// Otherwise we're good to go
		return true;
	}

	/**
	 * Is this configuration file type supported on this server?
	 *
	 * @return  int  0: no; 1: yes; 2: maybe (unsure)
	 */
	abstract public function isSupported(): int;

	public function convertFormDataToDatabaseData($data)
	{
		/**
		 * Subforms give us data in the format:
		 * ['__field1' => ['item' => 'directory1'], '__field2' => ['item' => 'directory2']]
		 * I need to convert to the format:
		 * ['directory1', 'directory2']
		 */
		foreach ($this->subformConfigKeys as $key)
		{
			$value = array_map(function ($subformFields) {
				return $subformFields['item'] ?? '';
			}, ($data[$key] ?? []) ?: []);

			$data[$key] = array_values(array_filter($value, function ($x) {
				return !empty($x);
			}));
		}

		return $data;
	}

	/**
	 * Converts data in a database format (['value1', 'value2']) to a format accepted by Joomla Form object
	 * ['__field1' => ['item' => 'value1'], '__field2' => ['item' => 'value2']]
	 *
	 * @param $data
	 *
	 * @return array
	 */
	public function convertDatabaseDataToFormData($data): array
	{
		$result = [];
		$i      = 1;

		foreach ($data as $item)
		{
			$result['__field'.$i] = ['item' => $item];

			$i++;
		}

		return $result;
	}

    /**
     * Checks if the current configuration file contains any directive for handling the PHP interpreter. We're adding this
     * here for consistency, but in reality this would only happen under Apache, since in NginX users already have to provide
     * this info and under IIS this is handled inside its management console.
     *
     * @param string $server_config
     *
     * @return string|null
     */
    public function extractHandler(string $server_config): ?string
    {
        return null;
    }

    /**
     * Does the current server configuration have any directive for PHP handlers?
     *
     * @return bool
     */
    public function hasPhpHandlers(): bool
    {
        return false;
    }

    /**
     * Gets any PHP handler directly from root .htaccess file
     *
     * @return string|null
     */
    public function getPhpHandlers(): ?string
    {
        return null;
    }

	protected function loadFormData()
	{
		$data = $this->loadConfiguration();

		/** @var CMSApplication $app */
		$app  = Factory::getApplication();

		// Fetch the data from the state only if we're not under CLI
		if (!$app->isClient('cli'))
		{
			$data = $app->getUserState('com_admintools.' . $this->getName() . '.data', $data);
		}

		$this->preprocessData('com_admintools.' . $this->getName(), $data);

		return $data;
	}

	protected function preprocessData($context, &$data, $group = 'content')
	{
		if (is_object($data))
		{
			$data = (array) $data;
		}

		foreach ($this->subformConfigKeys as $key)
		{
			$value      = $data[$key] ?? [];
			$data[$key] = array_map(function ($x) {
				return ['item' => $x];
			}, $value);
		}

		if (method_exists($this, 'enableRedirects') && !$this->enableRedirects())
		{
			$data['wwwredirs'] = 0;
		}

		// Get the dispatcher and load the users plugins.
		PluginHelper::importPlugin($group);

		// Trigger the data preparation event.
		$this->triggerPluginEvent('onContentPrepareData', [$context, &$data]);
	}

	protected function preprocessForm(Form $form, $data, $group = 'content')
	{
		/**
		 * Special case: wwwredirs must be disabled (value and interaction) if there's a $live_site URL hard-coded.
		 */
		if (method_exists($this, 'enableRedirects') && !$this->enableRedirects())
		{
			$form->setFieldAttribute('wwwredirs', 'disabled', 'true');
			$form->setFieldAttribute('wwwredirs', 'required', 'false');
			$form->setFieldAttribute('wwwredirs', 'filter', 'unset');
		}

		// Import the appropriate plugin group.
		PluginHelper::importPlugin($group);

		// Trigger the form preparation event.
		$this->triggerPluginEvent('onContentPrepareForm', [$form, $data]);

	}

	/**
	 * Escapes a string so that it's a neutral string inside a regular expression.
	 *
	 * @param   string  $str  The string to escape
	 *
	 * @return  string  The escaped string
	 */
	protected function escape_string_for_regex($str): string
	{
		//All regex special chars (according to arkani at iol dot pt below):
		// \ ^ . $ | ( ) [ ]
		// * + ? { } , -

		$patterns = [
			'/\//', '/\^/', '/\./', '/\$/', '/\|/',
			'/\(/', '/\)/', '/\[/', '/\]/', '/\*/', '/\+/',
			'/\?/', '/\{/', '/\}/', '/\,/', '/\-/',
		];

		$replace = [
			'\/', '\^', '\.', '\$', '\|', '\(', '\)',
			'\[', '\]', '\*', '\+', '\?', '\{', '\}', '\,', '\-',
		];

		return preg_replace($patterns, $replace, $str);
	}

	protected function bugfixBackendProtectionExclusionDirectories(array $directories): array
	{
		if (in_array('component', $directories) && !in_array('components', $directories))
		{
			$index = array_search('component', $directories);
			$directories[$index] = 'components';
		}

		return $directories;
	}

	protected function handlePotentialArrayKeys(array &$config): void
	{
		foreach ($this->arrayConfigKeys as $key)
		{
			$value = $config[$key] ?? [];

			if (is_object($value))
			{
				$config[$key] = (array) $value;

				continue;
			}

			if (is_string($value))
			{
				$config[$key] = array_map('trim', explode(',', $value));
				$config[$key] = array_filter($config[$key], function ($x) {
					return ($x !== null) && ($x !== '');
				});

				continue;
			}

			if (!is_array($value))
			{
				$config[$key] = [];
			}
		}

	}
}

© 2025 Cubjrnet7