<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Contao\CoreBundle\Exception\ResponseException;

/**
 * Provide methods to handle versioning.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class Versions extends Controller
{

	/**
	 * Table
	 * @var string
	 */
	protected $strTable;

	/**
	 * Parent ID
	 * @var integer
	 */
	protected $intPid;

	/**
	 * Edit URL
	 * @var string
	 */
	protected $strEditUrl;

	/**
	 * Username
	 * @var string
	 */
	protected $strUsername;

	/**
	 * User ID
	 * @var integer
	 */
	protected $intUserId;

	/**
	 * Initialize the object
	 *
	 * @param string  $strTable
	 * @param integer $intPid
	 *
	 * @throws \InvalidArgumentException
	 */
	public function __construct($strTable, $intPid)
	{
		$this->import(Database::class, 'Database');
		parent::__construct();

		$this->loadDataContainer($strTable);

		if (!isset($GLOBALS['TL_DCA'][$strTable])) {
			throw new \InvalidArgumentException(sprintf('"%s" is not a valid table', StringUtil::specialchars($strTable)));
		}

		$this->strTable = $strTable;
		$this->intPid = (int) $intPid;
	}

	/**
	 * Set the edit URL
	 *
	 * @param string $strEditUrl
	 */
	public function setEditUrl($strEditUrl)
	{
		$this->strEditUrl = $strEditUrl;
	}

	/**
	 * Set the username
	 *
	 * @param string $strUsername
	 */
	public function setUsername($strUsername)
	{
		$this->strUsername = $strUsername;
	}

	/**
	 * Set the user ID
	 *
	 * @param integer $intUserId
	 */
	public function setUserId($intUserId)
	{
		$this->intUserId = $intUserId;
	}

	/**
	 * Returns the latest version
	 *
	 * @return integer|null
	 */
	public function getLatestVersion()
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return null;
		}

		$objVersion = $this->Database->prepare("SELECT MAX(version) AS version FROM tl_version WHERE fromTable=? AND pid=?")
									 ->limit(1)
									 ->execute($this->strTable, $this->intPid);

		return (int) $objVersion->version;
	}

	/**
	 * Create the initial version of a record
	 */
	public function initialize()
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return;
		}

		$objVersion = $this->Database->prepare("SELECT COUNT(*) AS count FROM tl_version WHERE fromTable=? AND pid=?")
									 ->limit(1)
									 ->execute($this->strTable, $this->intPid);

		if ($objVersion->count > 0)
		{
			return;
		}

		$this->create();
	}

	/**
	 * Create a new version of a record
	 */
	public function create()
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return;
		}

		// Delete old versions from the database
		$tstamp = time() - (int) Config::get('versionPeriod');
		$this->Database->query("DELETE FROM tl_version WHERE tstamp<$tstamp");

		// Get the new record
		$objRecord = $this->Database->prepare("SELECT * FROM " . $this->strTable . " WHERE id=?")
									->limit(1)
									->execute($this->intPid);

		if ($objRecord->numRows < 1 || $objRecord->tstamp < 1)
		{
			return;
		}

		// Store the content if it is an editable file
		if ($this->strTable == 'tl_files')
		{
			$objModel = FilesModel::findByPk($this->intPid);

			if ($objModel !== null && \in_array($objModel->extension, StringUtil::trimsplit(',', strtolower(Config::get('editableFiles')))))
			{
				$objFile = new File($objModel->path);

				if ($objFile->extension == 'svgz')
				{
					$objRecord->content = gzdecode($objFile->getContent());
				}
				else
				{
					$objRecord->content = $objFile->getContent();
				}
			}
		}

		$intVersion = 1;

		$objVersion = $this->Database->prepare("SELECT MAX(version) AS version FROM tl_version WHERE pid=? AND fromTable=?")
									 ->execute($this->intPid, $this->strTable);

		if ($objVersion->version !== null)
		{
			$intVersion = $objVersion->version + 1;
		}

		$strDescription = '';

		if (!empty($objRecord->title))
		{
			$strDescription = $objRecord->title;
		}
		elseif (!empty($objRecord->name))
		{
			$strDescription = $objRecord->name;
		}
		elseif (!empty($objRecord->firstname))
		{
			$strDescription = $objRecord->firstname . ' ' . $objRecord->lastname;
		}
		elseif (!empty($objRecord->headline))
		{
			$chunks = StringUtil::deserialize($objRecord->headline);

			if (\is_array($chunks) && isset($chunks['value']))
			{
				$strDescription = $chunks['value'];
			}
			else
			{
				$strDescription = $objRecord->headline;
			}
		}
		elseif (!empty($objRecord->selector))
		{
			$strDescription = $objRecord->selector;
		}
		elseif (!empty($objRecord->subject))
		{
			$strDescription = $objRecord->subject;
		}

		$this->Database->prepare("UPDATE tl_version SET active='' WHERE pid=? AND fromTable=?")
					   ->execute($this->intPid, $this->strTable);

		$this->Database->prepare("INSERT INTO tl_version (pid, tstamp, version, fromTable, username, userid, description, editUrl, active, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)")
					   ->execute($this->intPid, time(), $intVersion, $this->strTable, $this->getUsername(), $this->getUserId(), $strDescription, $this->getEditUrl(), serialize($objRecord->row()));

		// Trigger the oncreate_version_callback
		if (\is_array($GLOBALS['TL_DCA'][$this->strTable]['config']['oncreate_version_callback']))
		{
			foreach ($GLOBALS['TL_DCA'][$this->strTable]['config']['oncreate_version_callback'] as $callback)
			{
				if (\is_array($callback))
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($this->strTable, $this->intPid, $intVersion, $objRecord->row());
				}
				elseif (\is_callable($callback))
				{
					$callback($this->strTable, $this->intPid, $intVersion, $objRecord->row());
				}
			}
		}

		$this->log('Version '.$intVersion.' of record "'.$this->strTable.'.id='.$this->intPid.'" has been created'.$this->getParentEntries($this->strTable, $this->intPid), __METHOD__, TL_GENERAL);
	}

	/**
	 * Restore a version
	 *
	 * @param integer $intVersion
	 */
	public function restore($intVersion)
	{
		if (!$GLOBALS['TL_DCA'][$this->strTable]['config']['enableVersioning'])
		{
			return;
		}

		$objData = $this->Database->prepare("SELECT * FROM tl_version WHERE fromTable=? AND pid=? AND version=?")
								  ->limit(1)
								  ->execute($this->strTable, $this->intPid, $intVersion);

		if ($objData->numRows < 1)
		{
			return;
		}

		$data = StringUtil::deserialize($objData->data);

		if (!\is_array($data))
		{
			return;
		}

		// Restore the content if it is an editable file
		if ($this->strTable == 'tl_files')
		{
			$objModel = FilesModel::findByPk($this->intPid);

			if ($objModel !== null && \in_array($objModel->extension, StringUtil::trimsplit(',', strtolower(Config::get('editableFiles')))))
			{
				$objFile = new File($objModel->path);

				if ($objFile->extension == 'svgz')
				{
					$objFile->write(gzencode($data['content']));
				}
				else
				{
					$objFile->write($data['content']);
				}

				$objFile->close();
			}
		}

		// Get the currently available fields
		$arrFields = array_flip($this->Database->getFieldNames($this->strTable));

		// Unset fields that do not exist (see #5219)
		$data = array_intersect_key($data, $arrFields);

		// Reset fields added after storing the version to their default value (see #7755)
		foreach (array_diff_key($arrFields, $data) as $k=>$v)
		{
			$data[$k] = Widget::getEmptyValueByFieldType($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['sql']);
		}

		$this->Database->prepare("UPDATE " . $this->strTable . " %s WHERE id=?")
					   ->set($data)
					   ->execute($this->intPid);

		$this->Database->prepare("UPDATE tl_version SET active='' WHERE fromTable=? AND pid=?")
					   ->execute($this->strTable, $this->intPid);

		$this->Database->prepare("UPDATE tl_version SET active=1 WHERE fromTable=? AND pid=? AND version=?")
					   ->execute($this->strTable, $this->intPid, $intVersion);

		// Trigger the onrestore_version_callback
		if (\is_array($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_version_callback']))
		{
			foreach ($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_version_callback'] as $callback)
			{
				if (\is_array($callback))
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($this->strTable, $this->intPid, $intVersion, $data);
				}
				elseif (\is_callable($callback))
				{
					$callback($this->strTable, $this->intPid, $intVersion, $data);
				}
			}
		}

		// Trigger the deprecated onrestore_callback
		if (\is_array($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_callback']))
		{
			@trigger_error('Using the onrestore_callback has been deprecated and will no longer work in Contao 5.0. Use the onrestore_version_callback instead.', E_USER_DEPRECATED);

			foreach ($GLOBALS['TL_DCA'][$this->strTable]['config']['onrestore_callback'] as $callback)
			{
				if (\is_array($callback))
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($this->intPid, $this->strTable, $data, $intVersion);
				}
				elseif (\is_callable($callback))
				{
					$callback($this->intPid, $this->strTable, $data, $intVersion);
				}
			}
		}

		$this->log('Version '.$intVersion.' of record "'.$this->strTable.'.id='.$this->intPid.'" has been restored'.$this->getParentEntries($this->strTable, $this->intPid), __METHOD__, TL_GENERAL);
	}

	/**
	 * Compare versions
	 *
	 * @param bool $blnReturnBuffer
	 *
	 * @return string
	 *
	 * @throws ResponseException
	 */
	public function compare($blnReturnBuffer=false)
	{
		$strBuffer = '';
		$arrVersions = array();
		$intTo = 0;
		$intFrom = 0;

		$objVersions = $this->Database->prepare("SELECT * FROM tl_version WHERE pid=? AND fromTable=? ORDER BY version DESC")
									  ->execute($this->intPid, $this->strTable);

		if ($objVersions->numRows < 2)
		{
			$strBuffer = '
There are no versions of ' . $this->strTable . '.id=' . $this->intPid . '
';
		}
		else
		{
			$intIndex = 0;
			$from = array();

			// Store the versions and mark the active one
			while ($objVersions->next())
			{
				if ($objVersions->active)
				{
					$intIndex = $objVersions->version;
				}

				$arrVersions[$objVersions->version] = $objVersions->row();
				$arrVersions[$objVersions->version]['info'] = $GLOBALS['TL_LANG']['MSC']['version'].' '.$objVersions->version.' ('.Date::parse(Config::get('datimFormat'), $objVersions->tstamp).') '.$objVersions->username;
			}

			// To
			if (Input::post('to') && isset($arrVersions[Input::post('to')]))
			{
				$intTo = Input::post('to');
				$to = StringUtil::deserialize($arrVersions[Input::post('to')]['data']);
			}
			elseif (Input::get('to') && isset($arrVersions[Input::get('to')]))
			{
				$intTo = Input::get('to');
				$to = StringUtil::deserialize($arrVersions[Input::get('to')]['data']);
			}
			else
			{
				$intTo = $intIndex;
				$to = StringUtil::deserialize($arrVersions[$intTo]['data']);
			}

			// From
			if (Input::post('from') && isset($arrVersions[Input::post('from')]))
			{
				$intFrom = Input::post('from');
				$from = StringUtil::deserialize($arrVersions[Input::post('from')]['data']);
			}
			elseif (Input::get('from') && isset($arrVersions[Input::get('from')]))
			{
				$intFrom = Input::get('from');
				$from = StringUtil::deserialize($arrVersions[Input::get('from')]['data']);
			}
			elseif ($objVersions->numRows > $intIndex)
			{
				$intFrom = $objVersions->first()->version;
				$from = StringUtil::deserialize($arrVersions[$intFrom]['data']);
			}
			elseif ($intIndex > 1)
			{
				$intFrom = $intIndex - 1;
				$from = StringUtil::deserialize($arrVersions[$intFrom]['data']);
			}

			// Only continue if both version numbers are set
			if ($intTo > 0 && $intFrom > 0)
			{
				System::loadLanguageFile($this->strTable);

				// Get the order fields
				$objDcaExtractor = DcaExtractor::getInstance($this->strTable);
				$arrOrder = $objDcaExtractor->getOrderFields();

				// Find the changed fields and highlight the changes
				foreach ($to as $k=>$v)
				{
					if ($from[$k] != $to[$k])
					{
						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['doNotShow'] || $GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['hideInput'])
						{
							continue;
						}

						$blnIsBinary = ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['inputType'] == 'fileTree' || \in_array($k, $arrOrder));

						// Decrypt the values
						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['encrypt'])
						{
							$to[$k] = Encryption::decrypt($to[$k]);
							$from[$k] = Encryption::decrypt($from[$k]);
						}

						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['multiple'])
						{
							if (isset($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['csv']))
							{
								$delimiter = $GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['csv'];

								if (isset($to[$k]))
								{
									$to[$k] = preg_replace('/' . preg_quote($delimiter, ' ?/') . '/', $delimiter . ' ', $to[$k]);
								}
								if (isset($from[$k]))
								{
									$from[$k] = preg_replace('/' . preg_quote($delimiter, ' ?/') . '/', $delimiter . ' ', $from[$k]);
								}
							}
							else
							{
								// Convert serialized arrays into strings
								if (\is_array(($tmp = StringUtil::deserialize($to[$k]))) && !\is_array($to[$k]))
								{
									$to[$k] = $this->implodeRecursive($tmp, $blnIsBinary);
								}
								if (\is_array(($tmp = StringUtil::deserialize($from[$k]))) && !\is_array($from[$k]))
								{
									$from[$k] = $this->implodeRecursive($tmp, $blnIsBinary);
								}
							}
						}

						unset($tmp);

						// Convert binary UUIDs to their hex equivalents (see #6365)
						if ($blnIsBinary)
						{
							if (Validator::isBinaryUuid($to[$k]))
							{
								$to[$k] = StringUtil::binToUuid($to[$k]);
							}
							if (Validator::isBinaryUuid($from[$k]))
							{
								$to[$k] = StringUtil::binToUuid($from[$k]);
							}
						}

						// Convert date fields
						if ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['rgxp'] == 'date')
						{
							$to[$k] = Date::parse(Config::get('dateFormat'), $to[$k] ?: '');
							$from[$k] = Date::parse(Config::get('dateFormat'), $from[$k] ?: '');
						}
						elseif ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['rgxp'] == 'time')
						{
							$to[$k] = Date::parse(Config::get('timeFormat'), $to[$k] ?: '');
							$from[$k] = Date::parse(Config::get('timeFormat'), $from[$k] ?: '');
						}
						elseif ($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['eval']['rgxp'] == 'datim' || $k == 'tstamp')
						{
							$to[$k] = Date::parse(Config::get('datimFormat'), $to[$k] ?: '');
							$from[$k] = Date::parse(Config::get('datimFormat'), $from[$k] ?: '');
						}

						// Convert strings into arrays
						if (!\is_array($to[$k]))
						{
							$to[$k] = explode("\n", $to[$k]);
						}
						if (!\is_array($from[$k]))
						{
							$from[$k] = explode("\n", $from[$k]);
						}

						$objDiff = new \Diff($from[$k], $to[$k]);
						$strBuffer .= $objDiff->render(new DiffRenderer(array('field'=>($GLOBALS['TL_DCA'][$this->strTable]['fields'][$k]['label'][0] ?: (isset($GLOBALS['TL_LANG']['MSC'][$k]) ? (\is_array($GLOBALS['TL_LANG']['MSC'][$k]) ? $GLOBALS['TL_LANG']['MSC'][$k][0] : $GLOBALS['TL_LANG']['MSC'][$k]) : $k)))));
					}
				}
			}
		}

		// Identical versions
		if ($strBuffer == '')
		{
			$strBuffer = '
'.$GLOBALS['TL_LANG']['MSC']['identicalVersions'].'
';
		}

		if ($blnReturnBuffer)
		{
			return $strBuffer;
		}

		$objTemplate = new BackendTemplate('be_diff');
		$objTemplate->content = $strBuffer;
		$objTemplate->versions = $arrVersions;
		$objTemplate->to = $intTo;
		$objTemplate->from = $intFrom;
		$objTemplate->showLabel = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['showDifferences']);
		$objTemplate->theme = Backend::getTheme();
		$objTemplate->base = Environment::get('base');
		$objTemplate->language = $GLOBALS['TL_LANGUAGE'];
		$objTemplate->title = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['showDifferences']);
		$objTemplate->charset = Config::get('characterSet');
		$objTemplate->action = ampersand(Environment::get('request'));

		throw new ResponseException($objTemplate->getResponse());
	}

	/**
	 * Render the versions dropdown menu
	 *
	 * @return string
	 */
	public function renderDropdown()
	{
		$objVersion = $this->Database->prepare("SELECT tstamp, version, username, active FROM tl_version WHERE fromTable=? AND pid=? ORDER BY version DESC")
								     ->execute($this->strTable, $this->intPid);

		if ($objVersion->numRows < 2)
		{
			return '';
		}

		$versions = '';

		while ($objVersion->next())
		{
			$versions .= '
  <option value="'.$objVersion->version.'"'.($objVersion->active ? ' selected="selected"' : '').'>'.$GLOBALS['TL_LANG']['MSC']['version'].' '.$objVersion->version.' ('.Date::parse(Config::get('datimFormat'), $objVersion->tstamp).') '.$objVersion->username.'</option>';
		}

		return '

<form action="'.ampersand(Environment::get('request'), true).'" id="tl_version" class="tl_form" method="post" aria-label="'.StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['versioning']).'">

<input type="hidden" name="FORM_SUBMIT" value="tl_version">
<input type="hidden" name="REQUEST_TOKEN" value="'.REQUEST_TOKEN.'">
<select name="version" class="tl_select">'.$versions.'
</select>
<button type="submit" name="showVersion" id="showVersion" class="tl_submit">'.$GLOBALS['TL_LANG']['MSC']['restore'].'</button>
intPid, $this->strTable))).'\',\'url\':this.href});return false">'.Image::getHtml('diff.svg').'

