<?php
/**
 * @package         Joomla.Administrator
 * @subpackage      com_joomlaupdate
 *
 * @copyright       (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
 * @license         GNU General Public License version 2 or later; see LICENSE.txt
 */

/**
 * Should you want to debug this file, please add a new line ABOVE this comment with the following
 * contents (excluding the space star space at the start of this line):
 *
 * define('_JOOMLA_UPDATE_DEBUG', 1);
 *
 * This will do two things:
 * - it will create the joomla_update.txt file in your site's temporary directory (default: tmp).
 *   This file contains a debug log, detailing everything extract.php is doing during the extraction
 *   of the Joomla update ZIP file.
 * - It will prevent extract.php from being overwritten during the update with a new version. This
 *   is useful if you are testing any changes in extract.php you do not want accidentally
 *   overwritten, or if you are given a modified extract.php by a Joomla core contributor with
 *   changes which might fix your update problem.
 */
define('_JOOMLA_UPDATE', 1);

/**
 * ZIP archive extraction class
 *
 * This is a derivative work of Akeeba Restore which is Copyright (c)2008-2021 Nicholas K.
 * Dionysopoulos and Akeeba Ltd, distributed under the terms of GNU General Public License version 3
 * or later.
 *
 * The author of the original work has decided to relicense the derivative work under the terms of
 * the GNU General Public License version 2 or later and share the copyright of the derivative work
 * with Open Source Matters, Inc (OSM), granting OSM non-exclusive rights to this work per the terms
 * of the Joomla Contributor Agreement (JCA) the author signed back in 2011 and which is still in
 * effect. This is affirmed by the cryptographically signed commits in the Git repository containing
 * this file, the copyright messages and this notice here.
 *
 * @since  4.0.4
 */
class ZIPExtraction
{
	/**
	 * How much data to read at once when processing files
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const CHUNK_SIZE = 524288;

	/**
	 * Maximum execution time (seconds).
	 *
	 * Each page load will take at most this much time. Please note that if the ZIP archive contains fairly large,
	 * compressed files we may overshoot this time since we can't interrupt the decompression. This should not be an
	 * issue in the context of updating Joomla as the ZIP archive contains fairly small files.
	 *
	 * If this is too low it will cause too many requests to hit the server, potentially triggering a DoS protection and
	 * causing the extraction to fail. If this is too big the extraction will not be as verbose and the user might think
	 * something is broken. A value between 3 and 7 seconds is, therefore, recommended.
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const MAX_EXEC_TIME = 4;

	/**
	 * Run-time execution bias (percentage points).
	 *
	 * We evaluate the time remaining on the timer before processing each file on the ZIP archive. If we have already
	 * consumed at least this much percentage of the MAX_EXEC_TIME we will stop processing the archive in this page
	 * load, return the result to the client and wait for it to call us again so we can resume the extraction.
	 *
	 * This becomes important when the MAX_EXEC_TIME is close to the PHP, PHP-FPM or Apache timeout on the server
	 * (whichever is lowest) and there are fairly large files in the backup archive. If we start extracting a large,
	 * compressed file close to a hard server timeout it's possible that we will overshoot that hard timeout and see the
	 * extraction failing.
	 *
	 * Since Joomla Update is used to extract a ZIP archive with many small files we can keep at a fairly high 90%
	 * without much fear that something will break.
	 *
	 * Example: if MAX_EXEC_TIME is 10 seconds and RUNTIME_BIAS is 80 each page load will take between 80% and 100% of
	 * the MAX_EXEC_TIME, i.e. anywhere between 8 and 10 seconds.
	 *
	 * Lower values make it less likely to overshoot MAX_EXEC_TIME when extracting large files.
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const RUNTIME_BIAS = 90;

	/**
	 * Minimum execution time (seconds).
	 *
	 * A request cannot take less than this many seconds. If it does, we add “dead time” (sleep) where the script does
	 * nothing except wait. This is essentially a rate limiting feature to avoid hitting a server-side DoS protection
	 * which could be triggered if we ended up sending too many requests in a limited amount of time.
	 *
	 * This should normally be less than MAX_EXEC * (RUNTIME_BIAS / 100). Values between that and MAX_EXEC_TIME have the
	 * effect of almost always adding dead time in each request, unless a really large file is being extracted from the
	 * ZIP archive. Values larger than MAX_EXEC will always add dead time to the request. This can be useful to
	 * artificially reduce the CPU usage limit. Some servers might kill the request if they see a sustained CPU usage
	 * spike over a short period of time.
	 *
	 * The chosen value of 3 seconds belongs to the first category, essentially making sure that we have a decent rate
	 * limiting without annoying the user too much but also catering for the most badly configured of shared
	 * hosting. It's a happy medium which works for the majority (~90%) of commercial servers out there.
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const MIN_EXEC_TIME = 3;

	/**
	 * Internal state when extracting files: we need to be initialised
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_INITIALIZE = -1;

	/**
	 * Internal state when extracting files: no file currently being extracted
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_NOFILE = 0;

	/**
	 * Internal state when extracting files: reading the file header
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_HEADER = 1;

	/**
	 * Internal state when extracting files: reading file data
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_DATA = 2;

	/**
	 * Internal state when extracting files: file data has been read thoroughly
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_DATAREAD = 3;

	/**
	 * Internal state when extracting files: post-processing the file
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_POSTPROC = 4;

	/**
	 * Internal state when extracting files: done with this file
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_DONE = 5;

	/**
	 * Internal state when extracting files: finished extracting the ZIP file
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const AK_STATE_FINISHED = 999;

	/**
	 * Internal logging level: debug
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const LOG_DEBUG = 1;

	/**
	 * Internal logging level: information
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const LOG_INFO = 10;

	/**
	 * Internal logging level: warning
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const LOG_WARNING = 50;

	/**
	 * Internal logging level: error
	 *
	 * @var   int
	 * @since 4.0.4
	 */
	private const LOG_ERROR = 90;

	/**
	 * Singleton instance
	 *
	 * @var   null|self
	 * @since 4.0.4
	 */
	private static $instance = null;

	/**
	 * Debug log file pointer resource
	 *
	 * @var   null|resource|boolean
	 * @since 4.0.4
	 */
	private static $logFP = null;

	/**
	 * Debug log filename
	 *
	 * @var   null|string
	 * @since 4.0.4
	 */
	private static $logFilePath = null;

	/**
	 * The total size of the ZIP archive
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	public $totalSize = 0;

	/**
	 * Which files to skip
	 *
	 * @var   array
	 * @since 4.0.4
	 */
	public $skipFiles = [];

