name : FilescannerController.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\Site\Controller;

defined('_JEXEC') or die;

use Akeeba\Component\AdminTools\Administrator\Helper\Storage;
use Akeeba\Component\AdminTools\Administrator\Mixin\ControllerEventsTrait;
use Akeeba\Component\AdminTools\Administrator\Mixin\ControllerRegisterTasksTrait;
use Akeeba\Component\AdminTools\Administrator\Model\ScansModel;
use Akeeba\Component\AdminTools\Administrator\Scanner\Complexify;
use Akeeba\Component\AdminTools\Administrator\Scanner\Util\Session;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\Input\Input;

class FilescannerController extends BaseController
{
	use ControllerEventsTrait;
	use ControllerRegisterTasksTrait;

	public function __construct($config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
	{
		parent::__construct($config, $factory, $app, $input);

		$this->registerControllerTasks('start');
	}

	/**
	 * Overriden to load the backend Scans model by default
	 *
	 * @param   string        $name
	 * @param   string        $prefix
	 * @param   array|bool[]  $config
	 *
	 * @return bool|\Joomla\CMS\MVC\Model\BaseDatabaseModel
	 */
	public function getModel($name = 'Scans', $prefix = 'Administrator', $config = ['ignore_request' => true])
	{
		return parent::getModel($name, $prefix, $config);
	}

	/**
	 * Starts a new front-end PHP File Change Scanner job
	 *
	 * @return  void
	 */
	public function start()
	{
		$this->enforceFrontendRequirements();

		/** @var ScansModel $model */
		$model = $this->getModel();

		$model->removeIncompleteScans();
		$this->resetPersistedEngineState();

		$resultArray = $model->startScan('frontend');

		$this->persistEngineState();
		$this->processResultArray($resultArray);
	}

	/**
	 * Steps through an already running front-end PHP File Change Scanner job
	 *
	 * @return  void
	 */
	public function step()
	{
		$this->enforceFrontendRequirements();
		$this->retrieveEngineState();

		/** @var ScansModel $model */
		$model       = $this->getModel();
		$resultArray = $model->stepScan();

		$this->persistEngineState();
		$this->processResultArray($resultArray);
	}

	/**
	 * Ensure that front-end scans are enabled and that the URL includes a correct, complex enough secret key.
	 *
	 * If any of these conditions is not met we return a 403.
	 *
	 * @return  void
	 */
	private function enforceFrontendRequirements()
	{
		$cParams = ComponentHelper::getParams('com_admintools');

		// Is frontend backup enabled?
		$febEnabled = $cParams->get('frontend_enable', 0) != 0;

		// Is the Secret Key strong enough?
		$validKey = $cParams->get('frontend_secret_word', '');

		if (!Complexify::isStrongEnough($validKey, false))
		{
			$febEnabled = false;
		}

		if (!$febEnabled)
		{
			@ob_end_clean();
			echo '403 ' . Text::_('COM_ADMINTOOLS_ERR_NOT_ENABLED');
			flush();

			$this->app->close();

			return;
		}

		// Is the key good?
		$key          = $this->input->getRaw('key', '');
		$validKeyTrim = trim($validKey);

		if (($key != $validKey) || (empty($validKeyTrim)))
		{
			@ob_end_clean();
			echo '403 ' . Text::_('COM_ADMINTOOLS_ERR_INVALID_KEY');
			flush();

			$this->app->close();
		}
	}

	/**
	 * Immediately issue a custom redirection and close the application.
	 *
	 * Unlike the regular Controller::redirect() this acts immediately and does not go through Joomla. Therefore we can
	 * use custom HTTP headers.
	 *
	 * @param   string  $url     URL to redirect to
	 * @param   string  $header  HTTP/1.1 header to use. Default: 302 Found (temporary redirection)
	 */
	private function issueRedirection($url, $header = '302 Found')
	{
		header('HTTP/1.1 ' . $header);
		header('Location: ' . $url);
		header('Content-Type: text/plain');
		header('Connection: close');

		$this->app->close();
	}

	/**
	 * Process the scanner engine's result array and send the correct response to the browser
	 *
	 * This included issuing a custom redirection to the URL of the next step if such a thing is necessary. In either
	 * case, the application is immediately closed right at the end of this method's execution.
	 *
	 * @param   array  $resultArray  The result array to parse
	 *
	 * @return  void
	 */
	private function processResultArray(array $resultArray)
	{
		// Is this an error?
		if ($resultArray['error'] != '')
		{
			$this->resetPersistedEngineState();

			// An error occured
			die('500 ERROR -- ' . $resultArray['error']);
		}

		// Are we finished already?
		if ($resultArray['done'])
		{
			$this->resetPersistedEngineState();

			@ob_end_clean();
			header('Content-type: text/plain');
			header('Connection: close');
			echo '200 OK';
			flush();

			$this->app->close();

			return;
		}

		// We have more work to do. Should we redirect...?
		$noredirect = $this->input->get('noredirect', 0, 'int');

		if ($noredirect != 0)
		{
			@ob_end_clean();
			header('Content-type: text/plain');
			header('Connection: close');
			echo "301 More work required";
			flush();

			$this->app->close();

			return;
		}

		$curUri  = Uri::getInstance();
		$ssl     = $curUri->isSSL() ? 1 : 0;
		$tempURL = Route::_('index.php', false, $ssl);
		$uri     = new Uri($tempURL);

		$uri->setVar('option', 'com_admintools');
		$uri->setVar('view', 'filescanner');
		$uri->setVar('task', 'step');
		$uri->setVar('format', 'raw');
		$uri->delVar('key');

		// Maybe we have a multilingual site?
		$languageTag = $this->app->getLanguage()->getTag();

		$uri->setVar('lang', $languageTag);
		$uri->setVar('key', urlencode($this->input->getRaw('key', '')));

		$redirectionURL = $uri->toString();

		$this->issueRedirection($redirectionURL);
	}

	/**
	 * Resets the persisted scanner engine state.
	 *
	 * @return  void
	 */
	private function resetPersistedEngineState()
	{
		$storage = new Storage();
		$storage->setValue('filescanner.memory', null);
		$storage->setValue('filescanner.timestamp', 0);
		$storage->save();
	}

	/**
	 * Persist the scanner engine state in the database.
	 *
	 * @return  void
	 */
	private function persistEngineState()
	{
		$session     = Session::getInstance();
		$storage     = new Storage();
		$sessionData = array_combine($session->getKnownKeys(), array_map(function ($key) use ($session) {
			return $session->get($key);
		}, $session->getKnownKeys()));

		$storage->setValue('filescanner.memory', json_encode($sessionData));
		$storage->setValue('filescanner.timestamp', time());
		$storage->save();
	}

	/**
	 * Retrieve the persisted scanner engine state from the database.
	 *
	 * It will result in a 403 error if there is no state, the state is invalid or it was stored more than 90 seconds
	 * ago.
	 *
	 * @return  void
	 */
	private function retrieveEngineState()
	{
		// Retrieve the engine's session from Admin Tools' storage in the database
		$storage    = new Storage();
		$jsonMemory = $storage->getValue('filescanner.memory', null);
		$timestamp  = $storage->getValue('filescanner.timestamp', 0);
		$valid      = !empty($jsonMemory) && (time() - $timestamp <= 90);
		try
		{
			$sessionData = @json_decode($jsonMemory, true);
		}
		catch (Exception $e)
		{
			$sessionData = null;
		}

		// If we have no data stored, invalid data stored or it was stored more than 90 seconds ago we can't proceed.
		if (!$valid || is_null($sessionData))
		{
			$this->resetPersistedEngineState();

			@ob_end_clean();
			echo '403 ' . Text::_('COM_ADMINTOOLS_ERR_NOT_ENABLED');
			flush();

			$this->app->close();
		}

		// Populate the session from the persisted state
		$session = Session::getInstance();
		array_walk($sessionData, function ($value, $key) use ($session) {
			$session->set($key, $value);
		});
	}
}

© 2025 Cubjrnet7