</form>

';
	}

	/**
	 * Add a list of versions to a template
	 *
	 * @param BackendTemplate $objTemplate
	 */
	public static function addToTemplate(BackendTemplate $objTemplate)
	{
		$arrVersions = array();

		$objUser = BackendUser::getInstance();
		$objDatabase = Database::getInstance();

		// Get the total number of versions
		$objTotal = $objDatabase->prepare("SELECT COUNT(*) AS count FROM tl_version WHERE version>1 AND editUrl IS NOT NULL" . (!$objUser->isAdmin ? " AND userid=?" : ""))
								->execute($objUser->id);

		$intLast   = ceil($objTotal->count / 30);
		$intPage   = Input::get('vp') ?? 1;
		$intOffset = ($intPage - 1) * 30;

		// Validate the page number
		if ($intPage < 1 || ($intLast > 0 && $intPage > $intLast))
		{
			header('HTTP/1.1 404 Not Found');
		}

		// Create the pagination menu
		$objPagination = new Pagination($objTotal->count, 30, 7, 'vp', new BackendTemplate('be_pagination'));
		$objTemplate->pagination = $objPagination->generate();

		// Get the versions
		$objVersions = $objDatabase->prepare("SELECT pid, tstamp, version, fromTable, username, userid, description, editUrl, active FROM tl_version WHERE editUrl IS NOT NULL" . (!$objUser->isAdmin ? " AND userid=?" : "") . " ORDER BY tstamp DESC, pid, version DESC")
								   ->limit(30, $intOffset)
								   ->execute($objUser->id);

		while ($objVersions->next())
		{
			// Hide profile changes if the user does not have access to the "user" module (see #1309)
			if (!$objUser->isAdmin && $objVersions->fromTable == 'tl_user' && !$objUser->hasAccess('user', 'modules'))
			{
				continue;
			}

			$arrRow = $objVersions->row();

			// Add some parameters
			$arrRow['from'] = max(($objVersions->version - 1), 1); // see #4828
			$arrRow['to'] = $objVersions->version;
			$arrRow['date'] = date(Config::get('datimFormat'), $objVersions->tstamp);
			$arrRow['description'] = StringUtil::substr($arrRow['description'], 32);
			$arrRow['shortTable'] = StringUtil::substr($arrRow['fromTable'], 18); // see #5769

			if ($arrRow['editUrl'] != '')
			{
				$arrRow['editUrl'] = preg_replace(array('/&(amp;)?popup=1/', '/&(amp;)?rt=[^&]+/'), array('', '&rt=' . REQUEST_TOKEN), ampersand($arrRow['editUrl']));
			}

			$arrVersions[] = $arrRow;
		}

		$intCount = -1;
		$arrVersions = array_values($arrVersions);

		// Add the "even" and "odd" classes
		foreach ($arrVersions as $k=>$v)
		{
			$arrVersions[$k]['class'] = (++$intCount % 2 == 0) ? 'even' : 'odd';

			try
			{
				// Mark deleted versions (see #4336)
				$objDeleted = $objDatabase->prepare("SELECT COUNT(*) AS count FROM " . $v['fromTable'] . " WHERE id=?")
										  ->execute($v['pid']);

				$arrVersions[$k]['deleted'] = ($objDeleted->count < 1);
			}
			catch (\Exception $e)
			{
				// Probably a disabled module
				--$intCount;
				unset($arrVersions[$k]);
			}

			// Skip deleted files (see #8480)
			if ($v['fromTable'] == 'tl_files' && $arrVersions[$k]['deleted'])
			{
				--$intCount;
				unset($arrVersions[$k]);
			}
		}

		$objTemplate->versions = $arrVersions;
	}

	/**
	 * Return the edit URL
	 *
	 * @return string
	 */
	protected function getEditUrl()
	{
		if ($this->strEditUrl !== null)
		{
			return sprintf($this->strEditUrl, $this->intPid);
		}

		$strUrl = Environment::get('request');

		// Save the real edit URL if the visibility is toggled via Ajax
		if (preg_match('/&(amp;)?state=/', $strUrl))
		{
			$strUrl = preg_replace
			(
				array('/&(amp;)?id=[^&]+/', '/(&(amp;)?)t(id=[^&]+)/', '/(&(amp;)?)state=[^&]*/'),
				array('', '$1$3', '$1act=edit'), $strUrl
			);
		}

		// Adjust the URL of the "personal data" module (see #7987)
		if (preg_match('/do=login(&|$)/', $strUrl))
		{
			$strUrl = preg_replace('/do=login(&|$)/', 'do=user$1', $strUrl);
			$strUrl .= '&act=edit&id=' . $this->User->id . '&rt=' . REQUEST_TOKEN;
		}

		// Correct the URL in "edit|override multiple" mode (see #7745)
		$strUrl = preg_replace('/act=(edit|override)All/', 'act=edit&id=' . $this->intPid, $strUrl);

		return $strUrl;
	}

	/**
	 * Return the username
	 *
	 * @return string
	 */
	protected function getUsername()
	{
		if ($this->strUsername !== null)
		{
			return $this->strUsername;
		}

		$this->import(BackendUser::class, 'User');

		return $this->User->username;
	}

	/**
	 * Return the user ID
	 *
	 * @return string
	 */
	protected function getUserId()
	{
		if ($this->intUserId !== null)
		{
			return $this->intUserId;
		}

		$this->import(BackendUser::class, 'User');

		return $this->User->id;
	}

	/**
	 * Implode a multi-dimensional array recursively
	 *
	 * @param mixed   $var
	 * @param boolean $binary
	 *
	 * @return string
	 */
	protected function implodeRecursive($var, $binary=false)
	{
		if (!\is_array($var))
		{
			return $binary ? StringUtil::binToUuid($var) : $var;
		}
		elseif (!\is_array(current($var)))
		{
			if ($binary)
			{
				$var = array_map(function ($v) { return $v ? StringUtil::binToUuid($v) : ''; }, $var);
			}

			return implode(', ', $var);
		}
		else
		{
			$buffer = '';

			foreach ($var as $k=>$v)
			{
				$buffer .= $k . ": " . $this->implodeRecursive($v) . "\n";
			}

			return trim($buffer);
		}
	}
}