	/**
	 * Current tally of compressed size read
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	public $compressedTotal = 0;

	/**
	 * Current tally of bytes written to disk
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	public $uncompressedTotal = 0;

	/**
	 * Current tally of files extracted
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	public $filesProcessed = 0;

	/**
	 * Maximum execution time allowance per step
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	private $maxExecTime = null;

	/**
	 * Timestamp of execution start
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	private $startTime;

	/**
	 * The last error message
	 *
	 * @var   string|null
	 * @since 4.0.4
	 */
	private $lastErrorMessage = null;

	/**
	 * Archive filename
	 *
	 * @var   string
	 * @since 4.0.4
	 */
	private $filename = null;

	/**
	 * Current archive part number
	 *
	 * @var   boolean
	 * @since 4.0.4
	 */
	private $archiveFileIsBeingRead = false;

	/**
	 * The offset inside the current part
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	private $currentOffset = 0;

	/**
	 * Absolute path to prepend to extracted files
	 *
	 * @var   string
	 * @since 4.0.4
	 */
	private $addPath = '';

	/**
	 * File pointer to the current archive part file
	 *
	 * @var   resource|null
	 * @since 4.0.4
	 */
	private $fp = null;

	/**
	 * Run state when processing the current archive file
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	private $runState = self::AK_STATE_INITIALIZE;

	/**
	 * File header data, as read by the readFileHeader() method
	 *
	 * @var   stdClass
	 * @since 4.0.4
	 */
	private $fileHeader = null;

	/**
	 * How much of the uncompressed data we've read so far
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	private $dataReadLength = 0;

	/**
	 * Unwritable files in these directories are always ignored and do not cause errors when not
	 * extracted.
	 *
	 * @var   array
	 * @since 4.0.4
	 */
	private $ignoreDirectories = [];

	/**
	 * Internal flag, set when the ZIP file has a data descriptor (which we will be ignoring)
	 *
	 * @var   boolean
	 * @since 4.0.4
	 */
	private $expectDataDescriptor = false;

	/**
	 * The UNIX last modification timestamp of the file last extracted
	 *
	 * @var   integer
	 * @since 4.0.4
	 */
	private $lastExtractedFileTimestamp = 0;

	/**
	 * The file path of the file last extracted
	 *
	 * @var   string
	 * @since 4.0.4
	 */
	private $lastExtractedFilename = null;

	/**
	 * Public constructor.
	 *
	 * Sets up the internal timer.
	 *
	 * @since   4.0.4
	 */
	public function __construct()
	{
		$this->setupMaxExecTime();

		// Initialize start time
		$this->startTime = microtime(true);
	}

	/**
	 * Singleton implementation.
	 *
	 * @return  static
	 * @since   4.0.4
	 */
	public static function getInstance(): self
	{
		if (is_null(self::$instance))
		{
			self::$instance = new self;
		}

		return self::$instance;
	}

	/**
	 * Returns a serialised copy of the object.
	 *
	 * This is different to calling serialise() directly. This operates on a copy of the object which undergoes a
	 * call to shutdown() first so any open files are closed first.
	 *
	 * @return  string  The serialised data, potentially base64 encoded.
	 * @since   4.0.4
	 */
	public static function getSerialised(): string
	{
		$clone = clone self::getInstance();
		$clone->shutdown();
		$serialized = serialize($clone);

		return (function_exists('base64_encode') && function_exists('base64_decode')) ? base64_encode($serialized) : $serialized;
	}

	/**
	 * Restores a serialised instance into the singleton implementation and returns it.
	 *
	 * If the serialised data is corrupt it will return null.
	 *
	 * @param   string  $serialised  The serialised data, potentially base64 encoded, to deserialize.
	 *
	 * @return  static|null  The instance of the object, NULL if it cannot be deserialised.
	 * @since   4.0.4
	 */
	public static function unserialiseInstance(string $serialised): ?self
	{
		if (function_exists('base64_encode') && function_exists('base64_decode'))
		{
			$serialised = base64_decode($serialised);
		}

		$instance = @unserialize($serialised, [
				'allowed_classes' => [
					self::class,
					stdClass::class,
				],
			]
		);

		if (($instance === false) || !is_object($instance) || !($instance instanceof self))
		{
			return null;
		}

		self::$instance = $instance;

		return self::$instance;
	}

	/**
	 * Wakeup function, called whenever the class is deserialized.
	 *
	 * This method does the following:
	 * - Restart the timer.
	 * - Reopen the archive file, if one is defined.
	 * - Seek to the correct offset of the file.
	 *
	 * @return  void
	 * @since   4.0.4
	 * @internal
	 */
	public function __wakeup(): void
	{
		// Reset the timer when deserializing the object.
		$this->startTime = microtime(true);

		if (!$this->archiveFileIsBeingRead)
		{
			return;
		}

		$this->fp = @fopen($this->filename, 'rb');

		if ((is_resource($this->fp)) && ($this->currentOffset > 0))
		{
			@fseek($this->fp, $this->currentOffset);
		}
	}

	/**
	 * Enforce the minimum execution time.
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	public function enforceMinimumExecutionTime()
	{
		$elapsed     = $this->getRunningTime() * 1000;
		$minExecTime = 1000.0 * min(1, (min(self::MIN_EXEC_TIME, $this->getPhpMaxExecTime()) - 1));

		// Only run a sleep delay if we haven't reached the minimum execution time
		if (($minExecTime <= $elapsed) || ($elapsed <= 0))
		{
			return;
		}

		$sleepMillisec = intval($minExecTime - $elapsed);

		/**
		 * If we need to sleep for more than 1 second we should be using sleep() or time_sleep_until() to prevent high
		 * CPU usage, also because some OS might not support sleeping for over 1 second using these functions. In all
		 * other cases we will try to use usleep or time_nanosleep instead.
		 */
		$longSleep          = $sleepMillisec > 1000;
		$miniSleepSupported = function_exists('usleep') || function_exists('time_nanosleep');

		if (!$longSleep && $miniSleepSupported)
		{
			if (function_exists('usleep') && ($sleepMillisec < 1000))
			{
				usleep(1000 * $sleepMillisec);

				return;
			}

			if (function_exists('time_nanosleep') && ($sleepMillisec < 1000))
			{
				time_nanosleep(0, 1000000 * $sleepMillisec);

				return;
			}
		}

		if (function_exists('sleep'))
		{
			sleep(ceil($sleepMillisec / 1000));

			return;
		}