class_alias(Versions::class, 'Versions');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Contao\CoreBundle\Exception\ResponseException;
use Contao\Image\Image as ContaoImage;
use Contao\Image\ImageDimensions;
use Patchwork\Utf8;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;

/**
 * Creates, reads, writes and deletes files
 *
 * Usage:
 *
 *     $file = new File('test.txt');
 *     $file->write('This is a test');
 *     $file->close();
 *
 *     $file->delete();
 *
 *     File::putContent('test.txt', 'This is a test');
 *
 * @property integer  $size          The file size
 * @property integer  $filesize      Alias of $size
 * @property string   $name          The file name and extension
 * @property string   $basename      Alias of $name
 * @property string   $dirname       The path of the parent folder
 * @property string   $extension     The lowercase file extension
 * @property string   $origext       The original file extension
 * @property string   $filename      The file name without extension
 * @property string   $tmpname       The name of the temporary file
 * @property string   $path          The file path
 * @property string   $value         Alias of $path
 * @property string   $mime          The mime type
 * @property string   $hash          The MD5 checksum
 * @property string   $ctime         The ctime
 * @property string   $mtime         The mtime
 * @property string   $atime         The atime
 * @property string   $icon          The mime icon name
 * @property string   $dataUri       The data URI
 * @property array    $imageSize     The file dimensions (images only)
 * @property integer  $width         The file width (images only)
 * @property integer  $height        The file height (images only)
 * @property array    $imageViewSize The viewbox dimensions
 * @property integer  $viewWidth     The viewbox width
 * @property integer  $viewHeight    The viewbox height
 * @property boolean  $isImage       True if the file is an image
 * @property boolean  $isGdImage     True if the file can be handled by the GDlib
 * @property boolean  $isSvgImage    True if the file is an SVG image
 * @property integer  $channels      The number of channels (images only)
 * @property integer  $bits          The number of bits for each color (images only)
 * @property boolean  $isRgbImage    True if the file is an RGB image
 * @property boolean  $isCmykImage   True if the file is a CMYK image
 * @property resource $handle        The file handle (returned by fopen())
 * @property string   $title         The file title
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class File extends System
{

	/**
	 * File handle
	 * @var resource
	 */
	protected $resFile;

	/**
	 * File name
	 * @var string
	 */
	protected $strFile;

	/**
	 * Temp name
	 * @var string
	 */
	protected $strTmp;

	/**
	 * Files model
	 * @var FilesModel
	 */
	protected $objModel;

	/**
	 * Root dir
	 * @var string
	 */
	protected $strRootDir;

	/**
	 * Pathinfo
	 * @var array
	 */
	protected $arrPathinfo = array();

	/**
	 * Image size
	 * @var array
	 */
	protected $arrImageSize = array();

	/**
	 * Image size runtime cache
	 * @var array
	 */
	protected static $arrImageSizeCache = array();

	/**
	 * Image view size
	 * @var array
	 */
	protected $arrImageViewSize = array();

	/**
	 * Instantiate a new file object
	 *
	 * @param string $strFile The file path
	 *
	 * @throws \Exception If $strFile is a directory
	 */
	public function __construct($strFile)
	{
		// No parent::__construct() here

		// Handle open_basedir restrictions
		if ($strFile == '.')
		{
			$strFile = '';
		}

		$this->strRootDir = System::getContainer()->getParameter('kernel.project_dir');

		// Make sure we are not pointing to a directory
		if (is_dir($this->strRootDir . '/' . $strFile))
		{
			throw new \Exception(sprintf('Directory "%s" is not a file', $strFile));
		}

		$this->import(Files::class, 'Files');

		$this->strFile = $strFile;
	}

	/**
	 * Close the file handle if it has not been done yet
	 */
	public function __destruct()
	{
		if (\is_resource($this->resFile))
		{
			$this->Files->fclose($this->resFile);
		}
	}

	/**
	 * Return an object property
	 *
	 * @param string $strKey The property name
	 *
	 * @return mixed The property value
	 */
	public function __get($strKey)
	{
		switch ($strKey)
		{
			case 'size':
			case 'filesize':
				return filesize($this->strRootDir . '/' . $this->strFile);
				break;

			case 'name':
			case 'basename':
				if (!isset($this->arrPathinfo[$strKey]))
				{
					$this->arrPathinfo = $this->getPathinfo();
				}

				return $this->arrPathinfo['basename'];
				break;

			case 'dirname':
			case 'filename':
				if (!isset($this->arrPathinfo[$strKey]))
				{
					$this->arrPathinfo = $this->getPathinfo();
				}

				return $this->arrPathinfo[$strKey];

			case 'extension':
				if (!isset($this->arrPathinfo['extension']))
				{
					$this->arrPathinfo = $this->getPathinfo();
				}

				return strtolower($this->arrPathinfo['extension']);
				break;

			case 'origext':
				if (!isset($this->arrPathinfo['extension']))
				{
					$this->arrPathinfo = $this->getPathinfo();
				}

				return $this->arrPathinfo['extension'];
				break;

			case 'tmpname':
				return basename($this->strTmp);
				break;

			case 'path':
			case 'value':
				return $this->strFile;
				break;

			case 'mime':
				return $this->getMimeType();
				break;

			case 'hash':
				return $this->getHash();
				break;

			case 'ctime':
				return filectime($this->strRootDir . '/' . $this->strFile);
				break;

			case 'mtime':
				return filemtime($this->strRootDir . '/' . $this->strFile);
				break;

			case 'atime':
				return fileatime($this->strRootDir . '/' . $this->strFile);
				break;

			case 'icon':
				return $this->getIcon();
				break;

			case 'dataUri':
				if ($this->extension == 'svgz')
				{
					return 'data:' . $this->mime . ';base64,' . base64_encode(gzdecode($this->getContent()));
				}
				else
				{
					return 'data:' . $this->mime . ';base64,' . base64_encode($this->getContent());
				}
				break;

			case 'imageSize':
				if (empty($this->arrImageSize))
				{
					$strCacheKey = $this->strFile . '|' . $this->mtime;

					if (isset(static::$arrImageSizeCache[$strCacheKey]))
					{
						$this->arrImageSize = static::$arrImageSizeCache[$strCacheKey];
					}
					elseif ($this->isGdImage)
					{
						$this->arrImageSize = @getimagesize($this->strRootDir . '/' . $this->strFile);
					}
					elseif ($this->isSvgImage)
					{
						try
						{
							$dimensions = (new ContaoImage($this->strRootDir . '/' . $this->strFile, System::getContainer()->get('contao.image.imagine_svg')))->getDimensions();

							if (!$dimensions->isRelative() && !$dimensions->isUndefined())
							{
								$this->arrImageSize = array
								(
									$dimensions->getSize()->getWidth(),
									$dimensions->getSize()->getHeight(),
									0, // replace this with IMAGETYPE_SVG when it becomes available
									'width="' . $dimensions->getSize()->getWidth() . '" height="' . $dimensions->getSize()->getHeight() . '"',
									'bits' => 8,
									'channels' => 3,
									'mime' => $this->getMimeType()
								);
							}
							else
							{
								$this->arrImageSize = false;
							}
						}
						catch(\Exception $e)
						{
							$this->arrImageSize = false;
						}
					}

					if (!isset(static::$arrImageSizeCache[$strCacheKey]))
					{
						static::$arrImageSizeCache[$strCacheKey] = $this->arrImageSize;
					}
				}

				return $this->arrImageSize;
				break;

			case 'width':
				return $this->imageSize[0];
				break;

			case 'height':
				return $this->imageSize[1];
				break;

			case 'imageViewSize':
				if (empty($this->arrImageViewSize))
				{
					if ($this->imageSize)
					{
						$this->arrImageViewSize = array
						(
							$this->imageSize[0],
							$this->imageSize[1]
						);
					}
					elseif ($this->isSvgImage)
					{
						try
						{
							$dimensions = new ImageDimensions(
								System::getContainer()
									->get('contao.image.imagine_svg')
									->open($this->strRootDir . '/' . $this->strFile)
									->getSize()
							);

							$this->arrImageViewSize = array
							(
								(int) $dimensions->getSize()->getWidth(),
								(int) $dimensions->getSize()->getHeight()
							);

							if (!$this->arrImageViewSize[0] || !$this->arrImageViewSize[1])
							{
								$this->arrImageViewSize = false;
							}
						}
						catch(\Exception $e)
						{
							$this->arrImageViewSize = false;
						}
					}
				}

				return $this->arrImageViewSize;
				break;

			case 'viewWidth':
				return $this->imageViewSize[0];
				break;

			case 'viewHeight':
				return $this->imageViewSize[1];
				break;

			case 'isImage':
				return $this->isGdImage || $this->isSvgImage;
				break;

			case 'isGdImage':
				return \in_array($this->extension, array('gif', 'jpg', 'jpeg', 'png'));
				break;

			case 'isSvgImage':
				return \in_array($this->extension, array('svg', 'svgz'));
				break;

			case 'channels':
				return $this->imageSize['channels'];
				break;

			case 'bits':
				return $this->imageSize['bits'];
				break;

			case 'isRgbImage':
				return $this->channels == 3;
				break;

			case 'isCmykImage':
				return $this->channels == 4;
				break;

			case 'handle':
				if (!\is_resource($this->resFile))
				{
					$this->resFile = fopen($this->strRootDir . '/' . $this->strFile, 'rb');
				}

				return $this->resFile;
				break;

			default:
				return parent::__get($strKey);
				break;
		}
	}

	/**
	 * Create the file if it does not yet exist
	 *
	 * @throws \Exception If the file cannot be written
	 */
	protected function createIfNotExists()
	{
		// The file exists
		if (file_exists($this->strRootDir . '/' . $this->strFile))
		{
			return;
		}

		// Handle open_basedir restrictions
		if (($strFolder = \dirname($this->strFile)) == '.')
		{
			$strFolder = '';
		}

		// Create the folder
		if (!is_dir($this->strRootDir . '/' . $strFolder))
		{
			new Folder($strFolder);
		}

		// Open the file
		if (!$this->resFile = $this->Files->fopen($this->strFile, 'wb'))
		{
			throw new \Exception(sprintf('Cannot create file "%s"', $this->strFile));
		}
	}

	/**
	 * Check whether the file exists
	 *
	 * @return boolean True if the file exists
	 */
	public function exists()
	{
		return file_exists($this->strRootDir . '/' . $this->strFile);
	}

	/**
	 * Truncate the file and reset the file pointer
	 *
	 * @return boolean True if the operation was successful
	 */
	public function truncate()
	{
		if (\is_resource($this->resFile))
		{
			ftruncate($this->resFile, 0);
			rewind($this->resFile);
		}

		return $this->write('');
	}

	/**
	 * Write data to the file
	 *
	 * @param mixed $varData The data to be written
	 *
	 * @return boolean True if the operation was successful
	 */
	public function write($varData)
	{
		return $this->fputs($varData, 'wb');
	}

	/**
	 * Append data to the file
	 *
	 * @param mixed  $varData The data to be appended
	 * @param string $strLine The line ending (defaults to LF)
	 *
	 * @return boolean True if the operation was successful
	 */
	public function append($varData, $strLine="\n")
	{
		return $this->fputs($varData . $strLine, 'ab');
	}

	/**
	 * Prepend data to the file
	 *
	 * @param mixed  $varData The data to be prepended
	 * @param string $strLine The line ending (defaults to LF)
	 *
	 * @return boolean True if the operation was successful
	 */
	public function prepend($varData, $strLine="\n")
	{
		return $this->fputs($varData . $strLine . $this->getContent(), 'wb');
	}

	/**
	 * Delete the file
	 *
	 * @return boolean True if the operation was successful
	 */
	public function delete()
	{
		$return = $this->Files->delete($this->strFile);

		// Update the database
		if (Dbafs::shouldBeSynchronized($this->strFile))
		{
			Dbafs::deleteResource($this->strFile);
		}

		return $return;
	}

	/**
	 * Set the file permissions
	 *
	 * @param integer $intChmod The CHMOD settings
	 *
	 * @return boolean True if the operation was successful
	 */
	public function chmod($intChmod)
	{
		return $this->Files->chmod($this->strFile, $intChmod);
	}

	/**
	 * Close the file handle
	 *
	 * @return boolean True if the operation was successful
	 */
	public function close()
	{
		if (\is_resource($this->resFile))
		{
			$this->Files->fclose($this->resFile);
		}

		// Create the file path
		if (!file_exists($this->strRootDir . '/' . $this->strFile))
		{
			// Handle open_basedir restrictions
			if (($strFolder = \dirname($this->strFile)) == '.')
			{
				$strFolder = '';
			}

			// Create the parent folder
			if (!is_dir($this->strRootDir . '/' . $strFolder))
			{
				new Folder($strFolder);
			}
		}

		// Move the temporary file to its destination
		$return = $this->Files->rename($this->strTmp, $this->strFile);
		$this->strTmp = null;

		// Update the database
		if (Dbafs::shouldBeSynchronized($this->strFile))
		{
			$this->objModel = Dbafs::addResource($this->strFile);
		}

		return $return;
	}

	/**
	 * Return the files model
	 *
	 * @return FilesModel The files model
	 */
	public function getModel()
	{
		if ($this->objModel === null && Dbafs::shouldBeSynchronized($this->strFile))
		{
			$this->objModel = FilesModel::findByPath($this->strFile);
		}

		return $this->objModel;
	}

	/**
	 * Return the file content as string
	 *
	 * @return string The file content without BOM
	 */
	public function getContent()
	{
		$strContent = file_get_contents($this->strRootDir . '/' . ($this->strTmp ?: $this->strFile));

		// Remove BOMs (see #4469)
		if (strncmp($strContent, "\xEF\xBB\xBF", 3) === 0)
		{
			$strContent = substr($strContent, 3);
		}
		elseif (strncmp($strContent, "\xFF\xFE", 2) === 0)
		{
			$strContent = substr($strContent, 2);
		}
		elseif (strncmp($strContent, "\xFE\xFF", 2) === 0)
		{
			$strContent = substr($strContent, 2);
		}

		return $strContent;
	}

	/**
	 * Write to a file
	 *
	 * @param string $strFile    Relative file name
	 * @param string $strContent Content to be written
	 */
	public static function putContent($strFile, $strContent)
	{
		$objFile = new static($strFile);
		$objFile->write($strContent);
		$objFile->close();
	}

	/**
	 * Return the file content as array
	 *
	 * @return array The file content as array
	 */
	public function getContentAsArray()
	{
		return array_map('rtrim', file($this->strRootDir . '/' . $this->strFile));
	}

	/**
	 * Rename the file
	 *
	 * @param string $strNewName The new path
	 *
	 * @return boolean True if the operation was successful
	 */
	public function renameTo($strNewName)
	{
		$strParent = \dirname($strNewName);

		// Create the parent folder if it does not exist
		if (!is_dir($this->strRootDir . '/' . $strParent))
		{
			new Folder($strParent);
		}

		$return = $this->Files->rename($this->strFile, $strNewName);

		// Update the database AFTER the file has been renamed
		$syncSource = Dbafs::shouldBeSynchronized($this->strFile);
		$syncTarget = Dbafs::shouldBeSynchronized($strNewName);

		// Synchronize the database
		if ($syncSource && $syncTarget)
		{
			$this->objModel = Dbafs::moveResource($this->strFile, $strNewName);
		}
		elseif ($syncSource)
		{
			$this->objModel = Dbafs::deleteResource($this->strFile);
		}
		elseif ($syncTarget)
		{
			$this->objModel = Dbafs::addResource($strNewName);
		}

		// Reset the object AFTER the database has been updated
		if ($return != false)
		{
			$this->strFile = $strNewName;
			$this->arrImageSize = array();
			$this->arrPathinfo = array();
		}

		return $return;
	}

	/**
	 * Copy the file
	 *
	 * @param string $strNewName The target path
	 *
	 * @return boolean True if the operation was successful
	 */
	public function copyTo($strNewName)
	{
		$strParent = \dirname($strNewName);

		// Create the parent folder if it does not exist
		if (!is_dir($this->strRootDir . '/' . $strParent))
		{
			new Folder($strParent);
		}

		$return = $this->Files->copy($this->strFile, $strNewName);

		// Update the database AFTER the file has been renamed
		$syncSource = Dbafs::shouldBeSynchronized($this->strFile);
		$syncTarget = Dbafs::shouldBeSynchronized($strNewName);

		// Synchronize the database
		if ($syncSource && $syncTarget)
		{
			Dbafs::copyResource($this->strFile, $strNewName);
		}
		elseif ($syncTarget)
		{
			Dbafs::addResource($strNewName);
		}

		return $return;
	}

	/**
	 * Resize the file if it is an image
	 *
	 * @param integer $width  The target width
	 * @param integer $height The target height
	 * @param string  $mode   The resize mode
	 *
	 * @return boolean True if the image could be resized successfully
	 */
	public function resizeTo($width, $height, $mode='')
	{
		if (!$this->isImage)
		{
			return false;
		}

		$return = System::getContainer()
			->get('contao.image.image_factory')
			->create($this->strRootDir . '/' . $this->strFile, array($width, $height, $mode), $this->strRootDir . '/' . $this->strFile)
			->getUrl($this->strRootDir)
		;

		if ($return)
		{
			$this->arrPathinfo = array();
			$this->arrImageSize = array();
		}

		return $return;
	}

	/**
	 * Send the file to the browser
	 *
	 * @param string  $filename An optional filename
	 * @param boolean $inline   Show the file in the browser instead of opening the download dialog
	 *
	 * @throws ResponseException
	 */
	public function sendToBrowser($filename='', $inline=false)
	{
		$response = new BinaryFileResponse($this->strRootDir . '/' . $this->strFile);

		$response->setContentDisposition
		(
			$inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT,
			$filename,
			Utf8::toAscii($this->basename)
		);

		$response->headers->addCacheControlDirective('must-revalidate');
		$response->headers->addCacheControlDirective('post-check', 0);
		$response->headers->addCacheControlDirective('pre-check', 0);
		$response->headers->set('Connection', 'close');

		throw new ResponseException($response);
	}

	/**
	 * Check if any parent folder contains a .public file
	 *
	 * @return bool
	 */
	public function isUnprotected()
	{
		return (new Folder(\dirname($this->strFile)))->isUnprotected();
	}

	/**
	 * Write data to a file
	 *
	 * @param mixed  $varData The data to be written
	 * @param string $strMode The operation mode
	 *
	 * @return boolean True if the operation was successful
	 */
	protected function fputs($varData, $strMode)
	{
		if (!\is_resource($this->resFile))
		{
			$this->strTmp = 'system/tmp/' . md5(uniqid(mt_rand(), true));

			// Copy the contents of the original file to append data
			if (strncmp($strMode, 'a', 1) === 0 && file_exists($this->strRootDir . '/' . $this->strFile))
			{
				$this->Files->copy($this->strFile, $this->strTmp);
			}

			// Open the temporary file
			if (!$this->resFile = $this->Files->fopen($this->strTmp, $strMode))
			{
				return false;
			}
		}

		fwrite($this->resFile, $varData);

		return true;
	}

	/**
	 * Return the mime type and icon of the file based on its extension
	 *
	 * @return array An array with mime type and icon name
	 */
	protected function getMimeInfo()
	{
		if (isset($GLOBALS['TL_MIME'][$this->extension]))
		{
			return $GLOBALS['TL_MIME'][$this->extension];
		}

		return array('application/octet-stream', 'iconPLAIN.svg');
	}

	/**
	 * Get the mime type of the file based on its extension
	 *
	 * @return string The mime type
	 */
	protected function getMimeType()
	{
		$arrMime = $this->getMimeInfo();

		return $arrMime[0];
	}

	/**
	 * Return the file icon depending on the file type
	 *
	 * @return string The icon name
	 */
	protected function getIcon()
	{
		$arrMime = $this->getMimeInfo();

		return $arrMime[1];
	}

	/**
	 * Return the MD5 hash of the file
	 *
	 * @return string The MD5 hash
	 */
	protected function getHash()
	{
		// Do not try to hash if bigger than 2 GB
		if ($this->filesize >= 2147483648)
		{
			return '';
		}
		else
		{
			return md5_file($this->strRootDir . '/' . $this->strFile);
		}
	}

	/**
	 * Return the path info (binary-safe)
	 *
	 * @return array The path info
	 *
	 * @see https://github.com/PHPMailer/PHPMailer/blob/master/class.phpmailer.php#L3520
	 */
	protected function getPathinfo()
	{
		$matches = array();
		$return = array('dirname'=>'', 'basename'=>'', 'extension'=>'', 'filename'=>'');

		preg_match('%^(.*?)[\\\\/]*(([^/\\\\]*?)(\.([^\.\\\\/]+?)|))[\\\\/\.]*$%m', $this->strFile, $matches);

		if (isset($matches[1]))
		{
			$return['dirname'] = $this->strRootDir . '/' . $matches[1]; // see #8325
		}

		if (isset($matches[2]))
		{
			$return['basename'] = $matches[2];
		}

		if (isset($matches[5]))
		{
			$return['extension'] = $matches[5];
		}

		if (isset($matches[3]))
		{
			$return['filename'] = $matches[3];
		}

		return $return;
	}
}

class_alias(File::class, 'File');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use League\Uri\Components\Query;
use League\Uri\Http;
use Symfony\Component\HttpFoundation\RedirectResponse;

/**
 * Provide methods to handle a logout page.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class PageLogout extends Frontend
{

	/**
	 * Return a redirect response object
	 *
	 * @param PageModel $objPage
	 *
	 * @return RedirectResponse
	 */
	public function getResponse($objPage)
	{
		// Set last page visited
		if ($objPage->redirectBack)
		{
			$_SESSION['LAST_PAGE_VISITED'] = $this->getReferer();
		}

		$strLogoutUrl = System::getContainer()->get('security.logout_url_generator')->getLogoutUrl();
		$strRedirect = Environment::get('base');

		// Redirect to last page visited
		if ($objPage->redirectBack && !empty($_SESSION['LAST_PAGE_VISITED']))
		{
			$strRedirect = $_SESSION['LAST_PAGE_VISITED'];
		}

		// Redirect to jumpTo page
		elseif (($objTarget = $objPage->getRelated('jumpTo')) instanceof PageModel)
		{
			/** @var PageModel $objTarget */
			$strRedirect = $objTarget->getAbsoluteUrl();
		}

		$uri = Http::createFromString($strLogoutUrl);

		// Add the redirect= parameter to the logout URL
		$query = new Query($uri->getQuery());
		$query = $query->merge('redirect=' . $strRedirect);

		return new RedirectResponse((string) $uri->withQuery((string) $query));
	}
}

class_alias(PageLogout::class, 'PageLogout');


<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle;

use Contao\CoreBundle\DependencyInjection\Compiler\AddAssetsPackagesPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddPackagesPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddResourcesPathsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\AddSessionBagsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\DataContainerCallbackPass;
use Contao\CoreBundle\DependencyInjection\Compiler\DoctrineMigrationsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\MakeServicesPublicPass;
use Contao\CoreBundle\DependencyInjection\Compiler\MapFragmentsToGlobalsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\PickerProviderPass;
use Contao\CoreBundle\DependencyInjection\Compiler\RegisterFragmentsPass;
use Contao\CoreBundle\DependencyInjection\Compiler\RegisterHookListenersPass;
use Contao\CoreBundle\DependencyInjection\ContaoCoreExtension;
use Contao\CoreBundle\DependencyInjection\Security\ContaoLoginFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\Console\Application;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\HttpKernel\DependencyInjection\FragmentRendererPass;