		if (function_exists('time_sleep_until'))
		{
			time_sleep_until(time() + ceil($sleepMillisec / 1000));
		}
	}

	/**
	 * Set the filepath to the ZIP archive which will be extracted.
	 *
	 * @param   string  $value  The filepath to the archive. Only LOCAL files are allowed!
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	public function setFilename(string $value)
	{
		// Security check: disallow remote filenames
		if (!empty($value) && strpos($value, '://') !== false)
		{
			$this->setError('Invalid archive location');

			return;
		}

		$this->filename = $value;
		$this->initializeLog(dirname($this->filename));
	}

	/**
	 * Sets the path to prefix all extracted files with. Essentially, where the archive will be extracted to.
	 *
	 * @param   string  $addPath  The path where the archive will be extracted.
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	public function setAddPath(string $addPath): void
	{
		$this->addPath = $addPath;
		$this->addPath = str_replace('\\', '/', $this->addPath);
		$this->addPath = rtrim($this->addPath, '/');

		if (!empty($this->addPath))
		{
			$this->addPath .= '/';
		}
	}

	/**
	 * Set the list of files to skip when extracting the ZIP file.
	 *
	 * @param   array  $skipFiles  A list of files to skip when extracting the ZIP archive
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	public function setSkipFiles(array $skipFiles): void
	{
		$this->skipFiles = array_values($skipFiles);
	}

	/**
	 * Set the directories to skip over when extracting the ZIP archive
	 *
	 * @param   array  $ignoreDirectories  The list of directories to ignore.
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	public function setIgnoreDirectories(array $ignoreDirectories): void
	{
		$this->ignoreDirectories = array_values($ignoreDirectories);
	}

	/**
	 * Prepares for the archive extraction
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	public function initialize(): void
	{
		$this->debugMsg(sprintf('Initializing extraction. Filepath: %s', $this->filename));
		$this->totalSize              = @filesize($this->filename) ?: 0;
		$this->archiveFileIsBeingRead = false;
		$this->currentOffset          = 0;
		$this->runState               = self::AK_STATE_NOFILE;

		$this->readArchiveHeader();

		if (!empty($this->getError()))
		{
			$this->debugMsg(sprintf('Error: %s', $this->getError()), self::LOG_ERROR);

			return;
		}

		$this->archiveFileIsBeingRead = true;
		$this->runState               = self::AK_STATE_NOFILE;

		$this->debugMsg('Setting state to NOFILE', self::LOG_DEBUG);
	}

	/**
	 * Executes a step of the archive extraction
	 *
	 * @return  boolean  True if we are done extracting or an error occurred
	 * @since   4.0.4
	 */
	public function step(): bool
	{
		$status = true;

		$this->debugMsg('Starting a new step', self::LOG_INFO);

		while ($status && ($this->getTimeLeft() > 0))
		{
			switch ($this->runState)
			{
				case self::AK_STATE_INITIALIZE:
					$this->debugMsg('Current run state: INITIALIZE', self::LOG_DEBUG);
					$this->initialize();
					break;

				case self::AK_STATE_NOFILE:
					$this->debugMsg('Current run state: NOFILE', self::LOG_DEBUG);
					$status = $this->readFileHeader();

					if ($status)
					{
						$this->debugMsg('Found file header; updating number of files processed and bytes in/out', self::LOG_DEBUG);

						// Update running tallies when we start extracting a file
						$this->filesProcessed++;
						$this->compressedTotal   += array_key_exists('compressed', get_object_vars($this->fileHeader))
							? $this->fileHeader->compressed : 0;
						$this->uncompressedTotal += $this->fileHeader->uncompressed;
					}

					break;

				case self::AK_STATE_HEADER:
				case self::AK_STATE_DATA:
					$runStateHuman = $this->runState === self::AK_STATE_HEADER ? 'HEADER' : 'DATA';
					$this->debugMsg(sprintf('Current run state: %s', $runStateHuman), self::LOG_DEBUG);

					$status = $this->processFileData();
					break;

				case self::AK_STATE_DATAREAD:
				case self::AK_STATE_POSTPROC:
					$runStateHuman = $this->runState === self::AK_STATE_DATAREAD ? 'DATAREAD' : 'POSTPROC';
					$this->debugMsg(sprintf('Current run state: %s', $runStateHuman), self::LOG_DEBUG);

					$this->setLastExtractedFileTimestamp($this->fileHeader->timestamp);
					$this->processLastExtractedFile();

					$status         = true;
					$this->runState = self::AK_STATE_DONE;
					break;

				case self::AK_STATE_DONE:
				default:
					$this->debugMsg('Current run state: DONE', self::LOG_DEBUG);
					$this->runState = self::AK_STATE_NOFILE;

					break;

				case self::AK_STATE_FINISHED:
					$this->debugMsg('Current run state: FINISHED', self::LOG_DEBUG);
					$status = false;
					break;
			}

			if ($this->getTimeLeft() <= 0)
			{
				$this->debugMsg('Ran out of time; the step will break.');
			}
			elseif (!$status)
			{
				$this->debugMsg('Last step status is false; the step will break.');
			}
		}

		$error = $this->getError() ?? null;

		if (!empty($error))
		{
			$this->debugMsg(sprintf('Step failed with error: %s', $error), self::LOG_ERROR);
		}

		// Did we just finish or run into an error?
		if (!empty($error) || $this->runState === self::AK_STATE_FINISHED)
		{
			$this->debugMsg('Returning true (must stop running) from step()', self::LOG_DEBUG);

			// Reset internal state, prevents __wakeup from trying to open a non-existent file
			$this->archiveFileIsBeingRead = false;

			return true;
		}

		$this->debugMsg('Returning false (must continue running) from step()', self::LOG_DEBUG);

		return false;
	}

	/**
	 * Get the most recent error message
	 *
	 * @return  string|null  The message string, null if there's no error
	 * @since   4.0.4
	 */
	public function getError(): ?string
	{
		return $this->lastErrorMessage;
	}

	/**
	 * Gets the number of seconds left, before we hit the "must break" threshold
	 *
	 * @return  float
	 * @since   4.0.4
	 */
	private function getTimeLeft(): float
	{
		return $this->maxExecTime - $this->getRunningTime();
	}

	/**
	 * Gets the time elapsed since object creation/unserialization, effectively how
	 * long Akeeba Engine has been processing data
	 *
	 * @return  float
	 * @since   4.0.4
	 */
	private function getRunningTime(): float
	{
		return microtime(true) - $this->startTime;
	}

	/**
	 * Process the last extracted file or directory
	 *
	 * This invalidates OPcache for .php files. Also applies the correct permissions and timestamp.
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function processLastExtractedFile(): void
	{
		$this->debugMsg(sprintf('Processing last extracted entity: %s', $this->lastExtractedFilename), self::LOG_DEBUG);

		if (@is_file($this->lastExtractedFilename))
		{
			@chmod($this->lastExtractedFilename, 0644);

			clearFileInOPCache($this->lastExtractedFilename);
		}
		else
		{
			@chmod($this->lastExtractedFilename, 0755);
		}

		if ($this->lastExtractedFileTimestamp > 0)
		{
			@touch($this->lastExtractedFilename, $this->lastExtractedFileTimestamp);
		}
	}

	/**
	 * Set the last extracted filename
	 *
	 * @param   string|null  $lastExtractedFilename  The last extracted filename
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function setLastExtractedFilename(?string $lastExtractedFilename): void
	{
		$this->lastExtractedFilename = $lastExtractedFilename;
	}

	/**
	 * Set the last modification UNIX timestamp for the last extracted file
	 *
	 * @param   int  $lastExtractedFileTimestamp  The timestamp
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function setLastExtractedFileTimestamp(int $lastExtractedFileTimestamp): void
	{
		$this->lastExtractedFileTimestamp = $lastExtractedFileTimestamp;
	}

	/**
	 * Sleep function, called whenever the class is serialized
	 *
	 * @return  void
	 * @since   4.0.4
	 * @internal
	 */
	private function shutdown(): void
	{
		if (is_resource(self::$logFP))
		{
			@fclose(self::$logFP);
		}

		if (!is_resource($this->fp))
		{
			return;
		}

		$this->currentOffset = @ftell($this->fp);

		@fclose($this->fp);
	}

	/**
	 * Unicode-safe binary data length
	 *
	 * @param   string|null  $string  The binary data to get the length for
	 *
	 * @return  integer
	 * @since   4.0.4
	 */
	private function binStringLength(?string $string): int
	{
		if (is_null($string))
		{
			return 0;
		}

		if (function_exists('mb_strlen'))
		{
			return mb_strlen($string, '8bit') ?: 0;
		}

		return strlen($string) ?: 0;
	}

	/**
	 * Add an error message
	 *
	 * @param   string  $error  Error message
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function setError(string $error): void
	{
		$this->lastErrorMessage = $error;
	}

	/**
	 * Reads data from the archive.
	 *
	 * @param   resource  $fp      The file pointer to read data from
	 * @param   int|null  $length  The volume of data to read, in bytes
	 *
	 * @return  string  The data read from the file
	 * @since   4.0.4
	 */
	private function fread($fp, ?int $length = null): string
	{
		$readLength = (is_numeric($length) && ($length > 0)) ? $length : PHP_INT_MAX;
		$data       = fread($fp, $readLength);

		if ($data === false)
		{
			$this->debugMsg('No more data could be read from the file', self::LOG_WARNING);

			$data = '';
		}

		return $data;
	}

	/**
	 * Read the header of the archive, making sure it's a valid ZIP file.
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function readArchiveHeader(): void
	{
		$this->debugMsg('Reading the archive header.', self::LOG_DEBUG);

		// Open the first part
		$this->openArchiveFile();

		// Fail for unreadable files
		if ($this->fp === false)
		{
			return;
		}

		// Read the header data.
		$sigBinary  = fread($this->fp, 4);
		$headerData = unpack('Vsig', $sigBinary);

		// We only support single part ZIP files
		if ($headerData['sig'] != 0x04034b50)
		{
			$this->setError('The archive file is corrupt: bad header');

			return;
		}

		// Roll back the file pointer
		fseek($this->fp, -4, SEEK_CUR);

		$this->currentOffset  = @ftell($this->fp);
		$this->dataReadLength = 0;

	}

	/**
	 * Concrete classes must use this method to read the file header
	 *
	 * @return boolean True if reading the file was successful, false if an error occurred or we
	 *                 reached end of archive.
	 * @since   4.0.4
	 */
	private function readFileHeader(): bool
	{
		$this->debugMsg('Reading the file entry header.', self::LOG_DEBUG);

		if (!is_resource($this->fp))
		{
			$this->setError('The archive is not open for reading.');

			return false;
		}

		// Unexpected end of file
		if ($this->isEOF())
		{
			$this->debugMsg('EOF when reading file header data', self::LOG_WARNING);
			$this->setError('The archive is corrupt or truncated');

			return false;
		}

		$this->currentOffset = ftell($this->fp);

		if ($this->expectDataDescriptor)
		{
			$this->debugMsg('Expecting data descriptor (bit 3 of general purpose flag was set).', self::LOG_DEBUG);

			/**
			 * The last file had bit 3 of the general purpose bit flag set. This means that we have a 12 byte data
			 * descriptor we need to skip. To make things worse, there might also be a 4 byte optional data descriptor
			 * header (0x08074b50).
			 */
			$junk       = @fread($this->fp, 4);
			$junk       = unpack('Vsig', $junk);
			$readLength = ($junk['sig'] == 0x08074b50) ? 12 : 8;
			$junk       = @fread($this->fp, $readLength);

			// And check for EOF, too
			if ($this->isEOF())
			{
				$this->debugMsg('EOF when reading data descriptor', self::LOG_WARNING);
				$this->setError('The archive is corrupt or truncated');

				return false;
			}
		}

		// Get and decode Local File Header
		$headerBinary = fread($this->fp, 30);
		$format       = 'Vsig/C2ver/vbitflag/vcompmethod/vlastmodtime/vlastmoddate/Vcrc/Vcompsize/'
			. 'Vuncomp/vfnamelen/veflen';
		$headerData   = unpack($format, $headerBinary);

		// Check signature
		if (!($headerData['sig'] == 0x04034b50))
		{
			// The signature is not the one used for files. Is this a central directory record (i.e. we're done)?
			if ($headerData['sig'] == 0x02014b50)
			{
				$this->debugMsg('Found Central Directory header; the extraction is complete', self::LOG_DEBUG);

				// End of ZIP file detected. We'll just skip to the end of file...
				@fseek($this->fp, 0, SEEK_END);
				$this->runState = self::AK_STATE_FINISHED;

				return false;
			}

			$this->setError('The archive file is corrupt or truncated');

			return false;
		}

		// If bit 3 of the bitflag is set, expectDataDescriptor is true
		$this->expectDataDescriptor  = ($headerData['bitflag'] & 4) == 4;
		$this->fileHeader            = new stdClass;
		$this->fileHeader->timestamp = 0;

		// Read the last modified date and time
		$lastmodtime = $headerData['lastmodtime'];
		$lastmoddate = $headerData['lastmoddate'];

		if ($lastmoddate && $lastmodtime)
		{
			$vHour    = ($lastmodtime & 0xF800) >> 11;
			$vMInute  = ($lastmodtime & 0x07E0) >> 5;
			$vSeconds = ($lastmodtime & 0x001F) * 2;
			$vYear    = (($lastmoddate & 0xFE00) >> 9) + 1980;
			$vMonth   = ($lastmoddate & 0x01E0) >> 5;
			$vDay     = $lastmoddate & 0x001F;

			$this->fileHeader->timestamp = @mktime($vHour, $vMInute, $vSeconds, $vMonth, $vDay, $vYear);
		}

		$isBannedFile = false;

		$this->fileHeader->compressed   = $headerData['compsize'];
		$this->fileHeader->uncompressed = $headerData['uncomp'];
		$nameFieldLength                = $headerData['fnamelen'];
		$extraFieldLength               = $headerData['eflen'];

		// Read filename field
		$this->fileHeader->file = fread($this->fp, $nameFieldLength);

		// Read extra field if present
		if ($extraFieldLength > 0)
		{
			$extrafield = fread($this->fp, $extraFieldLength);
		}

		// Decide filetype -- Check for directories
		$this->fileHeader->type = 'file';

		if (strrpos($this->fileHeader->file, '/') == strlen($this->fileHeader->file) - 1)
		{
			$this->fileHeader->type = 'dir';
		}

		// Decide filetype -- Check for symbolic links
		if (($headerData['ver1'] == 10) && ($headerData['ver2'] == 3))
		{
			$this->fileHeader->type = 'link';
		}

		switch ($headerData['compmethod'])
		{
			case 0:
				$this->fileHeader->compression = 'none';
				break;
			case 8:
				$this->fileHeader->compression = 'gzip';
				break;
			default:
				$messageTemplate = 'This script cannot handle ZIP compression method %d. '
					. 'Only 0 (no compression) and 8 (DEFLATE, gzip) can be handled.';
				$actualMessage   = sprintf($messageTemplate, $headerData['compmethod']);
				$this->setError($actualMessage);

				return false;
				break;
		}

		// Find hard-coded banned files
		if ((basename($this->fileHeader->file) == ".") || (basename($this->fileHeader->file) == ".."))
		{
			$isBannedFile = true;
		}

		// Also try to find banned files passed in class configuration
		if ((count($this->skipFiles) > 0) && in_array($this->fileHeader->file, $this->skipFiles))
		{
			$isBannedFile = true;
		}

		// If we have a banned file, let's skip it
		if ($isBannedFile)
		{
			$debugMessage = sprintf('Current entity (%s) is banned from extraction and will be skipped over.', $this->fileHeader->file);
			$this->debugMsg($debugMessage, self::LOG_DEBUG);

			// Advance the file pointer, skipping exactly the size of the compressed data
			$seekleft = $this->fileHeader->compressed;

			while ($seekleft > 0)
			{
				// Ensure that we can seek past archive part boundaries
				$curSize = @filesize($this->filename);
				$curPos  = @ftell($this->fp);
				$canSeek = $curSize - $curPos;
				$canSeek = ($canSeek > $seekleft) ? $seekleft : $canSeek;
				@fseek($this->fp, $canSeek, SEEK_CUR);
				$seekleft -= $canSeek;

				if ($seekleft)
				{
					$this->setError('The archive is corrupt or truncated');

					return false;
				}
			}

			$this->currentOffset = @ftell($this->fp);
			$this->runState      = self::AK_STATE_DONE;

			return true;
		}

		// Last chance to prepend a path to the filename
		if (!empty($this->addPath))
		{
			$this->fileHeader->file = $this->addPath . $this->fileHeader->file;
		}

		// Get the translated path name
		if ($this->fileHeader->type == 'file')
		{
			$this->fileHeader->realFile = $this->fileHeader->file;
			$this->setLastExtractedFilename($this->fileHeader->file);
		}
		elseif ($this->fileHeader->type == 'dir')
		{
			$this->fileHeader->timestamp = 0;

			$dir = $this->fileHeader->file;

			if (!@is_dir($dir))
			{
				mkdir($dir, 0755, true);
			}

			$this->setLastExtractedFilename(null);
		}
		else
		{
			// Symlink; do not post-process
			$this->fileHeader->timestamp = 0;
			$this->setLastExtractedFilename(null);
		}

		$this->createDirectory();

		// Header is read
		$this->runState = self::AK_STATE_HEADER;

		return true;
	}

	/**
	 * Creates the directory this file points to
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function createDirectory(): void
	{
		// Do we need to create a directory?
		if (empty($this->fileHeader->realFile))
		{
			$this->fileHeader->realFile = $this->fileHeader->file;
		}

		$lastSlash = strrpos($this->fileHeader->realFile, '/');
		$dirName   = substr($this->fileHeader->realFile, 0, $lastSlash);
		$perms     = 0755;
		$ignore    = $this->isIgnoredDirectory($dirName);

		if (@is_dir($dirName))
		{
			return;
		}

		if ((@mkdir($dirName, $perms, true) === false) && (!$ignore))
		{
			$this->setError(sprintf('Could not create %s folder', $dirName));
		}

	}

	/**
	 * Concrete classes must use this method to process file data. It must set $runState to self::AK_STATE_DATAREAD when
	 * it's finished processing the file data.
	 *
	 * @return   boolean  True if processing the file data was successful, false if an error occurred
	 * @since   4.0.4
	 */
	private function processFileData(): bool
	{
		switch ($this->fileHeader->type)
		{
			case 'dir':
				$this->debugMsg('Extracting entity of type Directory', self::LOG_DEBUG);

				return $this->processTypeDir();
				break;

			case 'link':
				$this->debugMsg('Extracting entity of type Symbolic Link', self::LOG_DEBUG);

				return $this->processTypeLink();
				break;

			case 'file':
				switch ($this->fileHeader->compression)
				{
					case 'none':
						$this->debugMsg('Extracting entity of type File (Stored)', self::LOG_DEBUG);

						return $this->processTypeFileUncompressed();
						break;

					case 'gzip':
					case 'bzip2':
						$this->debugMsg('Extracting entity of type File (Compressed)', self::LOG_DEBUG);

						return $this->processTypeFileCompressed();
						break;

					case 'default':
						$this->setError(sprintf('Unknown compression type %s.', $this->fileHeader->compression));

						return false;
						break;
				}
				break;
		}

		$this->setError(sprintf('Unknown entry type %s.', $this->fileHeader->type));

		return false;
	}

	/**
	 * Opens the next part file for reading
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function openArchiveFile(): void
	{
		$this->debugMsg('Opening archive file for reading', self::LOG_DEBUG);

		if ($this->archiveFileIsBeingRead)
		{
			return;
		}

		if (is_resource($this->fp))
		{
			@fclose($this->fp);
		}

		$this->fp = @fopen($this->filename, 'rb');

		if ($this->fp === false)
		{
			$message = 'Could not open archive for reading. Check that the file exists, is '
				. 'readable by the web server and is not in a directory made out of reach by chroot, '
				. 'open_basedir restrictions or any other restriction put in place by your host.';
			$this->setError($message);

			return;
		}

		fseek($this->fp, 0);
		$this->currentOffset = 0;

	}

	/**
	 * Returns true if we have reached the end of file
	 *
	 * @return   boolean  True if we have reached End Of File
	 * @since   4.0.4
	 */
	private function isEOF(): bool
	{
		/**
		 * feof() will return false if the file pointer is exactly at the last byte of the file. However, this is a
		 * condition we want to treat as a proper EOF for the purpose of extracting a ZIP file. Hence the second part
		 * after the logical OR.
		 */
		return @feof($this->fp) || (@ftell($this->fp) > @filesize($this->filename));
	}

	/**
	 * Handles the permissions of the parent directory to a file and the file itself to make it writeable.
	 *
	 * @param   string  $path  A path to a file
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function setCorrectPermissions(string $path): void
	{
		static $rootDir = null;

		if (is_null($rootDir))
		{
			$rootDir = rtrim($this->addPath, '/\\');
		}

		$directory = rtrim(dirname($path), '/\\');

		// Is this an unwritable directory?
		if (($directory != $rootDir) && !is_writeable($directory))
		{
			@chmod($directory, 0755);
		}

		@chmod($path, 0644);
	}

	/**
	 * Is this file or directory contained in a directory we've decided to ignore
	 * write errors for? This is useful to let the extraction work despite write
	 * errors in the log, logs and tmp directories which MIGHT be used by the system
	 * on some low quality hosts and Plesk-powered hosts.
	 *
	 * @param   string  $shortFilename  The relative path of the file/directory in the package
	 *
	 * @return  boolean  True if it belongs in an ignored directory
	 * @since   4.0.4
	 */
	private function isIgnoredDirectory(string $shortFilename): bool
	{
		$check = substr($shortFilename, -1) == '/' ? rtrim($shortFilename, '/') : dirname($shortFilename);

		return in_array($check, $this->ignoreDirectories);
	}

	/**
	 * Process the file data of a directory entry
	 *
	 * @return  boolean
	 * @since   4.0.4
	 */
	private function processTypeDir(): bool
	{
		// Directory entries do not have file data, therefore we're done processing the entry.
		$this->runState = self::AK_STATE_DATAREAD;

		return true;
	}

	/**
	 * Process the file data of a link entry
	 *
	 * @return  boolean
	 * @since   4.0.4
	 */
	private function processTypeLink(): bool
	{
		$toReadBytes = 0;
		$leftBytes   = $this->fileHeader->compressed;
		$data        = '';

		while ($leftBytes > 0)
		{
			$toReadBytes     = min($leftBytes, self::CHUNK_SIZE);
			$mydata          = $this->fread($this->fp, $toReadBytes);
			$reallyReadBytes = $this->binStringLength($mydata);
			$data            .= $mydata;
			$leftBytes       -= $reallyReadBytes;

			if ($reallyReadBytes < $toReadBytes)
			{
				// We read less than requested!
				if ($this->isEOF())
				{
					$this->debugMsg('EOF when reading symlink data', self::LOG_WARNING);
					$this->setError('The archive file is corrupt or truncated');

					return false;
				}
			}
		}

		$filename = $this->fileHeader->realFile ?? $this->fileHeader->file;

		// Try to remove an existing file or directory by the same name
		if (file_exists($filename))
		{
			clearFileInOPCache($filename);
			@unlink($filename);
			@rmdir($filename);
		}

		// Remove any trailing slash
		if (substr($filename, -1) == '/')
		{
			$filename = substr($filename, 0, -1);
		}

		// Create the symlink
		@symlink($data, $filename);

		$this->runState = self::AK_STATE_DATAREAD;

		// No matter if the link was created!
		return true;
	}

	/**
	 * Processes an uncompressed (stored) file
	 *
	 * @return  boolean
	 * @since   4.0.4
	 */
	private function processTypeFileUncompressed(): bool
	{
		// Uncompressed files are being processed in small chunks, to avoid timeouts
		if ($this->dataReadLength == 0)
		{
			// Before processing file data, ensure permissions are adequate
			$this->setCorrectPermissions($this->fileHeader->file);
		}

		// Open the output file
		$ignore = $this->isIgnoredDirectory($this->fileHeader->file);

		$writeMode = ($this->dataReadLength == 0) ? 'wb' : 'ab';
		$outfp     = @fopen($this->fileHeader->realFile, $writeMode);

		// Can we write to the file?
		if (($outfp === false) && (!$ignore))
		{
			// An error occurred
			$this->setError(sprintf('Could not open %s for writing.', $this->fileHeader->realFile));

			return false;
		}

		// Does the file have any data, at all?
		if ($this->fileHeader->compressed == 0)
		{
			// No file data!
			if (is_resource($outfp))
			{
				@fclose($outfp);
			}

			$this->debugMsg('Zero byte Stored file; no data will be read', self::LOG_DEBUG);

			$this->runState = self::AK_STATE_DATAREAD;

			return true;
		}

		$leftBytes = $this->fileHeader->compressed - $this->dataReadLength;

		// Loop while there's data to read and enough time to do it
		while (($leftBytes > 0) && ($this->getTimeLeft() > 0))
		{
			$toReadBytes          = min($leftBytes, self::CHUNK_SIZE);
			$data                 = $this->fread($this->fp, $toReadBytes);
			$reallyReadBytes      = $this->binStringLength($data);
			$leftBytes            -= $reallyReadBytes;
			$this->dataReadLength += $reallyReadBytes;

			if ($reallyReadBytes < $toReadBytes)
			{
				// We read less than requested! Why? Did we hit local EOF?
				if ($this->isEOF())
				{
					// Nope. The archive is corrupt
					$this->debugMsg('EOF when reading stored file data', self::LOG_WARNING);
					$this->setError('The archive file is corrupt or truncated');

					return false;
				}
			}

			if (is_resource($outfp))
			{
				@fwrite($outfp, $data);
			}

			if ($this->getTimeLeft())
			{
				$this->debugMsg('Out of time; will resume extraction in the next step', self::LOG_DEBUG);
			}
		}

		// Close the file pointer
		if (is_resource($outfp))
		{
			@fclose($outfp);
		}

		// Was this a pre-timeout bail out?
		if ($leftBytes > 0)
		{
			$this->debugMsg(sprintf('We have %d bytes left to extract in the next step', $leftBytes), self::LOG_DEBUG);
			$this->runState = self::AK_STATE_DATA;

			return true;
		}

		// Oh! We just finished!
		$this->runState       = self::AK_STATE_DATAREAD;
		$this->dataReadLength = 0;

		return true;
	}

	/**
	 * Processes a compressed file
	 *
	 * @return  boolean
	 * @since   4.0.4
	 */
	private function processTypeFileCompressed(): bool
	{
		// Before processing file data, ensure permissions are adequate
		$this->setCorrectPermissions($this->fileHeader->file);

		// Open the output file
		$outfp = @fopen($this->fileHeader->realFile, 'wb');

		// Can we write to the file?
		$ignore = $this->isIgnoredDirectory($this->fileHeader->file);

		if (($outfp === false) && (!$ignore))
		{
			// An error occurred
			$this->setError(sprintf('Could not open %s for writing.', $this->fileHeader->realFile));

			return false;
		}

		// Does the file have any data, at all?
		if ($this->fileHeader->compressed == 0)
		{
			$this->debugMsg('Zero byte Compressed file; no data will be read', self::LOG_DEBUG);

			// No file data!
			if (is_resource($outfp))
			{
				@fclose($outfp);
			}

			$this->runState = self::AK_STATE_DATAREAD;

			return true;
		}

		// Simple compressed files are processed as a whole; we can't do chunk processing
		$zipData = $this->fread($this->fp, $this->fileHeader->compressed);

		while ($this->binStringLength($zipData) < $this->fileHeader->compressed)
		{
			// End of local file before reading all data?
			if ($this->isEOF())
			{
				$this->debugMsg('EOF reading compressed data', self::LOG_WARNING);
				$this->setError('The archive file is corrupt or truncated');

				return false;
			}
		}

		switch ($this->fileHeader->compression)
		{
			case 'gzip':
				/** @noinspection PhpComposerExtensionStubsInspection */
				$unzipData = gzinflate($zipData);
				break;

			case 'bzip2':
				/** @noinspection PhpComposerExtensionStubsInspection */
				$unzipData = bzdecompress($zipData);
				break;

			default:
				$this->setError(sprintf('Unknown compression method %s', $this->fileHeader->compression));

				return false;
				break;
		}

		unset($zipData);

		// Write to the file.
		if (is_resource($outfp))
		{
			@fwrite($outfp, $unzipData, $this->fileHeader->uncompressed);
			@fclose($outfp);
		}

		unset($unzipData);

		$this->runState = self::AK_STATE_DATAREAD;

		return true;
	}

	/**
	 * Set up the maximum execution time
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function setupMaxExecTime(): void
	{
		$configMaxTime     = self::MAX_EXEC_TIME;
		$bias              = self::RUNTIME_BIAS / 100;
		$this->maxExecTime = min($this->getPhpMaxExecTime(), $configMaxTime) * $bias;
	}

	/**
	 * Get the PHP maximum execution time.
	 *
	 * If it's not defined or it's zero (infinite) we use a fake value of 10 seconds.
	 *
	 * @return  integer
	 * @since   4.0.4
	 */
	private function getPhpMaxExecTime(): int
	{
		if (!@function_exists('ini_get'))
		{
			return 10;
		}

		$phpMaxTime = @ini_get("maximum_execution_time");
		$phpMaxTime = (!is_numeric($phpMaxTime) ? 10 : @intval($phpMaxTime)) ?: 10;

		return max(1, $phpMaxTime);
	}

	/**
	 * Write a message to the debug error log
	 *
	 * @param   string  $message   The message to log
	 * @param   int     $priority  The message's log priority
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function debugMsg(string $message, int $priority = self::LOG_INFO): void
	{
		if (!defined('_JOOMLA_UPDATE_DEBUG'))
		{
			return;
		}

		if (!is_resource(self::$logFP) && !is_bool(self::$logFP))
		{
			self::$logFP = @fopen(self::$logFilePath, 'at');
		}

		if (!is_resource(self::$logFP))
		{
			return;
		}

		switch ($priority)
		{
			case self::LOG_DEBUG:
				$priorityString = 'DEBUG';
				break;

			case self::LOG_INFO:
				$priorityString = 'INFO';
				break;

			case self::LOG_WARNING:
				$priorityString = 'WARNING';
				break;

			case self::LOG_ERROR:
				$priorityString = 'ERROR';
				break;
		}

		fputs(self::$logFP, sprintf('%s | %7s | %s' . "\r\n", gmdate('Y-m-d H:i:s'), $priorityString, $message));
	}

	/**
	 * Initialise the debug log file
	 *
	 * @param   string  $logPath  The path where the log file will be written to
	 *
	 * @return  void
	 * @since   4.0.4
	 */
	private function initializeLog(string $logPath): void
	{
		if (!defined('_JOOMLA_UPDATE_DEBUG'))
		{
			return;
		}

		$logPath = $logPath ?: dirname($this->filename);
		$logFile = rtrim($logPath, '/' . DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'joomla_update.txt';

		self::$logFilePath = $logFile;
	}
}