class ContaoCoreBundle extends Bundle
{
    public const SCOPE_BACKEND = 'backend';
    public const SCOPE_FRONTEND = 'frontend';

    /**
     * {@inheritdoc}
     */
    public function getContainerExtension(): ContaoCoreExtension
    {
        return new ContaoCoreExtension();
    }

    /**
     * {@inheritdoc}
     */
    public function registerCommands(Application $application): void
    {
        // disable automatic command registration
    }

    /**
     * {@inheritdoc}
     */
    public function build(ContainerBuilder $container): void
    {
        parent::build($container);

        /** @var SecurityExtension $extension */
        $extension = $container->getExtension('security');
        $extension->addSecurityListenerFactory(new ContaoLoginFactory());

        $container->addCompilerPass(new MakeServicesPublicPass());
        $container->addCompilerPass(new AddPackagesPass());
        $container->addCompilerPass(new AddAssetsPackagesPass());
        $container->addCompilerPass(new AddSessionBagsPass());
        $container->addCompilerPass(new AddResourcesPathsPass());
        $container->addCompilerPass(new DoctrineMigrationsPass(), PassConfig::TYPE_BEFORE_REMOVING);
        $container->addCompilerPass(new PickerProviderPass());
        $container->addCompilerPass(new RegisterFragmentsPass());
        $container->addCompilerPass(new FragmentRendererPass('contao.fragment.handler'));
        $container->addCompilerPass(new MapFragmentsToGlobalsPass());
        $container->addCompilerPass(new DataContainerCallbackPass());
        $container->addCompilerPass(new RegisterHookListenersPass(), PassConfig::TYPE_OPTIMIZE);
    }
}


<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\Security\Authentication\Token;

use Contao\BackendUser;
use Contao\FrontendUser;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class TokenChecker
{
    /**
     * @var SessionInterface
     */
    private $session;

    /**
     * @var AuthenticationTrustResolverInterface
     */
    private $trustResolver;

    public function __construct(SessionInterface $session, AuthenticationTrustResolverInterface $trustResolveer)
    {
        $this->session = $session;
        $this->trustResolver = $trustResolveer;
    }

    /**
     * Checks if a front end user is authenticated.
     */
    public function hasFrontendUser(): bool
    {
        $token = $this->getToken(FrontendUser::SECURITY_SESSION_KEY);

        return null !== $token && $token->getUser() instanceof FrontendUser;
    }

    /**
     * Checks if a back end user is authenticated.
     */
    public function hasBackendUser(): bool
    {
        $token = $this->getToken(BackendUser::SECURITY_SESSION_KEY);

        return null !== $token && $token->getUser() instanceof BackendUser;
    }

    /**
     * Gets the front end username from the session.
     */
    public function getFrontendUsername(): ?string
    {
        $token = $this->getToken(FrontendUser::SECURITY_SESSION_KEY);

        if (null === $token || !$token->getUser() instanceof FrontendUser) {
            return null;
        }

        return $token->getUser()->getUsername();
    }

    /**
     * Gets the back end username from the session.
     */
    public function getBackendUsername(): ?string
    {
        $token = $this->getToken(BackendUser::SECURITY_SESSION_KEY);

        if (null === $token || !$token->getUser() instanceof BackendUser) {
            return null;
        }

        return $token->getUser()->getUsername();
    }

    /**
     * Tells whether the front end preview can show unpublished fragments.
     */
    public function isPreviewMode(): bool
    {
        $token = $this->getToken(FrontendUser::SECURITY_SESSION_KEY);

        return $token instanceof FrontendPreviewToken && $token->showUnpublished();
    }

    private function getToken(string $sessionKey): ?TokenInterface
    {
        if (!$this->session->isStarted() || !$this->session->has($sessionKey)) {
            return null;
        }

        $token = unserialize($this->session->get($sessionKey), ['allowed_classes' => true]);

        if (!$token instanceof TokenInterface || !$token->isAuthenticated()) {
            return null;
        }

        if ($this->trustResolver->isAnonymous($token)) {
            return null;
        }

        return $token;
    }
}


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Contao\Model\Collection;

/**
 * Reads and writes member groups
 *
 * @property integer $id
 * @property integer $tstamp
 * @property string  $name
 * @property boolean $redirect
 * @property integer $jumpTo
 * @property boolean $disable
 * @property string  $start
 * @property string  $stop
 *
 * @method static MemberGroupModel|null findById($id, array $opt=array())
 * @method static MemberGroupModel|null findByPk($id, array $opt=array())
 * @method static MemberGroupModel|null findByIdOrAlias($val, array $opt=array())
 * @method static MemberGroupModel|null findOneBy($col, $val, array $opt=array())
 * @method static MemberGroupModel|null findOneByTstamp($val, array $opt=array())
 * @method static MemberGroupModel|null findOneByName($val, array $opt=array())
 * @method static MemberGroupModel|null findOneByRedirect($val, array $opt=array())
 * @method static MemberGroupModel|null findOneByJumpTo($val, array $opt=array())
 * @method static MemberGroupModel|null findOneByDisable($val, array $opt=array())
 * @method static MemberGroupModel|null findOneByStart($val, array $opt=array())
 * @method static MemberGroupModel|null findOneByStop($val, array $opt=array())
 *
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByTstamp($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByName($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByRedirect($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByJumpTo($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByDisable($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByStart($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findByStop($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findMultipleByIds($val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findBy($col, $val, array $opt=array())
 * @method static Collection|MemberGroupModel[]|MemberGroupModel|null findAll(array $opt=array())
 *
 * @method static integer countById($id, array $opt=array())
 * @method static integer countByTstamp($val, array $opt=array())
 * @method static integer countByName($val, array $opt=array())
 * @method static integer countByRedirect($val, array $opt=array())
 * @method static integer countByJumpTo($val, array $opt=array())
 * @method static integer countByDisable($val, array $opt=array())
 * @method static integer countByStart($val, array $opt=array())
 * @method static integer countByStop($val, array $opt=array())
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class MemberGroupModel extends Model
{

	/**
	 * Table name
	 * @var string
	 */
	protected static $strTable = 'tl_member_group';

	/**
	 * Find a published group by its ID
	 *
	 * @param integer $intId      The member group ID
	 * @param array   $arrOptions An optional options array
	 *
	 * @return MemberGroupModel|null The model or null if there is no member group
	 */
	public static function findPublishedById($intId, array $arrOptions=array())
	{
		$t = static::$strTable;
		$arrColumns = array("$t.id=?");

		if (!static::isPreviewMode($arrOptions))
		{
			$time = Date::floorToMinute();
			$arrColumns[] = "($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "') AND $t.disable=''";
		}

		return static::findOneBy($arrColumns, $intId, $arrOptions);
	}

	/**
	 * Find all active groups
	 *
	 * @param array $arrOptions An optional options array
	 *
	 * @return Collection|MemberGroupModel|null A collection of models or null if there are no member groups
	 */
	public static function findAllActive(array $arrOptions=array())
	{
		$t = static::$strTable;
		$time = Date::floorToMinute();

		return static::findBy(array("$t.disable='' AND ($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "')"), null, $arrOptions);
	}

	/**
	 * Find the first active group with a published jumpTo page
	 *
	 * @param string $arrIds An array of member group IDs
	 *
	 * @return MemberGroupModel|null The model or null if there is no matching member group
	 *
	 * @deprecated Deprecated since Contao 4.0, to be removed in Contao 5.0.
	 *             Use PageModel::findFirstActiveByMemberGroups() instead.
	 */
	public static function findFirstActiveWithJumpToByIds($arrIds)
	{
		@trigger_error('Using MemberGroupModel::findFirstActiveWithJumpToByIds() has been deprecated and will no longer work in Contao 5.0. Use PageModel::findFirstActiveByMemberGroups() instead.', E_USER_DEPRECATED);

		if (empty($arrIds) || !\is_array($arrIds))
		{
			return null;
		}

		$time = Date::floorToMinute();
		$objDatabase = Database::getInstance();
		$arrIds = array_map('\intval', $arrIds);

		$objResult = $objDatabase->prepare("SELECT p.* FROM tl_member_group g LEFT JOIN tl_page p ON g.jumpTo=p.id WHERE g.id IN(" . implode(',', $arrIds) . ") AND g.jumpTo>0 AND g.redirect='1' AND g.disable!='1' AND (g.start='' OR g.start<='$time') AND (g.stop='' OR g.stop>'" . ($time + 60) . "') AND p.published='1' AND (p.start='' OR p.start<='$time') AND (p.stop='' OR p.stop>'" . ($time + 60) . "') ORDER BY " . $objDatabase->findInSet('g.id', $arrIds))
								 ->limit(1)
								 ->execute();

		if ($objResult->numRows < 1)
		{
			return null;
		}

		return new static($objResult);
	}
}

class_alias(MemberGroupModel::class, 'MemberGroupModel');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Patchwork\Utf8;
use Psr\Log\LogLevel;

/**
 * Provides string manipulation methods
 *
 * Usage:
 *
 *     $short = StringUtil::substr($str, 32);
 *     $html  = StringUtil::substrHtml($str, 32);
 *     $xhtml = StringUtil::toXhtml($html5);
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class StringUtil
{

	/**
	 * Shorten a string to a given number of characters
	 *
	 * The function preserves words, so the result might be a bit shorter or
	 * longer than the number of characters given. It strips all tags.
	 *
	 * @param string  $strString        The string to shorten
	 * @param integer $intNumberOfChars The target number of characters
	 * @param string  $strEllipsis      An optional ellipsis to append to the shortened string
	 *
	 * @return string The shortened string
	 */
	public static function substr($strString, $intNumberOfChars, $strEllipsis=' …')
	{
		$strString = preg_replace('/[\t\n\r]+/', ' ', $strString);
		$strString = strip_tags($strString);

		if (Utf8::strlen($strString) <= $intNumberOfChars)
		{
			return $strString;
		}

		$intCharCount = 0;
		$arrWords = array();
		$arrChunks = preg_split('/\s+/', $strString);
		$blnAddEllipsis = false;

		foreach ($arrChunks as $strChunk)
		{
			$intCharCount += Utf8::strlen(static::decodeEntities($strChunk));

			if ($intCharCount++ <= $intNumberOfChars)
			{
				$arrWords[] = $strChunk;
				continue;
			}

			// If the first word is longer than $intNumberOfChars already, shorten it
			// with Utf8::substr() so the method does not return an empty string.
			if (empty($arrWords))
			{
				$arrWords[] = Utf8::substr($strChunk, 0, $intNumberOfChars);
			}

			if ($strEllipsis !== false)
			{
				$blnAddEllipsis = true;
			}

			break;
		}

		// Deprecated since Contao 4.0, to be removed in Contao 5.0
		if ($strEllipsis === true)
		{
			@trigger_error('Passing "true" as third argument to StringUtil::substr() has been deprecated and will no longer work in Contao 5.0. Pass the ellipsis string instead.', E_USER_DEPRECATED);

			$strEllipsis = ' …';
		}

		return implode(' ', $arrWords) . ($blnAddEllipsis ? $strEllipsis : '');
	}

	/**
	 * Shorten a HTML string to a given number of characters
	 *
	 * The function preserves words, so the result might be a bit shorter or
	 * longer than the number of characters given. It preserves allowed tags.
	 *
	 * @param string  $strString        The string to shorten
	 * @param integer $intNumberOfChars The target number of characters
	 *
	 * @return string The shortened HTML string
	 */
	public static function substrHtml($strString, $intNumberOfChars)
	{
		$strReturn = '';
		$intCharCount = 0;
		$arrOpenTags = array();
		$arrTagBuffer = array();
		$arrEmptyTags = array('area', 'base', 'br', 'col', 'hr', 'img', 'input', 'frame', 'link', 'meta', 'param');

		$strString = preg_replace('/[\t\n\r]+/', ' ', $strString);
		$strString = strip_tags($strString, Config::get('allowedTags'));
		$strString = preg_replace('/ +/', ' ', $strString);

		// Seperate tags and text
		$arrChunks = preg_split('/(<[^>]+>)/', $strString, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);

		for ($i=0, $c=\count($arrChunks); $i<$c; $i++)
		{
			// Buffer tags to include them later
			if (preg_match('/<([^>]+)>/', $arrChunks[$i]))
			{
				$arrTagBuffer[] = $arrChunks[$i];
				continue;
			}

			$buffer = $arrChunks[$i];

			// Get the substring of the current text
			if (!$arrChunks[$i] = static::substr($arrChunks[$i], ($intNumberOfChars - $intCharCount), false))
			{
				break;
			}

			$blnModified = ($buffer !== $arrChunks[$i]);
			$intCharCount += Utf8::strlen(static::decodeEntities($arrChunks[$i]));

			if ($intCharCount <= $intNumberOfChars)
			{
				foreach ($arrTagBuffer as $strTag)
				{
					$strTagName = strtolower(trim($strTag));

					// Extract the tag name (see #5669)
					if (($pos = strpos($strTagName, ' ')) !== false)
					{
						$strTagName = substr($strTagName, 1, $pos - 1);
					}
					else
					{
						$strTagName = substr($strTagName, 1, -1);
					}

					// Skip empty tags
					if (\in_array($strTagName, $arrEmptyTags))
					{
						continue;
					}

					// Store opening tags in the open_tags array
					if (strncmp($strTagName, '/', 1) !== 0)
					{
						if (!empty($arrChunks[$i]) || $i<$c)
						{
							$arrOpenTags[] = $strTagName;
						}

						continue;
					}

					// Closing tags will be removed from the "open tags" array
					if (!empty($arrChunks[$i]) || $i<$c)
					{
						$arrOpenTags = array_values($arrOpenTags);

						for ($j=\count($arrOpenTags)-1; $j>=0; $j--)
						{
							if ($strTagName == '/' . $arrOpenTags[$j])
							{
								unset($arrOpenTags[$j]);
								break;
							}
						}
					}
				}

				// If the current chunk contains text, add tags and text to the return string
				if (\strlen($arrChunks[$i]) || $i<$c)
				{
					$strReturn .= implode('', $arrTagBuffer) . $arrChunks[$i];
				}

				// Stop after the first shortened chunk (see #7311)
				if ($blnModified)
				{
					break;
				}

				$arrTagBuffer = array();
				continue;
			}

			break;
		}

		// Close all remaining open tags
		krsort($arrOpenTags);

		foreach ($arrOpenTags as $strTag)
		{
			$strReturn .= '</' . $strTag . '>';
		}

		return trim($strReturn);
	}

	/**
	 * Decode all entities
	 *
	 * @param string  $strString     The string to decode
	 * @param integer $strQuoteStyle The quote style (defaults to ENT_COMPAT)
	 * @param string  $strCharset    An optional charset
	 *
	 * @return string The decoded string
	 */
	public static function decodeEntities($strString, $strQuoteStyle=ENT_COMPAT, $strCharset=null)
	{
		if ($strString == '')
		{
			return '';
		}

		if ($strCharset === null)
		{
			$strCharset = Config::get('characterSet');
		}

		$strString = preg_replace('/(&#*\w+)[\x00-\x20]+;/i', '$1;', $strString);
		$strString = preg_replace('/(&#x*)([0-9a-f]+);/i', '$1$2;', $strString);

		return html_entity_decode($strString, $strQuoteStyle, $strCharset);
	}

	/**
	 * Restore basic entities
	 *
	 * @param string $strBuffer The string with the tags to be replaced
	 *
	 * @return string The string with the original entities
	 */
	public static function restoreBasicEntities($strBuffer)
	{
		return str_replace(array('[&]', '[&]', '[lt]', '[gt]', '[nbsp]', '[-]'), array('&', '&', '<', '>', ' ', ''), $strBuffer);
	}

	/**
	 * Generate an alias from a string
	 *
	 * @param string $strString The string
	 *
	 * @return string The alias
	 */
	public static function generateAlias($strString)
	{
		$strString = static::decodeEntities($strString);
		$strString = static::restoreBasicEntities($strString);
		$strString = static::standardize(strip_tags($strString));

		// Remove the prefix if the alias is not numeric (see #707)
		if (strncmp($strString, 'id-', 3) === 0 && !is_numeric($strSubstr = substr($strString, 3)))
		{
			$strString = $strSubstr;
		}

		return $strString;
	}

	/**
	 * Prepare a slug
	 *
	 * @param string $strSlug The slug
	 *
	 * @return string
	 */
	public static function prepareSlug($strSlug)
	{
		$strSlug = static::stripInsertTags($strSlug);
		$strSlug = static::restoreBasicEntities($strSlug);
		$strSlug = static::decodeEntities($strSlug);

		return $strSlug;
	}

	/**
	 * Censor a single word or an array of words within a string
	 *
	 * @param string $strString  The string to censor
	 * @param mixed  $varWords   A string or array or words to replace
	 * @param string $strReplace An optional replacement string
	 *
	 * @return string The cleaned string
	 */
	public static function censor($strString, $varWords, $strReplace='')
	{
		foreach ((array) $varWords as $strWord)
		{
			$strString = preg_replace('/\b(' . str_replace('\*', '\w*?', preg_quote($strWord, '/')) . ')\b/i', $strReplace, $strString);
		}

		return $strString;
	}

	/**
	 * Encode all e-mail addresses within a string
	 *
	 * @param string $strString The string to encode
	 *
	 * @return string The encoded string
	 */
	public static function encodeEmail($strString)
	{
		if (strpos($strString, '@') === false)
		{
			return $strString;
		}

		$arrEmails = static::extractEmail($strString, Config::get('allowedTags'));

		foreach ($arrEmails as $strEmail)
		{
			$strEncoded = '';
			$arrCharacters = Utf8::str_split($strEmail);

			foreach ($arrCharacters as $strCharacter)
			{
				$strEncoded .= sprintf((random_int(0, 1) ? '&#x%X;' : '&#%s;'), Utf8::ord($strCharacter));
			}

			$strString = str_replace($strEmail, $strEncoded, $strString);
		}

		return str_replace('mailto:', 'mailto:', $strString);
	}

	/**
	 * Extract all e-mail addresses from a string
	 *
	 * @param string $strString      The string
	 * @param string $strAllowedTags A list of allowed HTML tags
	 *
	 * @return array The e-mail addresses
	 */
	public static function extractEmail($strString, $strAllowedTags='')
	{
		$arrEmails = array();

		if (strpos($strString, '@') === false)
		{
			return $arrEmails;
		}

		// Find all mailto: addresses
		preg_match_all('/mailto:(?:[^\x00-\x20\x22\x40\x7F]{1,64}+|\x22[^\x00-\x1F\x7F]{1,64}?\x22)@(?:\[(?:IPv)?[a-f0-9.:]{1,47}\]|[\w.-]{1,252}\.[a-z]{2,63}\b)/u', $strString, $matches);

		foreach ($matches[0] as &$strEmail)
		{
			$strEmail = str_replace('mailto:', '', $strEmail);

			if (Validator::isEmail($strEmail))
			{
				$arrEmails[] = $strEmail;
			}
		}

		unset($strEmail);

		// Encode opening arrow brackets (see #3998)
		$strString = preg_replace_callback('@</?([^\s<>/]*)@', function ($matches) use ($strAllowedTags)
		{
			if ($matches[1] == '' || stripos($strAllowedTags, '<' . strtolower($matches[1]) . '>') === false)
			{
				$matches[0] = str_replace('<', '<', $matches[0]);
			}

			return $matches[0];
		}, $strString);

		// Find all addresses in the plain text
		preg_match_all('/(?:[^\x00-\x20\x22\x40\x7F]{1,64}|\x22[^\x00-\x1F\x7F]{1,64}?\x22)@(?:\[(?:IPv)?[a-f0-9.:]{1,47}\]|[\w.-]{1,252}\.[a-z]{2,63}\b)/u', strip_tags($strString), $matches);

		foreach ($matches[0] as &$strEmail)
		{
			$strEmail = str_replace('<', '<', $strEmail);

			if (Validator::isEmail($strEmail))
			{
				$arrEmails[] = $strEmail;
			}
		}

		return array_unique($arrEmails);
	}

	/**
	 * Split a friendly-name e-address and return name and e-mail as array
	 *
	 * @param string $strEmail A friendly-name e-mail address
	 *
	 * @return array An array with name and e-mail address
	 */
	public static function splitFriendlyEmail($strEmail)
	{
		if (strpos($strEmail, '<') !== false)
		{
			return array_map('trim', explode(' <', str_replace('>', '', $strEmail)));
		}
		elseif (strpos($strEmail, '[') !== false)
		{
			return array_map('trim', explode(' [', str_replace(']', '', $strEmail)));
		}
		else
		{
			return array('', $strEmail);
		}
	}

	/**
	 * Wrap words after a particular number of characers
	 *
	 * @param string  $strString The string to wrap
	 * @param integer $strLength The number of characters to wrap after
	 * @param string  $strBreak  An optional break character
	 *
	 * @return string The wrapped string
	 */
	public static function wordWrap($strString, $strLength=75, $strBreak="\n")
	{
		return wordwrap($strString, $strLength, $strBreak);
	}

	/**
	 * Highlight a phrase within a string
	 *
	 * @param string $strString     The string
	 * @param string $strPhrase     The phrase to highlight
	 * @param string $strOpeningTag The opening tag (defaults to )
	 * @param string $strClosingTag The closing tag (defaults to )
	 *
	 * @return string The highlighted string
	 */
	public static function highlight($strString, $strPhrase, $strOpeningTag='', $strClosingTag='')
	{
		if ($strString == '' || $strPhrase == '')
		{
			return $strString;
		}

		return preg_replace('/(' . preg_quote($strPhrase, '/') . ')/i', $strOpeningTag . '\\1' . $strClosingTag, $strString);
	}

	/**
	 * Split a string of comma separated values
	 *
	 * @param string $strString    The string to split
	 * @param string $strDelimiter An optional delimiter
	 *
	 * @return array The string chunks
	 */
	public static function splitCsv($strString, $strDelimiter=',')
	{
		$arrValues = preg_split('/'.$strDelimiter.'(?=(?:[^"]*"[^"]*")*(?![^"]*"))/', $strString);

		foreach ($arrValues as $k=>$v)
		{
			$arrValues[$k] = trim($v, ' "');
		}

		return $arrValues;
	}

	/**
	 * Convert a string to XHTML
	 *
	 * @param string $strString The HTML5 string
	 *
	 * @return string The XHTML string
	 */
	public static function toXhtml($strString)
	{
		$arrPregReplace = array
		(
			'/<(br|hr|img)([^>]*)>/i' => '<$1$2 />', // Close stand-alone tags
			'/ border="[^"]*"/'       => ''          // Remove deprecated attributes
		);

		$arrStrReplace = array
		(
			'/ />'             => ' />',        // Fix incorrectly closed tags
			''              => '',   // Replace  with
			''             => '',
			''              => '',       // Replace  with
			''             => '',
			''              => '',
			''             => '',
			' target="_self"'  => '',
			' target="_blank"' => ' onclick="return !window.open(this.href)"'
		);

		$strString = preg_replace(array_keys($arrPregReplace), $arrPregReplace, $strString);
		$strString = str_ireplace(array_keys($arrStrReplace), $arrStrReplace, $strString);

		return $strString;
	}

	/**
	 * Convert a string to HTML5
	 *
	 * @param string $strString The XHTML string
	 *
	 * @return string The HTML5 string
	 */
	public static function toHtml5($strString)
	{
		$arrPregReplace = array
		(
			'/<(br|hr|img)([^>]*) \/>/i'                  => '<$1$2>',             // Close stand-alone tags
			'/ (cellpadding|cellspacing|border)="[^"]*"/' => '',                   // Remove deprecated attributes
			'/ rel="lightbox(\[([^\]]+)\])?"/'            => ' data-lightbox="$2"' // see #4073
		);

		$arrStrReplace = array
		(
			''                                              => '',
			''                                             => '',
			' target="_self"'                                  => '',
			' onclick="window.open(this.href); return false"'  => ' target="_blank"',
			' onclick="window.open(this.href);return false"'   => ' target="_blank"',
			' onclick="window.open(this.href); return false;"' => ' target="_blank"'
		);

		$strString = preg_replace(array_keys($arrPregReplace), $arrPregReplace, $strString);
		$strString = str_ireplace(array_keys($arrStrReplace), $arrStrReplace, $strString);

		return $strString;
	}

	/**
	 * Parse simple tokens
	 *
	 * @param string $strString The string to be parsed
	 * @param array  $arrData   The replacement data
	 *
	 * @return string The converted string
	 *
	 * @throws \Exception                If $strString cannot be parsed
	 * @throws \InvalidArgumentException If there are incorrectly formatted if-tags
	 */
	public static function parseSimpleTokens($strString, $arrData)
	{
		$strReturn = '';

		$replaceTokens = function ($strSubject) use ($arrData)
		{
			// Replace tokens
			return preg_replace_callback
			(
				'/##([^=!<>\s]+?)##/',
				function (array $matches) use ($arrData)
				{
					if (!\array_key_exists($matches[1], $arrData))
					{
						System::getContainer()
							->get('monolog.logger.contao')
							->log(LogLevel::INFO, sprintf('Tried to parse unknown simple token "%s".', $matches[1]))
						;

						return '##' . $matches[1] . '##';
					}

					return $arrData[$matches[1]];
				},
				$strSubject
			);
		};

		$evaluateExpression = function ($strExpression) use ($arrData)
		{
			if (!preg_match('/^([^=!<>\s]+)([=!<>]+)(.+)$/s', $strExpression, $arrMatches))
			{
				return false;
			}

			$strToken = $arrMatches[1];
			$strOperator = $arrMatches[2];
			$strValue = $arrMatches[3];

			if (!\array_key_exists($strToken, $arrData))
			{
				System::getContainer()
					->get('monolog.logger.contao')
					->log(LogLevel::INFO, sprintf('Tried to evaluate unknown simple token "%s".', $strToken))
				;

				return false;
			}

			$varTokenValue = $arrData[$strToken];

			if (is_numeric($strValue))
			{
				if (strpos($strValue, '.') === false)
				{
					$varValue = (int) $strValue;
				}
				else
				{
					$varValue = (float) $strValue;
				}
			}
			elseif (strtolower($strValue) === 'true')
			{
				$varValue = true;
			}
			elseif (strtolower($strValue) === 'false')
			{
				$varValue = false;
			}
			elseif (strtolower($strValue) === 'null')
			{
				$varValue = null;
			}
			elseif (substr($strValue, 0, 1) === '"' && substr($strValue, -1) === '"')
			{
				$varValue = str_replace('\"', '"', substr($strValue, 1, -1));
			}
			elseif (substr($strValue, 0, 1) === "'" && substr($strValue, -1) === "'")
			{
				$varValue = str_replace("\\'", "'", substr($strValue, 1, -1));
			}
			else
			{
				throw new \InvalidArgumentException(sprintf('Unknown data type of comparison value "%s".', $strValue));
			}

			switch ($strOperator)
			{
				case '==':
					return $varTokenValue == $varValue;

				case '!=':
					return $varTokenValue != $varValue;

				case '===':
					return $varTokenValue === $varValue;

				case '!==':
					return $varTokenValue !== $varValue;

				case '<':
					return $varTokenValue < $varValue;

				case '>':
					return $varTokenValue > $varValue;

				case '<=':
					return $varTokenValue <= $varValue;

				case '>=':
					return $varTokenValue >= $varValue;

				default:
					throw new \InvalidArgumentException(sprintf('Unknown simple token comparison operator "%s".', $strOperator));
			}
		};

		// The last item is true if it is inside a matching if-tag
		$arrStack = array(true);

		// The last item is true if any if/elseif at that level was true
		$arrIfStack = array(true);

		// Tokenize the string into tag and text blocks
		$arrTags = preg_split('/({[^{}]+})\n?/', $strString, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);

		// Parse the tokens
		foreach ($arrTags as $strTag)
		{
			// True if it is inside a matching if-tag
			$blnCurrent = $arrStack[\count($arrStack) - 1];
			$blnCurrentIf = $arrIfStack[\count($arrIfStack) - 1];

			if (strncmp($strTag, '{if ', 4) === 0)
			{
				$blnExpression = $evaluateExpression(substr($strTag, 4, -1));
				$arrStack[] = $blnCurrent && $blnExpression;
				$arrIfStack[] = $blnExpression;
			}
			elseif (strncmp($strTag, '{elseif ', 8) === 0)
			{
				$blnExpression = $evaluateExpression(substr($strTag, 8, -1));
				array_pop($arrStack);
				array_pop($arrIfStack);
				$arrStack[] = !$blnCurrentIf && $arrStack[\count($arrStack) - 1] && $blnExpression;
				$arrIfStack[] = $blnCurrentIf || $blnExpression;
			}
			elseif (strncmp($strTag, '{else}', 6) === 0)
			{
				array_pop($arrStack);
				array_pop($arrIfStack);
				$arrStack[] = !$blnCurrentIf && $arrStack[\count($arrStack) - 1];
				$arrIfStack[] = true;
			}
			elseif (strncmp($strTag, '{endif}', 7) === 0)
			{
				array_pop($arrStack);
				array_pop($arrIfStack);
			}
			elseif ($blnCurrent)
			{
				$strReturn .= $replaceTokens($strTag);
			}
		}

		// Throw an exception if there is an error
		if (\count($arrStack) !== 1)
		{
			throw new \Exception('Error parsing simple tokens');
		}

		return $strReturn;
	}

	/**
	 * Convert a UUID string to binary data
	 *
	 * @param string $uuid The UUID string
	 *
	 * @return string The binary data
	 */
	public static function uuidToBin($uuid)
	{
		return hex2bin(str_replace('-', '', $uuid));
	}

	/**
	 * Get a UUID string from binary data
	 *
	 * @param string $data The binary data
	 *
	 * @return string The UUID string
	 */
	public static function binToUuid($data)
	{
		return implode('-', unpack('H8time_low/H4time_mid/H4time_high/H4clock_seq/H12node', $data));
	}

	/**
	 * Convert file paths inside "src" attributes to insert tags
	 *
	 * @param string $data The markup string
	 *
	 * @return string The markup with file paths converted to insert tags
	 */
	public static function srcToInsertTag($data)
	{
		$return = '';
		$paths = preg_split('/((src|href)="([^"]+)")/i', $data, -1, PREG_SPLIT_DELIM_CAPTURE);

		for ($i=0, $c=\count($paths); $i<$c; $i+=4)
		{
			$return .= $paths[$i];

			if (!isset($paths[$i+1]))
			{
				continue;
			}

			$file = FilesModel::findByPath($paths[$i+3]);

			if ($file !== null)
			{
				$return .= $paths[$i+2] . '="{{file::' . static::binToUuid($file->uuid) . '}}"';
			}
			else
			{
				$return .= $paths[$i+2] . '="' . $paths[$i+3] . '"';
			}
		}

		return $return;
	}

	/**
	 * Convert insert tags inside "src" attributes to file paths
	 *
	 * @param string $data The markup string
	 *
	 * @return string The markup with insert tags converted to file paths
	 */
	public static function insertTagToSrc($data)
	{
		$return = '';
		$paths = preg_split('/((src|href)="([^"]*)\{\{file::([^"\}]+)\}\}")/i', $data, -1, PREG_SPLIT_DELIM_CAPTURE);

		for ($i=0, $c=\count($paths); $i<$c; $i+=5)
		{
			$return .= $paths[$i];

			if (!isset($paths[$i+1]))
			{
				continue;
			}

			$file = FilesModel::findByUuid($paths[$i+4]);

			if ($file !== null)
			{
				$return .= $paths[$i+2] . '="' . $paths[$i+3] . $file->path . '"';
			}
			else
			{
				$return .= $paths[$i+2] . '="' . $paths[$i+3] . $paths[$i+4] . '"';
			}
		}

		return $return;
	}

	/**
	 * Sanitize a file name
	 *
	 * @param string $strName The file name
	 *
	 * @return string The sanitized file name
	 */
	public static function sanitizeFileName($strName)
	{
		// Remove invisible control characters and unused code points
		$strName = preg_replace('/[\pC]/u', '', $strName);

		if ($strName === null)
		{
			throw new \InvalidArgumentException('The file name could not be sanitzied');
		}

		// Remove special characters not supported on e.g. Windows
		$strName = str_replace(array('\\', '/', ':', '*', '?', '"', '<', '>', '|'), '-', $strName);

		return $strName;
	}

	/**
	 * Resolve a flagged URL such as assets/js/core.js|static|10184084
	 *
	 * @param string $url The URL
	 *
	 * @return \stdClass The options object
	 */
	public static function resolveFlaggedUrl(&$url)
	{
		$options = new \stdClass();

		// Defaults
		$options->static = false;
		$options->media  = null;
		$options->mtime  = null;
		$options->async  = false;

		$chunks = explode('|', $url);

		// Remove the flags from the URL
		$url = $chunks[0];

		for ($i=1, $c=\count($chunks); $i<$c; $i++)
		{
			if (empty($chunks[$i]))
			{
				continue;
			}

			switch ($chunks[$i])
			{
				case 'static':
					$options->static = true;
					break;

				case 'async':
					$options->async = true;
					break;

				case is_numeric($chunks[$i]):
					$options->mtime = $chunks[$i];
					break;

				default:
					$options->media = $chunks[$i];
					break;
			}
		}

		return $options;
	}

	/**
	 * Convert the character encoding
	 *
	 * @param string $str  The input string
	 * @param string $to   The target character set
	 * @param string $from An optional source character set
	 *
	 * @return string The converted string
	 */
	public static function convertEncoding($str, $to, $from=null)
	{
		if ($str == '')
		{
			return '';
		}

		if (!$from)
		{
			$from = mb_detect_encoding($str, 'ASCII,ISO-2022-JP,UTF-8,EUC-JP,ISO-8859-1');
		}

		if ($from == $to)
		{
			return $str;
		}

		if ($from == 'UTF-8' && $to == 'ISO-8859-1')
		{
			return utf8_decode($str);
		}

		if ($from == 'ISO-8859-1' && $to == 'UTF-8')
		{
			return utf8_encode($str);
		}

		return mb_convert_encoding($str, $to, $from);
	}

	/**
	 * Convert special characters to HTML entities preventing double conversions
	 *
	 * @param string  $strString          The input string
	 * @param boolean $blnStripInsertTags True to strip insert tags
	 * @param boolean $blnDoubleEncode    True to encode existing html entities
	 *
	 * @return string The converted string
	 */
	public static function specialchars($strString, $blnStripInsertTags=false, $blnDoubleEncode=false)
	{
		if ($blnStripInsertTags)
		{
			$strString = static::stripInsertTags($strString);
		}

		// Use ENT_COMPAT here (see #4889)
		return htmlspecialchars($strString, ENT_COMPAT, Config::get('characterSet'), $blnDoubleEncode);
	}

	/**
	 * Remove Contao insert tags from a string
	 *
	 * @param string $strString The input string
	 *
	 * @return string The converted string
	 */
	public static function stripInsertTags($strString)
	{
		$count = 0;

		do
		{
			$strString = preg_replace('/\{\{[^\{\}]*\}\}/', '', $strString, -1, $count);
		}
		while ($count > 0);

		return $strString;
	}

	/**
	 * Standardize a parameter (strip special characters and convert spaces)
	 *
	 * @param string  $strString            The input string
	 * @param boolean $blnPreserveUppercase True to preserver uppercase characters
	 *
	 * @return string The converted string
	 */
	public static function standardize($strString, $blnPreserveUppercase=false)
	{
		$arrSearch = array('/[^\pN\pL \.\&\/_-]+/u', '/[ \.\&\/-]+/');
		$arrReplace = array('', '-');

		$strString = html_entity_decode($strString, ENT_QUOTES, $GLOBALS['TL_CONFIG']['characterSet']);
		$strString = static::stripInsertTags($strString);
		$strString = preg_replace($arrSearch, $arrReplace, $strString);

		if (is_numeric(substr($strString, 0, 1)))
		{
			$strString = 'id-' . $strString;
		}

		if (!$blnPreserveUppercase)
		{
			$strString = Utf8::strtolower($strString);
		}

		return trim($strString, '-');
	}

	/**
	 * Return an unserialized array or the argument
	 *
	 * @param mixed   $varValue      The serialized string
	 * @param boolean $blnForceArray True to always return an array
	 *
	 * @return array|string|null The array, an empty string or null
	 */
	public static function deserialize($varValue, $blnForceArray=false)
	{
		// Already an array
		if (\is_array($varValue))
		{
			return $varValue;
		}

		// Null
		if ($varValue === null)
		{
			return $blnForceArray ? array() : null;
		}

		// Not a string
		if (!\is_string($varValue))
		{
			return $blnForceArray ? array($varValue) : $varValue;
		}

		// Empty string
		if (trim($varValue) == '')
		{
			return $blnForceArray ? array() : '';
		}

		// Not a serialized array (see #1486)
		if (strncmp($varValue, 'a:', 2) !== 0)
		{
			return $blnForceArray ? array($varValue) : $varValue;
		}

		// Potentially including an object (see #6724)
		if (preg_match('/[OoC]:\+?[0-9]+:"/', $varValue))
		{
			trigger_error('StringUtil::deserialize() does not allow serialized objects', E_USER_WARNING);

			return $blnForceArray ? array($varValue) : $varValue;
		}

		$varUnserialized = @unserialize($varValue, array('allowed_classes' => false));

		if (\is_array($varUnserialized))
		{
			$varValue = $varUnserialized;
		}
		elseif ($blnForceArray)
		{
			$varValue = array($varValue);
		}

		return $varValue;
	}

	/**
	 * Split a string into fragments, remove whitespace and return fragments as array
	 *
	 * @param string $strPattern The split pattern
	 * @param string $strString  The input string
	 *
	 * @return array The fragments array
	 */
	public static function trimsplit($strPattern, $strString)
	{
		// Split
		if (\strlen($strPattern) == 1)
		{
			$arrFragments = array_map('trim', explode($strPattern, $strString));
		}
		else
		{
			$arrFragments = array_map('trim', preg_split('/'.$strPattern.'/ui', $strString));
		}

		// Empty array
		if (\count($arrFragments) < 2 && !\strlen($arrFragments[0]))
		{
			$arrFragments = array();
		}

		return $arrFragments;
	}

	/**
	 * Strip the Contao root dir from the given absolute path
	 *
	 * @param string $path
	 *
	 * @return string
	 *
	 * @throws \InvalidArgumentException
	 */
	public static function stripRootDir($path)
	{
		$rootDir = System::getContainer()->getParameter('kernel.project_dir');
		$length = \strlen($rootDir);

		if (strncmp($path, $rootDir, $length) !== 0 || \strlen($path) <= $length || ($path[$length] !== '/' && $path[$length] !== '\\'))
		{
			throw new \InvalidArgumentException(sprintf('Path "%s" is not inside the Contao root dir "%s"', $path, $rootDir));
		}

		return (string) substr($path, $length + 1);
	}
}