// Skip over the mini-controller for testing purposes
if (defined('_JOOMLA_UPDATE_TESTING'))
{
	return;
}

/**
 * Invalidate a file in OPcache.
 *
 * Only applies if the file has a .php extension.
 *
 * @param   string  $file  The filepath to clear from OPcache
 *
 * @return  boolean
 * @since   4.0.4
 */
function clearFileInOPCache(string $file): bool
{
	static $hasOpCache = null;

	if (is_null($hasOpCache))
	{
		$hasOpCache = ini_get('opcache.enable')
			&& function_exists('opcache_invalidate')
			&& (!ini_get('opcache.restrict_api') || stripos(realpath($_SERVER['SCRIPT_FILENAME']), ini_get('opcache.restrict_api')) === 0);
	}

	if ($hasOpCache && (strtolower(substr($file, -4)) === '.php'))
	{
		return opcache_invalidate($file, true);
	}

	return false;
}

/**
 * A timing safe equals comparison.
 *
 * Uses the built-in hash_equals() method if it exists. It SHOULD exist, as it's available since PHP 5.6 whereas even
 * Joomla 4.0 requires PHP 7.2 or later. If for any reason the built-in function is not available (for example, a host
 * has disabled it because they do not understand the first thing about security) we will fall back to a safe, userland
 * implementation.
 *
 * @param   string  $known  The known value to check against
 * @param   string  $user   The user submitted value to check
 *
 * @return  boolean  True if the two strings are identical.
 * @since   4.0.4
 *
 * @see     http://blog.ircmaxell.com/2014/11/its-all-about-time.html
 */