class_alias(StringUtil::class, 'StringUtil');


<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use Contao\Model\Collection;

/**
 * Reads and writes FAQs
 *
 * @property integer $id
 * @property integer $pid
 * @property integer $sorting
 * @property integer $tstamp
 * @property string  $question
 * @property string  $alias
 * @property integer $author
 * @property string  $answer
 * @property boolean $addImage
 * @property string  $singleSRC
 * @property string  $alt
 * @property string  $size
 * @property string  $imagemargin
 * @property string  $imageUrl
 * @property boolean $fullsize
 * @property string  $caption
 * @property string  $floating
 * @property boolean $addEnclosure
 * @property string  $enclosure
 * @property boolean $noComments
 * @property boolean $published
 *
 * @method static FaqModel|null findById($id, $opt=array())
 * @method static FaqModel|null findByPk($id, array $opt=array())
 * @method static FaqModel|null findByIdOrAlias($val, array $opt=array())
 * @method static FaqModel|null findOneBy($col, $val, array $opt=array())
 * @method static FaqModel|null findOneByPid($val, $opt=array())
 * @method static FaqModel|null findOneBySorting($val, $opt=array())
 * @method static FaqModel|null findOneByTstamp($val, $opt=array())
 * @method static FaqModel|null findOneByQuestion($val, $opt=array())
 * @method static FaqModel|null findOneByAlias($val, $opt=array())
 * @method static FaqModel|null findOneByAuthor($val, $opt=array())
 * @method static FaqModel|null findOneByAnswer($val, $opt=array())
 * @method static FaqModel|null findOneByAddImage($val, $opt=array())
 * @method static FaqModel|null findOneBySingleSRC($val, $opt=array())
 * @method static FaqModel|null findOneByAlt($val, $opt=array())
 * @method static FaqModel|null findOneBySize($val, $opt=array())
 * @method static FaqModel|null findOneByImagemargin($val, $opt=array())
 * @method static FaqModel|null findOneByImageUrl($val, $opt=array())
 * @method static FaqModel|null findOneByFullsize($val, $opt=array())
 * @method static FaqModel|null findOneByCaption($val, $opt=array())
 * @method static FaqModel|null findOneByFloating($val, $opt=array())
 * @method static FaqModel|null findOneByAddEnclosure($val, $opt=array())
 * @method static FaqModel|null findOneByEnclosure($val, $opt=array())
 * @method static FaqModel|null findOneByNoComments($val, $opt=array())
 * @method static FaqModel|null findOneByPublished($val, $opt=array())
 *
 * @method static Collection|FaqModel[]|FaqModel|null findByPid($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findBySorting($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByTstamp($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByQuestion($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByAlias($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByAuthor($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByAnswer($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByAddImage($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findBySingleSRC($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByAlt($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findBySize($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByImagemargin($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByImageUrl($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByFullsize($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByCaption($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByFloating($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByAddEnclosure($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByEnclosure($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByNoComments($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findByPublished($val, $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findMultipleByIds($val, array $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findBy($col, $val, array $opt=array())
 * @method static Collection|FaqModel[]|FaqModel|null findAll(array $opt=array())
 *
 * @method static integer countById($id, $opt=array())
 * @method static integer countByPid($val, $opt=array())
 * @method static integer countBySorting($val, $opt=array())
 * @method static integer countByTstamp($val, $opt=array())
 * @method static integer countByQuestion($val, $opt=array())
 * @method static integer countByAlias($val, $opt=array())
 * @method static integer countByAuthor($val, $opt=array())
 * @method static integer countByAnswer($val, $opt=array())
 * @method static integer countByAddImage($val, $opt=array())
 * @method static integer countBySingleSRC($val, $opt=array())
 * @method static integer countByAlt($val, $opt=array())
 * @method static integer countBySize($val, $opt=array())
 * @method static integer countByImagemargin($val, $opt=array())
 * @method static integer countByImageUrl($val, $opt=array())
 * @method static integer countByFullsize($val, $opt=array())
 * @method static integer countByCaption($val, $opt=array())
 * @method static integer countByFloating($val, $opt=array())
 * @method static integer countByAddEnclosure($val, $opt=array())
 * @method static integer countByEnclosure($val, $opt=array())
 * @method static integer countByNoComments($val, $opt=array())
 * @method static integer countByPublished($val, $opt=array())
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class FaqModel extends Model
{

	/**
	 * Table name
	 * @var string
	 */
	protected static $strTable = 'tl_faq';

	/**
	 * Find a published FAQ from one or more categories by its ID or alias
	 *
	 * @param mixed $varId      The numeric ID or alias name
	 * @param array $arrPids    An array of parent IDs
	 * @param array $arrOptions An optional options array
	 *
	 * @return FaqModel|null The model or null if there is no FAQ
	 */
	public static function findPublishedByParentAndIdOrAlias($varId, $arrPids, array $arrOptions=array())
	{
		if (empty($arrPids) || !\is_array($arrPids))
		{
			return null;
		}

		$t = static::$strTable;
		$arrColumns = !preg_match('/^[1-9]\d*$/', $varId) ? array("$t.alias=?") : array("$t.id=?");
		$arrColumns[] = "$t.pid IN(" . implode(',', array_map('\intval', $arrPids)) . ")";

		if (!static::isPreviewMode($arrOptions))
		{
			$arrColumns[] = "$t.published='1'";
		}

		return static::findOneBy($arrColumns, $varId, $arrOptions);
	}

	/**
	 * Find all published FAQs by their parent ID
	 *
	 * @param int   $intPid     The parent ID
	 * @param array $arrOptions An optional options array
	 *
	 * @return Collection|FaqModel[]|FaqModel|null A collection of models or null if there are no FAQs
	 */
	public static function findPublishedByPid($intPid, array $arrOptions=array())
	{
		$t = static::$strTable;
		$arrColumns = array("$t.pid=?");

		if (!static::isPreviewMode($arrOptions))
		{
			$arrColumns[] = "$t.published='1'";
		}

		if (!isset($arrOptions['order']))
		{
			$arrOptions['order'] = "$t.sorting";
		}

		return static::findBy($arrColumns, $intPid, $arrOptions);
	}

	/**
	 * Find all published FAQs by their parent IDs
	 *
	 * @param array $arrPids    An array of FAQ category IDs
	 * @param array $arrOptions An optional options array
	 *
	 * @return Collection|FaqModel[]|FaqModel|null A collection of models or null if there are no FAQs
	 */
	public static function findPublishedByPids($arrPids, array $arrOptions=array())
	{
		if (empty($arrPids) || !\is_array($arrPids))
		{
			return null;
		}

		$t = static::$strTable;
		$arrColumns = array("$t.pid IN(" . implode(',', array_map('\intval', $arrPids)) . ")");

		if (!static::isPreviewMode($arrOptions))
		{
			$arrColumns[] = "$t.published='1'";
		}

		if (!isset($arrOptions['order']))
		{
			$arrOptions['order'] = "$t.pid, $t.sorting";
		}

		return static::findBy($arrColumns, null, $arrOptions);
	}
}