function timingSafeEquals($known, $user)
{
	if (function_exists('hash_equals'))
	{
		return hash_equals($known, $user);
	}

	$safeLen = strlen($known);
	$userLen = strlen($user);

	if ($userLen != $safeLen)
	{
		return false;
	}

	$result = 0;

	for ($i = 0; $i < $userLen; $i++)
	{
		$result |= (ord($known[$i]) ^ ord($user[$i]));
	}

	// They are only identical strings if $result is exactly 0...
	return $result === 0;
}

/**
 * Gets the configuration parameters from the update.php file and validates the password sent with
 * the request.
 *
 * @return  array|null  The configuration parameters to use. NULL if this is an invalid request.
 * @since   4.0.4
 */
function getConfiguration(): ?array
{
	// Make sure the locale is correct for basename() to work
	if (function_exists('setlocale'))
	{
		@setlocale(LC_ALL, 'en_US.UTF8');
	}

	// Require update.php or fail
	$setupFile = __DIR__ . '/update.php';

	if (!file_exists($setupFile))
	{
		return null;
	}

	/**
	 * If the setup file was created more than 1.5 hours ago we can assume that it's stale and someone forgot to
	 * remove it from the server.
	 *
	 * This prevents brute force attacks against the randomly generated password. Even a simple 8 character simple
	 * alphanum (a-z, 0-9) password yields over 2.8e12 permutation. Assuming a very fast server which can
	 * serve 100 requests to extract.php per second and an easy to attack password requiring going over just 1% of
	 * the search space it'd still take over 282 million seconds to brute force it. Our limit is more than 4 orders
	 * of magnitude lower than this best practical case scenario, giving us adequate protection against all but the
	 * luckiest attacker (spoiler alert: the mathematics of probabilities say you're not gonna get too lucky).
	 *
	 * It is still advisable to remove the update.php file once you are done with the extraction. This check
	 * here is only meant as a failsafe in case of a server error during the extraction and subsequent lack of user
	 * action to remove the update.php file from their server.
	 */
	clearstatcache(true);
	$setupFileCreationTime = filectime($setupFile);

	if (abs(time() - $setupFileCreationTime) > 5400)
	{
		return null;
	}

	// Load update.php. It pulls a variable named $restoration_setup into the local scope.
	clearFileInOPCache($setupFile);

	require_once $setupFile;

	/** @var array $extractionSetup */

	// The file exists but no configuration is present?
	if (empty($extractionSetup ?? null) || !is_array($extractionSetup))
	{
		return null;
	}

	/**
	 * Immediately reject any attempt to run extract.php without a password.
	 *
	 * Doing that is a GRAVE SECURITY RISK. It makes it trivial to hack a site. Therefore we are preventing this script
	 * to run without a password.
	 */
	$password     = $extractionSetup['security.password'] ?? null;
	$userPassword = $_REQUEST['password'] ?? '';
	$userPassword = !is_string($userPassword) ? '' : trim($userPassword);

	if (empty($password) || !is_string($password) || (trim($password) == '') || (strlen(trim($password)) < 32))
	{
		return null;
	}

	// Timing-safe password comparison. See http://blog.ircmaxell.com/2014/11/its-all-about-time.html
	if (!timingSafeEquals($password, $userPassword))
	{
		return null;
	}

	// An "instance" variable will resume the engine from the serialised instance
	$serialized = $_REQUEST['instance'] ?? null;

	if (!is_null($serialized) && empty(ZIPExtraction::unserialiseInstance($serialized)))
	{
		// The serialised instance is corrupt or someone tries to trick us. YOU SHALL NOT PASS!
		return null;
	}

	return $extractionSetup;
}