class_alias(FaqModel::class, 'FaqModel');


<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\EventListener;

use Contao\CoreBundle\Routing\ScopeMatcher;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

class RefererIdListener
{
    /**
     * @var CsrfTokenManagerInterface
     */
    private $tokenManager;

    /**
     * @var ScopeMatcher
     */
    private $scopeMatcher;

    /**
     * @var CsrfToken
     */
    private $token;

    public function __construct(CsrfTokenManagerInterface $tokenManager, ScopeMatcher $scopeMatcher)
    {
        $this->tokenManager = $tokenManager;
        $this->scopeMatcher = $scopeMatcher;
    }

    /**
     * Adds the referer ID to the request.
     */
    public function onKernelRequest(GetResponseEvent $event): void
    {
        if (!$this->scopeMatcher->isBackendMasterRequest($event)) {
            return;
        }

        $request = $event->getRequest();

        if (null === $this->token) {
            $this->token = $this->tokenManager->refreshToken('contao_referer_id');
        }

        $request->attributes->set('_contao_referer_id', $this->token->getValue());
    }
}


<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\EventListener;

use Contao\CoreBundle\Exception\ResponseException;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class ResponseExceptionListener
{
    /**
     * Sets the response from the exception.
     */
    public function onKernelException(GetResponseForExceptionEvent $event): void
    {
        $exception = $event->getException();

        if (!$exception instanceof ResponseException) {
            return;
        }

        $event->allowCustomResponseCode();
        $event->setResponse($exception->getResponse());
    }
}