// Import configuration
$retArray = [
	'status'  => true,
	'message' => null,
];

$configuration = getConfiguration();
$enabled       = !empty($configuration);

if ($enabled)
{
	$sourcePath = $configuration['setup.sourcepath'] ?? '';
	$sourceFile = $configuration['setup.sourcefile'] ?? '';
	$destDir    = ($configuration['setup.destdir'] ?? null) ?: __DIR__;
	$basePath   = rtrim(str_replace('\\', '/', __DIR__), '/');
	$basePath   = empty($basePath) ? $basePath : ($basePath . '/');
	$sourceFile = (empty($sourcePath) ? '' : (rtrim($sourcePath, '/\\') . '/')) . $sourceFile;
	$engine     = ZIPExtraction::getInstance();

	$engine->setFilename($sourceFile);
	$engine->setAddPath($destDir);
	$skipFiles = [
		'administrator/components/com_joomlaupdate/restoration.php',
		'administrator/components/com_joomlaupdate/update.php',
	];

	if (defined('_JOOMLA_UPDATE_DEBUG'))
	{
		$skipFiles[] = 'administrator/components/com_joomlaupdate/extract.php';
	}

	$engine->setSkipFiles($skipFiles
	);
	$engine->setIgnoreDirectories([
			'tmp', 'administrator/logs',
		]
	);

	$task = $_REQUEST['task'] ?? null;

	switch ($task)
	{
		case 'startExtract':
		case 'stepExtract':
			$done  = $engine->step();
			$error = $engine->getError();

			if ($error != '')
			{
				$retArray['status']  = false;
				$retArray['done']    = true;
				$retArray['message'] = $error;
			}
			elseif ($done)
			{
				$retArray['files']    = $engine->filesProcessed;
				$retArray['bytesIn']  = $engine->compressedTotal;
				$retArray['bytesOut'] = $engine->uncompressedTotal;
				$retArray['percent']  = 100;
				$retArray['status']   = true;
				$retArray['done']     = true;

				$retArray['percent'] = min($retArray['percent'], 100);
			}
			else
			{
				$retArray['files']    = $engine->filesProcessed;
				$retArray['bytesIn']  = $engine->compressedTotal;
				$retArray['bytesOut'] = $engine->uncompressedTotal;
				$retArray['percent']  = ($engine->totalSize > 0) ? (100 * $engine->compressedTotal / $engine->totalSize) : 0;
				$retArray['status']   = true;
				$retArray['done']     = false;
				$retArray['instance'] = ZIPExtraction::getSerialised();
			}

			$engine->enforceMinimumExecutionTime();

			break;

		case 'finalizeUpdate':
			$root = $configuration['setup.destdir'] ?? '';

			// Remove update.php
			clearFileInOPCache($basePath . 'update.php');
			@unlink($basePath . 'update.php');

			// Import a custom finalisation file
			$filename = dirname(__FILE__) . '/finalisation.php';

			if (file_exists($filename))
			{
				clearFileInOPCache($filename);

				include_once $filename;
			}

			// Run a custom finalisation script
			if (function_exists('finalizeUpdate'))
			{
				finalizeUpdate($root, $basePath);
			}

			$engine->enforceMinimumExecutionTime();

			break;

		default:
			// Invalid task!
			$enabled = false;
			break;
	}
}

// This could happen even if $enabled was true, e.g. if we were asked for an invalid task.
if (!$enabled)
{
	// Maybe we weren't authorized or the task was invalid?
	$retArray['status']  = false;
	$retArray['message'] = 'Invalid login';
}

// JSON encode the message
echo json_encode($retArray);
