<?php

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

namespace Contao;

use Contao\Database\Result;
use Patchwork\Utf8;
use Symfony\Component\HttpFoundation\Session\Session;

/**
 * Provide methods to handle themes.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class Theme extends Backend
{

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

	/**
	 * Set the root directory
	 */
	public function __construct()
	{
		parent::__construct();
		$this->strRootDir = System::getContainer()->getParameter('kernel.project_dir');
	}

	/**
	 * Import a theme
	 *
	 * @return string
	 */
	public function importTheme()
	{
		/** @var FileUpload $objUploader */
		$objUploader = new FileUpload();

		if (Input::post('FORM_SUBMIT') == 'tl_theme_import')
		{
			/** @var Session $objSession */
			$objSession = System::getContainer()->get('session');

			if (!Input::post('confirm'))
			{
				$arrUploaded = $objUploader->uploadTo('system/tmp');

				if (empty($arrUploaded))
				{
					Message::addError($GLOBALS['TL_LANG']['ERR']['all_fields']);
					$this->reload();
				}

				$arrFiles = array();

				foreach ($arrUploaded as $strFile)
				{
					// Skip folders
					if (is_dir($this->strRootDir . '/' . $strFile))
					{
						Message::addError(sprintf($GLOBALS['TL_LANG']['ERR']['importFolder'], basename($strFile)));
						continue;
					}

					$objFile = new File($strFile);

					// Skip anything but .cto files
					if ($objFile->extension != 'cto')
					{
						Message::addError(sprintf($GLOBALS['TL_LANG']['ERR']['filetype'], $objFile->extension));
						continue;
					}

					$arrFiles[] = $strFile;
				}
			}
			else
			{
				$arrFiles = explode(',', $objSession->get('uploaded_themes'));
			}

			// Check whether there are any files
			if (empty($arrFiles))
			{
				Message::addError($GLOBALS['TL_LANG']['ERR']['all_fields']);
				$this->reload();
			}

			// Store the field names of the theme tables
			$arrDbFields = array
			(
				'tl_files'           => $this->Database->getFieldNames('tl_files'),
				'tl_theme'           => $this->Database->getFieldNames('tl_theme'),
				'tl_style_sheet'     => $this->Database->getFieldNames('tl_style_sheet'),
				'tl_style'           => $this->Database->getFieldNames('tl_style'),
				'tl_module'          => $this->Database->getFieldNames('tl_module'),
				'tl_layout'          => $this->Database->getFieldNames('tl_layout'),
				'tl_image_size'      => $this->Database->getFieldNames('tl_image_size'),
				'tl_image_size_item' => $this->Database->getFieldNames('tl_image_size_item')
			);

			// Proceed
			if (Input::post('confirm') == 1)
			{
				$this->extractThemeFiles($arrFiles, $arrDbFields);
			}
			else
			{
				$objSession->set('uploaded_themes', implode(',', $arrFiles));

				return $this->compareThemeFiles($arrFiles, $arrDbFields);
			}
		}

		// Return the form
		return Message::generate() . '

'.$GLOBALS['TL_LANG']['MSC']['backBT'].'

<form action="'.ampersand(Environment::get('request'), true).'" id="tl_theme_import" class="tl_form tl_edit_form" method="post" enctype="multipart/form-data">

<input type="hidden" name="FORM_SUBMIT" value="tl_theme_import">
<input type="hidden" name="REQUEST_TOKEN" value="'.REQUEST_TOKEN.'">
<input type="hidden" name="MAX_FILE_SIZE" value="'.Config::get('maxFileSize').'">

'.$GLOBALS['TL_LANG']['tl_theme']['source'][0].'
'.$objUploader->generateMarkup().(isset($GLOBALS['TL_LANG']['tl_theme']['source'][1]) ? '

'.$GLOBALS['TL_LANG']['tl_theme']['source'][1].'
' : '').'

  <button type="submit" name="save" id="save" class="tl_submit" accesskey="s">'.$GLOBALS['TL_LANG']['tl_theme']['importTheme'][0].'</button>

</form>';
	}

	/**
	 * Compare the theme tables with the local database and check whether there are custom layout sections
	 *
	 * @param array $arrFiles
	 * @param array $arrDbFields
	 *
	 * @return string
	 */
	protected function compareThemeFiles($arrFiles, $arrDbFields)
	{
		$return = Message::generate() . '

'.$GLOBALS['TL_LANG']['MSC']['backBT'].'

<form action="'.ampersand(Environment::get('request'), true).'" id="tl_theme_import" class="tl_form tl_edit_form" method="post">

<input type="hidden" name="FORM_SUBMIT" value="tl_theme_import">
<input type="hidden" name="REQUEST_TOKEN" value="'.REQUEST_TOKEN.'">
<input type="hidden" name="confirm" value="1">';

		$count = 0;

		// Check the theme data
		foreach ($arrFiles as $strFile)
		{
			$return .= '

<div class="tl_'. (($count++ < 1) ? 't' : '') .'box theme_import">

'. basename($strFile) .'

'.$GLOBALS['TL_LANG']['tl_theme']['tables_fields'].'
';

			// Find the XML file
			$objArchive = new ZipReader($strFile);

			// Continue if there is no XML file
			if ($objArchive->getFile('theme.xml') === false)
			{
				$return .= "\n  " . '
'. sprintf($GLOBALS['TL_LANG']['tl_theme']['missing_xml'], basename($strFile)) ."
\n
";
				continue;
			}

			// Open the XML file
			$xml = new \DOMDocument();
			$xml->preserveWhiteSpace = false;
			$xml->loadXML($objArchive->unzip());
			$tables = $xml->getElementsByTagName('table');

			$blnHasError = false;

			// Loop through the tables
			for ($i=0; $i<$tables->length; $i++)
			{
				$rows = $tables->item($i)->childNodes;
				$table = $tables->item($i)->getAttribute('name');

				// Skip invalid tables
				if ($table != 'tl_theme' && $table != 'tl_style_sheet' && $table != 'tl_style' && $table != 'tl_module' && $table != 'tl_layout' && $table != 'tl_image_size' && $table != 'tl_image_size_item')
				{
					continue;
				}

				$arrFieldNames = array();

				// Loop through the rows
				for ($j=0; $j<$rows->length; $j++)
				{
					$fields = $rows->item($j)->childNodes;

					// Loop through the fields
					for ($k=0; $k<$fields->length; $k++)
					{
						$arrFieldNames[$fields->item($k)->getAttribute('name')] = true;
					}
				}

				$arrFieldNames = array_keys($arrFieldNames);

				// Loop through the fields
				foreach ($arrFieldNames as $name)
				{
					// Print a warning if a field is missing
					if (!\in_array($name, $arrDbFields[$table]))
					{
						$blnHasError = true;
						$return .= "\n  " . '
'. sprintf($GLOBALS['TL_LANG']['tl_theme']['missing_field'], $table .'.'. $name) .'
';
					}
				}
			}

			// Confirmation
			if (!$blnHasError)
			{
				$return .= "\n  " . '
'. $GLOBALS['TL_LANG']['tl_theme']['tables_ok'] .'
';
			}

			// Check the custom templates
			$return .= '

'.$GLOBALS['TL_LANG']['tl_theme']['custom_templates'].'
';

			$objArchive->reset();
			$blnTplExists = false;

			// Loop through the archive
			while ($objArchive->next())
			{
				if (strncmp($objArchive->file_name, 'templates/', 10) !== 0)
				{
					continue;
				}

				if (file_exists($this->strRootDir .'/'. $objArchive->file_name))
				{
					$blnTplExists = true;
					$return .= "\n  " . '
'. sprintf($GLOBALS['TL_LANG']['tl_theme']['template_exists'], $objArchive->file_name) .'
';
				}
			}

			// Confirmation
			if (!$blnTplExists)
			{
				$return .= "\n  " . '
'. $GLOBALS['TL_LANG']['tl_theme']['templates_ok'] .'
';
			}

			// HOOK: add custom logic
			if (isset($GLOBALS['TL_HOOKS']['compareThemeFiles']) && \is_array($GLOBALS['TL_HOOKS']['compareThemeFiles']))
			{
				foreach ($GLOBALS['TL_HOOKS']['compareThemeFiles'] as $callback)
				{
					$return .= System::importStatic($callback[0])->{$callback[1]}($xml, $objArchive);
				}
			}

			$return .= '

';
		}

		// Return the form
		return $return . '

  <button type="submit" name="save" id="save" class="tl_submit" accesskey="s">'.$GLOBALS['TL_LANG']['MSC']['continue'].'</button>

</form>';
	}

	/**
	 * Extract the theme files and write the data to the database
	 *
	 * @param array $arrFiles
	 * @param array $arrDbFields
	 */
	protected function extractThemeFiles($arrFiles, $arrDbFields)
	{
		foreach ($arrFiles as $strZipFile)
		{
			$xml = null;

			// Open the archive
			$objArchive = new ZipReader($strZipFile);

			// Extract all files
			while ($objArchive->next())
			{
				// Load the XML file
				if ($objArchive->file_name == 'theme.xml')
				{
					$xml = new \DOMDocument();
					$xml->preserveWhiteSpace = false;
					$xml->loadXML($objArchive->unzip());
					continue;
				}

				// Limit file operations to files and the templates directory
				if (strncmp($objArchive->file_name, 'files/', 6) !== 0 && strncmp($objArchive->file_name, 'tl_files/', 9) !== 0 && strncmp($objArchive->file_name, 'templates/', 10) !== 0)
				{
					Message::addError(sprintf($GLOBALS['TL_LANG']['ERR']['invalidFile'], $objArchive->file_name));
					continue;
				}

				// Extract the files
				try
				{
					File::putContent($this->customizeUploadPath($objArchive->file_name), $objArchive->unzip());
				}
				catch (\Exception $e)
				{
					Message::addError($e->getMessage());
				}
			}

			// Continue if there is no XML file
			if (!$xml instanceof \DOMDocument)
			{
				Message::addError(sprintf($GLOBALS['TL_LANG']['tl_theme']['missing_xml'], basename($strZipFile)));
				continue;
			}

			$arrMapper = array();
			$tables = $xml->getElementsByTagName('table');
			$arrNewFolders = array();

			// Extract the folder names from the XML file
			for ($i=0; $i<$tables->length; $i++)
			{
				if ($tables->item($i)->getAttribute('name') == 'tl_theme')
				{
					$fields = $tables->item($i)->childNodes->item(0)->childNodes;

					for ($k=0; $k<$fields->length; $k++)
					{
						if ($fields->item($k)->getAttribute('name') == 'folders')
						{
							$arrNewFolders = StringUtil::deserialize($fields->item($k)->nodeValue);
							break;
						}
					}

					break;
				}
			}

			// Sync the new folder(s)
			if (!empty($arrNewFolders) && \is_array($arrNewFolders))
			{
				foreach ($arrNewFolders as $strFolder)
				{
					$strCustomized = $this->customizeUploadPath($strFolder);

					if (Dbafs::shouldBeSynchronized($strCustomized))
					{
						Dbafs::addResource($strCustomized);
					}
				}
			}

			// Lock the tables
			$arrLocks = array
			(
				'tl_files'           => 'WRITE',
				'tl_theme'           => 'WRITE',
				'tl_style_sheet'     => 'WRITE',
				'tl_style'           => 'WRITE',
				'tl_module'          => 'WRITE',
				'tl_layout'          => 'WRITE',
				'tl_image_size'      => 'WRITE',
				'tl_image_size_item' => 'WRITE'
			);

			// Load the DCAs of the locked tables (see #7345)
			foreach (array_keys($arrLocks) as $table)
			{
				$this->loadDataContainer($table);
			}

			$this->Database->lockTables($arrLocks);

			// Get the current auto_increment values
			$tl_files = $this->Database->getNextId('tl_files');
			$tl_theme = $this->Database->getNextId('tl_theme');
			$tl_style_sheet = $this->Database->getNextId('tl_style_sheet');
			$tl_style = $this->Database->getNextId('tl_style');
			$tl_module = $this->Database->getNextId('tl_module');
			$tl_layout = $this->Database->getNextId('tl_layout');
			$tl_image_size = $this->Database->getNextId('tl_image_size');
			$tl_image_size_item = $this->Database->getNextId('tl_image_size_item');

			// Build the mapper data (see #8326)
			for ($i=0; $i<$tables->length; $i++)
			{
				$rows = $tables->item($i)->childNodes;
				$table = $tables->item($i)->getAttribute('name');

				// Skip invalid tables
				if (!\array_key_exists($table, $arrLocks))
				{
					continue;
				}

				// Loop through the rows
				for ($j=0; $j<$rows->length; $j++)
				{
					$fields = $rows->item($j)->childNodes;

					// Loop through the fields
					for ($k=0; $k<$fields->length; $k++)
					{
						// Increment the ID
						if ($fields->item($k)->getAttribute('name') == 'id')
						{
							$arrMapper[$table][$fields->item($k)->nodeValue] = ${$table}++;
							break;
						}
					}
				}
			}

			// Loop through the tables
			for ($i=0; $i<$tables->length; $i++)
			{
				$rows = $tables->item($i)->childNodes;
				$table = $tables->item($i)->getAttribute('name');

				// Skip invalid tables
				if (!\array_key_exists($table, $arrLocks))
				{
					continue;
				}

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

				// Loop through the rows
				for ($j=0; $j<$rows->length; $j++)
				{
					$set = array();
					$fields = $rows->item($j)->childNodes;

					// Loop through the fields
					for ($k=0; $k<$fields->length; $k++)
					{
						$value = $fields->item($k)->nodeValue;
						$name = $fields->item($k)->getAttribute('name');

						// Skip NULL values
						if ($value == 'NULL')
						{
							continue;
						}

						// Increment the ID
						elseif ($name == 'id')
						{
							$value = $arrMapper[$table][$value];
						}

						// Increment the parent IDs
						elseif ($name == 'pid')
						{
							if ($table == 'tl_style')
							{
								$value = $arrMapper['tl_style_sheet'][$value];
							}
							elseif ($table == 'tl_image_size_item')
							{
								$value = $arrMapper['tl_image_size'][$value];
							}
							else
							{
								$value = $arrMapper['tl_theme'][$value];
							}
						}

						// Handle fallback fields
						elseif ($name == 'fallback')
						{
							$value = '';
						}

						// Adjust the style sheet IDs of the page layout
						elseif ($table == 'tl_layout' && $name == 'stylesheet')
						{
							$stylesheets = StringUtil::deserialize($value);

							if (\is_array($stylesheets))
							{
								foreach (array_keys($stylesheets) as $key)
								{
									$stylesheets[$key] = $arrMapper['tl_style_sheet'][$stylesheets[$key]];
								}

								$value = serialize($stylesheets);
							}
						}

						// Adjust the module IDs of the page layout
						elseif ($table == 'tl_layout' && $name == 'modules')
						{
							$modules = StringUtil::deserialize($value);

							if (\is_array($modules))
							{
								foreach ($modules as $key=>$mod)
								{
									if ($mod['mod'] > 0)
									{
										$modules[$key]['mod'] = $arrMapper['tl_module'][$mod['mod']];
									}
								}

								$value = serialize($modules);
							}
						}

						// Adjust duplicate theme and style sheet names
						elseif (($table == 'tl_theme' || $table == 'tl_style_sheet') && $name == 'name')
						{
							$objCount = $this->Database->prepare("SELECT COUNT(*) AS count FROM ". $table ." WHERE name=?")
													   ->execute($value);

							if ($objCount->count > 0)
							{
								$value = preg_replace('/( |\-)[0-9]+$/', '', $value);
								$value .= (($table == 'tl_style_sheet') ? '-' : ' ') . ${$table};
							}
						}

						// Adjust the file paths in style sheets and tl_files
						elseif (($table == 'tl_style_sheet' || $table == 'tl_style' || ($table == 'tl_files' && $name == 'path')) && strpos($value, 'files') !== false)
						{
							$tmp = StringUtil::deserialize($value);

							if (\is_array($tmp))
							{
								foreach ($tmp as $kk=>$vv)
								{
									$tmp[$kk] = $this->customizeUploadPath($vv);
								}

								$value = serialize($tmp);
							}
							else
							{
								$value = $this->customizeUploadPath($value);
							}
						}

						// Replace the file paths in singleSRC fields with their tl_files ID
						elseif ($GLOBALS['TL_DCA'][$table]['fields'][$name]['inputType'] == 'fileTree' && !$GLOBALS['TL_DCA'][$table]['fields'][$name]['eval']['multiple'])
						{
							if (!$value)
							{
								$value = null; // Contao >= 3.2
							}
							else
							{
								// Do not use the FilesModel here – tables are locked!
								$objFile = $this->Database->prepare("SELECT uuid FROM tl_files WHERE path=?")
														  ->limit(1)
														  ->execute($this->customizeUploadPath($value));

								$value = $objFile->uuid;
							}
						}

						// Replace the file paths in multiSRC fields with their tl_files ID
						elseif ($GLOBALS['TL_DCA'][$table]['fields'][$name]['inputType'] == 'fileTree' || \in_array($name, $arrOrder))
						{
							$tmp = StringUtil::deserialize($value);

							if (\is_array($tmp))
							{
								foreach ($tmp as $kk=>$vv)
								{
									// Do not use the FilesModel here – tables are locked!
									$objFile = $this->Database->prepare("SELECT uuid FROM tl_files WHERE path=?")
															  ->limit(1)
															  ->execute($this->customizeUploadPath($vv));

									$tmp[$kk] = $objFile->uuid;
								}

								$value = serialize($tmp);
							}
						}

						// Adjust the imageSize widget data
						elseif ($GLOBALS['TL_DCA'][$table]['fields'][$name]['inputType'] == 'imageSize')
						{
							$imageSizes = StringUtil::deserialize($value, true);

							if (!empty($imageSizes))
							{
								if (is_numeric($imageSizes[2]))
								{
									$imageSizes[2] = $arrMapper['tl_image_size'][$imageSizes[2]];
								}
							}

							$value = serialize($imageSizes);
						}

						$set[$name] = $value;
					}

					// Skip fields that are not in the database (e.g. because of missing extensions)
					foreach ($set as $k=>$v)
					{
						if (!\in_array($k, $arrDbFields[$table]))
						{
							unset($set[$k]);
						}
					}

					// Create the templates folder even if it is empty (see #4793)
					if ($table == 'tl_theme' && isset($set['templates']) && strncmp($set['templates'], 'templates/', 10) === 0 && !is_dir($this->strRootDir . '/' . $set['templates']))
					{
						new Folder($set['templates']);
					}

					// Update tl_files (entries have been created by the Dbafs class)
					if ($table == 'tl_files')
					{
						$this->Database->prepare("UPDATE $table %s WHERE path=?")->set($set)->execute($set['path']);
					}
					else
					{
						$this->Database->prepare("INSERT INTO $table %s")->set($set)->execute();
					}
				}
			}

			// Unlock the tables
			$this->Database->unlockTables();

			// Update the style sheets
			$this->import(StyleSheets::class, 'StyleSheets');
			$this->StyleSheets->updateStyleSheets();

			// Notify the user
			Message::addConfirmation(sprintf($GLOBALS['TL_LANG']['tl_theme']['theme_imported'], basename($strZipFile)));

			// HOOK: add custom logic
			if (isset($GLOBALS['TL_HOOKS']['extractThemeFiles']) && \is_array($GLOBALS['TL_HOOKS']['extractThemeFiles']))
			{
				$intThemeId = empty($arrMapper['tl_theme']) ? null : reset($arrMapper['tl_theme']);

				foreach ($GLOBALS['TL_HOOKS']['extractThemeFiles'] as $callback)
				{
					System::importStatic($callback[0])->{$callback[1]}($xml, $objArchive, $intThemeId, $arrMapper);
				}
			}

			unset($tl_files, $tl_theme, $tl_style_sheet, $tl_style, $tl_module, $tl_layout, $tl_image_size, $tl_image_size_item);
		}

		System::setCookie('BE_PAGE_OFFSET', 0, 0);

		/** @var Session $objSession */
		$objSession = System::getContainer()->get('session');
		$objSession->remove('uploaded_themes');

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

		$this->redirect(str_replace('&key=importTheme', '', Environment::get('request')));
	}

	/**
	 * Export a theme
	 *
	 * @param DataContainer $dc
	 */
	public function exportTheme(DataContainer $dc)
	{
		// Get the theme meta data
		$objTheme = $this->Database->prepare("SELECT * FROM tl_theme WHERE id=?")
								   ->limit(1)
								   ->execute($dc->id);

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

		// Romanize the name
		$strName = Utf8::toAscii($objTheme->name);
		$strName = strtolower(str_replace(' ', '_', $strName));
		$strName = preg_replace('/[^A-Za-z0-9._-]/', '', $strName);
		$strName = basename($strName);

		// Create a new XML document
		$xml = new \DOMDocument('1.0', 'UTF-8');
		$xml->formatOutput = true;

		// Root element
		$tables = $xml->createElement('tables');
		$tables = $xml->appendChild($tables);

		// Add the tables
		$this->addTableTlTheme($xml, $tables, $objTheme);
		$this->addTableTlStyleSheet($xml, $tables, $objTheme);
		$this->addTableTlImageSize($xml, $tables, $objTheme);
		$this->addTableTlModule($xml, $tables, $objTheme);
		$this->addTableTlLayout($xml, $tables, $objTheme);

		// Generate the archive
		$strTmp = md5(uniqid(mt_rand(), true));
		$objArchive = new ZipWriter('system/tmp/'. $strTmp);

		// Add the files
		$this->addTableTlFiles($xml, $tables, $objTheme, $objArchive);

		// Add the template files
		$this->addTemplatesToArchive($objArchive, $objTheme->templates);

		// HOOK: add custom logic
		if (isset($GLOBALS['TL_HOOKS']['exportTheme']) && \is_array($GLOBALS['TL_HOOKS']['exportTheme']))
		{
			foreach ($GLOBALS['TL_HOOKS']['exportTheme'] as $callback)
			{
				System::importStatic($callback[0])->{$callback[1]}($xml, $objArchive, $objTheme->id);
			}
		}

		// Add the XML document
		$objArchive->addString($xml->saveXML(), 'theme.xml');

		// Close the archive
		$objArchive->close();

		// Open the "save as …" dialogue
		$objFile = new File('system/tmp/'. $strTmp);
		$objFile->sendToBrowser($strName . '.cto');
	}

	/**
	 * Add the table tl_theme
	 *
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $tables
	 * @param Result               $objTheme
	 */
	protected function addTableTlTheme(\DOMDocument $xml, \DOMNode $tables, Result $objTheme)
	{
		// Add the table
		$table = $xml->createElement('table');
		$table->setAttribute('name', 'tl_theme');
		$table = $tables->appendChild($table);

		// Load the DCA
		$this->loadDataContainer('tl_theme');

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

		// Add the row
		$this->addDataRow($xml, $table, $objTheme->row(), $arrOrder);
	}

	/**
	 * Add the table tl_style_sheet
	 *
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $tables
	 * @param Result               $objTheme
	 */
	protected function addTableTlStyleSheet(\DOMDocument $xml, \DOMNode $tables, Result $objTheme)
	{
		// Add the table
		$table = $xml->createElement('table');
		$table->setAttribute('name', 'tl_style_sheet');
		$table = $tables->appendChild($table);

		// Load the DCA
		$this->loadDataContainer('tl_style_sheet');

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

		// Get all style sheets
		$objStyleSheet = $this->Database->prepare("SELECT * FROM tl_style_sheet WHERE pid=? ORDER BY name")
										->execute($objTheme->id);

		// Add the rows
		while ($objStyleSheet->next())
		{
			$this->addDataRow($xml, $table, $objStyleSheet->row(), $arrOrder);
		}

		$objStyleSheet->reset();

		// Add the child table
		$table = $xml->createElement('table');
		$table->setAttribute('name', 'tl_style');
		$table = $tables->appendChild($table);

		// Load the DCA
		$this->loadDataContainer('tl_style');

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

		// Add the child rows
		while ($objStyleSheet->next())
		{
			// Get all format definitions
			$objStyle = $this->Database->prepare("SELECT * FROM tl_style WHERE pid=? ORDER BY sorting")
									   ->execute($objStyleSheet->id);

			// Add the rows
			while ($objStyle->next())
			{
				$this->addDataRow($xml, $table, $objStyle->row(), $arrOrder);
			}
		}
	}

	/**
	 * Add the table tl_module
	 *
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $tables
	 * @param Result               $objTheme
	 */
	protected function addTableTlModule(\DOMDocument $xml, \DOMNode $tables, Result $objTheme)
	{
		// Add the table
		$table = $xml->createElement('table');
		$table->setAttribute('name', 'tl_module');
		$table = $tables->appendChild($table);

		// Load the DCA
		$this->loadDataContainer('tl_module');

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

		// Get all modules
		$objModule = $this->Database->prepare("SELECT * FROM tl_module WHERE pid=? ORDER BY name")
									->execute($objTheme->id);

		// Add the rows
		while ($objModule->next())
		{
			$this->addDataRow($xml, $table, $objModule->row(), $arrOrder);
		}
	}

	/**
	 * Add the table tl_layout
	 *
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $tables
	 * @param Result               $objTheme
	 */
	protected function addTableTlLayout(\DOMDocument $xml, \DOMNode $tables, Result $objTheme)
	{
		// Add the table
		$table = $xml->createElement('table');
		$table->setAttribute('name', 'tl_layout');
		$table = $tables->appendChild($table);

		// Load the DCA
		$this->loadDataContainer('tl_layout');

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

		// Get all layouts
		$objLayout = $this->Database->prepare("SELECT * FROM tl_layout WHERE pid=? ORDER BY name")
									->execute($objTheme->id);

		// Add the rows
		while ($objLayout->next())
		{
			$this->addDataRow($xml, $table, $objLayout->row(), $arrOrder);
		}
	}

	/**
	 * Add the table tl_image_size
	 *
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $tables
	 * @param Result               $objTheme
	 */
	protected function addTableTlImageSize(\DOMDocument $xml, \DOMNode $tables, Result $objTheme)
	{
		// Add the tables
		$imageSizeTable = $xml->createElement('table');
		$imageSizeTable->setAttribute('name', 'tl_image_size');
		$imageSizeTable = $tables->appendChild($imageSizeTable);

		$imageSizeItemTable = $xml->createElement('table');
		$imageSizeItemTable->setAttribute('name', 'tl_image_size_item');
		$imageSizeItemTable = $tables->appendChild($imageSizeItemTable);

		// Get all sizes
		$objSizes = $this->Database->prepare("SELECT * FROM tl_image_size WHERE pid=?")
								   ->execute($objTheme->id);

		// Add the rows
		while ($objSizes->next())
		{
			$this->addDataRow($xml, $imageSizeTable, $objSizes->row());

			// Get all size items
			$objSizeItems = $this->Database->prepare("SELECT * FROM tl_image_size_item WHERE pid=?")
										   ->execute($objSizes->id);

			// Add the rows
			while ($objSizeItems->next())
			{
				$this->addDataRow($xml, $imageSizeItemTable, $objSizeItems->row());
			}
		}
	}

	/**
	 * Add the table tl_files to the XML and the files to the archive
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $tables
	 * @param Result               $objTheme
	 * @param ZipWriter            $objArchive
	 */
	protected function addTableTlFiles(\DOMDocument $xml, \DOMElement $tables, Result $objTheme, ZipWriter $objArchive)
	{
		// Add the table
		$table = $xml->createElement('table');
		$table->setAttribute('name', 'tl_files');
		$table = $tables->appendChild($table);

		// Load the DCA
		$this->loadDataContainer('tl_files');

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

		// Add the folders
		$arrFolders = StringUtil::deserialize($objTheme->folders);

		if (!empty($arrFolders) && \is_array($arrFolders))
		{
			$objFolders = FilesModel::findMultipleByUuids($arrFolders);

			if ($objFolders !== null)
			{
				foreach ($this->eliminateNestedPaths($objFolders->fetchEach('path')) as $strFolder)
				{
					$this->addFolderToArchive($objArchive, $strFolder, $xml, $table, $arrOrder);
				}
			}
		}
	}

	/**
	 * Add a data row to the XML document
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $table
	 * @param array                $arrRow
	 * @param array                $arrOrder
	 */
	protected function addDataRow(\DOMDocument $xml, \DOMElement $table, array $arrRow, array $arrOrder=array())
	{
		$t = $table->getAttribute('name');

		$row = $xml->createElement('row');
		$row = $table->appendChild($row);

		foreach ($arrRow as $k=>$v)
		{
			$field = $xml->createElement('field');
			$field->setAttribute('name', $k);
			$field = $row->appendChild($field);

			if ($v === null)
			{
				$v = 'NULL';
			}

			// Replace the IDs of singleSRC fields with their path (see #4952)
			elseif ($GLOBALS['TL_DCA'][$t]['fields'][$k]['inputType'] == 'fileTree' && !$GLOBALS['TL_DCA'][$t]['fields'][$k]['eval']['multiple'])
			{
				$objFile = FilesModel::findByUuid($v);

				if ($objFile !== null)
				{
					$v = $this->standardizeUploadPath($objFile->path);
				}
				else
				{
					$v = 'NULL';
				}
			}

			// Replace the IDs of multiSRC fields with their paths (see #4952)
			elseif ($GLOBALS['TL_DCA'][$t]['fields'][$k]['inputType'] == 'fileTree' || \in_array($k, $arrOrder))
			{
				$arrFiles = StringUtil::deserialize($v);

				if (!empty($arrFiles) && \is_array($arrFiles))
				{
					$objFiles = FilesModel::findMultipleByUuids($arrFiles);

					if ($objFiles !== null)
					{
						$arrTmp = array();

						while ($objFiles->next())
						{
							$arrTmp[] = $this->standardizeUploadPath($objFiles->path);
						}

						$v = serialize($arrTmp);
					}
					else
					{
						$v = 'NULL';
					}
				}
			}
			elseif ($t == 'tl_style' && ($k == 'bgimage' || $k == 'liststyleimage'))
			{
				$v = $this->standardizeUploadPath($v);
			}

			$value = $xml->createTextNode($v);
			$field->appendChild($value);
		}
	}

	/**
	 * Recursively add a folder to the archive
	 *
	 * @param ZipWriter            $objArchive
	 * @param string               $strFolder
	 * @param \DOMDocument         $xml
	 * @param \DOMNode|\DOMElement $table
	 * @param array                $arrOrder
	 *
	 * @throws \Exception If the folder path is insecure
	 */
	protected function addFolderToArchive(ZipWriter $objArchive, $strFolder, \DOMDocument $xml, \DOMElement $table, array $arrOrder=array())
	{
		// Strip the custom upload folder name
		$strFolder = preg_replace('@^'.preg_quote(Config::get('uploadPath'), '@').'/@', '', $strFolder);

		// Add the default upload folder name
		if ($strFolder == '')
		{
			$strTarget = 'files';
			$strFolder = Config::get('uploadPath');
		}
		else
		{
			$strTarget = 'files/' . $strFolder;
			$strFolder = Config::get('uploadPath') .'/'. $strFolder;
		}

		if (Validator::isInsecurePath($strFolder))
		{
			throw new \RuntimeException('Insecure path ' . $strFolder);
		}

		// Return if the folder does not exist
		if (!is_dir($this->strRootDir .'/'. $strFolder))
		{
			return;
		}

		// Recursively add the files and subfolders
		foreach (scan($this->strRootDir .'/'. $strFolder) as $strFile)
		{
			// Skip hidden resources
			if (strncmp($strFile, '.', 1) === 0)
			{
				continue;
			}

			if (is_dir($this->strRootDir .'/'. $strFolder .'/'. $strFile))
			{
				$this->addFolderToArchive($objArchive, $strFolder .'/'. $strFile, $xml, $table, $arrOrder);
			}
			else
			{
				// Always store files in files and convert the directory upon import
				$objArchive->addFile($strFolder .'/'. $strFile, $strTarget .'/'. $strFile);

				$arrRow = array();
				$objFile = new File($strFolder .'/'. $strFile);
				$objModel = FilesModel::findByPath($strFolder .'/'. $strFile);

				if ($objModel !== null)
				{
					$arrRow = $objModel->row();

					foreach (array('id', 'pid', 'tstamp', 'uuid', 'type', 'extension', 'found', 'name') as $key)
					{
						unset($arrRow[$key]);
					}
				}

				// Always use files as directory and convert it upon import
				$arrRow['path'] = $strTarget .'/'. $strFile;
				$arrRow['hash'] = $objFile->hash;

				// Add the row
				$this->addDataRow($xml, $table, $arrRow, $arrOrder);
			}
		}
	}

	/**
	 * Add templates to the archive
	 *
	 * @param ZipWriter $objArchive
	 * @param string    $strFolder
	 */
	protected function addTemplatesToArchive(ZipWriter $objArchive, $strFolder)
	{
		// Strip the templates folder name
		$strFolder = preg_replace('@^templates/@', '', $strFolder);

		// Re-add the templates folder name
		if ($strFolder == '')
		{
			$strFolder = 'templates';
		}
		else
		{
			$strFolder = 'templates/' . $strFolder;
		}

		if (Validator::isInsecurePath($strFolder))
		{
			throw new \RuntimeException('Insecure path ' . $strFolder);
		}

		// Return if the folder does not exist
		if (!is_dir($this->strRootDir .'/'. $strFolder))
		{
			return;
		}

		// Add all template files to the archive (see #7048)
		foreach (scan($this->strRootDir .'/'. $strFolder) as $strFile)
		{
			if (preg_match('/\.(html5|sql)$/', $strFile) && strncmp($strFile, 'be_', 3) !== 0 && strncmp($strFile, 'nl_', 3) !== 0)
			{
				$objArchive->addFile($strFolder .'/'. $strFile);
			}
		}
	}

	/**
	 * Replace files/ with the custom upload folder name
	 *
	 * @param string $strPath
	 *
	 * @return string
	 */
	protected function customizeUploadPath($strPath)
	{
		if ($strPath == '')
		{
			return '';
		}

		return preg_replace('@^(tl_)?files/@', Config::get('uploadPath') . '/', $strPath);
	}

	/**
	 * Replace a custom upload folder name with files/
	 *
	 * @param string $strPath
	 *
	 * @return string
	 */
	protected function standardizeUploadPath($strPath)
	{
		if ($strPath == '')
		{
			return '';
		}

		return preg_replace('@^' . preg_quote(Config::get('uploadPath'), '@') . '/@', 'files/', $strPath);
	}
}

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


<?php

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

namespace Contao;

use Contao\Filter\SyncExclude;
use Contao\Model\Collection;

/**
 * Handles the database assisted file system (DBAFS)
 *
 * The class provides static methods to add, move, copy and delete resources as
 * well as a method to synchronize the file system and the database.
 *
 * Usage:
 *
 *     $file = Dbafs::addResource('files/james-wilson.jpg');
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class Dbafs
{

	/**
	 * Synchronize the database
	 * @var array
	 */
	protected static $arrShouldBeSynchronized = array();

	/**
	 * Adds a file or folder with its parent folders
	 *
	 * @param string  $strResource      The path to the file or folder
	 * @param boolean $blnUpdateFolders If true, the parent folders will be updated
	 *
	 * @return FilesModel The files model
	 *
	 * @throws \Exception                If a parent ID entry is missing
	 * @throws \InvalidArgumentException If the resource is outside the upload folder
	 */
	public static function addResource($strResource, $blnUpdateFolders=true)
	{
		$strUploadPath = Config::get('uploadPath') . '/';
		$rootDir = System::getContainer()->getParameter('kernel.project_dir');

		// Remove trailing slashes (see #5707)
		if (substr($strResource, -1) == '/')
		{
			$strResource = substr($strResource, 0, -1);
		}

		// Normalize the path (see #6034)
		$strResource = str_replace(array('\\', '//'), '/', $strResource);

		// The resource does not exist or lies outside the upload directory
		if ($strResource == '' || strncmp($strResource, $strUploadPath, \strlen($strUploadPath)) !== 0 || !file_exists($rootDir . '/' . $strResource))
		{
			throw new \InvalidArgumentException("Invalid resource $strResource");
		}

		$objModel = FilesModel::findByPath($strResource);

		// Return the model if it exists already
		if ($objModel !== null)
		{
			$objFile = ($objModel->type == 'folder') ? new Folder($objModel->path) : new File($objModel->path);

			// Update the timestamp and file hash (see #4818, #7828)
			if ($objModel->hash != $objFile->hash)
			{
				$objModel->tstamp = time();
				$objModel->hash   = $objFile->hash;
				$objModel->save();
			}

			return $objModel;
		}

		$arrPaths    = array();
		$arrChunks   = explode('/', $strResource);
		$strPath     = array_shift($arrChunks);
		$arrPids     = array($strPath => null);
		$arrUpdate   = array($strResource);
		$objDatabase = Database::getInstance();

		// Build the paths
		while (\count($arrChunks))
		{
			$strPath .= '/' . array_shift($arrChunks);
			$arrPaths[] = $strPath;
		}

		unset($arrChunks);

		$objModels = FilesModel::findMultipleByPaths($arrPaths);

		// Unset the entries in $arrPaths if the DB entry exists
		if ($objModels !== null)
		{
			while ($objModels->next())
			{
				if (($i = array_search($objModels->path, $arrPaths)) !== false)
				{
					unset($arrPaths[$i]);
					$arrPids[$objModels->path] = $objModels->uuid;
				}
			}
		}

		$arrPaths = array_values($arrPaths);

		// If the resource is a folder, also add its contents
		if (is_dir($rootDir . '/' . $strResource))
		{
			/** @var \SplFileInfo[] $objFiles */
			$objFiles = new \RecursiveIteratorIterator(
				new SyncExclude(
					new \RecursiveDirectoryIterator(
						$rootDir . '/' . $strResource,
						\FilesystemIterator::UNIX_PATHS|\FilesystemIterator::FOLLOW_SYMLINKS|\FilesystemIterator::SKIP_DOTS
					)
				), \RecursiveIteratorIterator::SELF_FIRST
			);

			// Add the relative path
			foreach ($objFiles as $objFile)
			{
				$strRelpath = StringUtil::stripRootDir($objFile->getPathname());

				if ($objFile->isDir())
				{
					$arrUpdate[] = $strRelpath;
				}

				$arrPaths[] = $strRelpath;
			}
		}

		$objReturn = null;

		// Create the new resources
		foreach ($arrPaths as $strPath)
		{
			if (\in_array(basename($strPath), array('.public', '.nosync')))
			{
				continue;
			}

			$strParent = \dirname($strPath);

			// The parent ID should be in $arrPids
			// Do not use isset() here, because the PID can be null
			if (\array_key_exists($strParent, $arrPids))
			{
				$strPid = $arrPids[$strParent];
			}
			else
			{
				throw new \Exception("No parent entry for $strParent");
			}

			// Create the file or folder
			if (is_file($rootDir . '/' . $strPath))
			{
				$objFile = new File($strPath);

				$objModel = new FilesModel();
				$objModel->pid       = $strPid;
				$objModel->tstamp    = time();
				$objModel->name      = $objFile->name;
				$objModel->type      = 'file';
				$objModel->path      = $objFile->path;
				$objModel->extension = $objFile->extension;
				$objModel->hash      = $objFile->hash;
				$objModel->uuid      = $objDatabase->getUuid();
				$objModel->save();

				$arrPids[$objFile->path] = $objModel->uuid;
			}
			else
			{
				$objFolder = new Folder($strPath);

				$objModel = new FilesModel();
				$objModel->pid       = $strPid;
				$objModel->tstamp    = time();
				$objModel->name      = $objFolder->name;
				$objModel->type      = 'folder';
				$objModel->path      = $objFolder->path;
				$objModel->extension = '';
				$objModel->uuid      = $objDatabase->getUuid();
				$objModel->save();

				$arrPids[$objFolder->path] = $objModel->uuid;
			}

			// Store the model to be returned (see #5979)
			if ($objModel->path == $strResource)
			{
				$objReturn = $objModel;
			}
		}

		// Update the folder hashes from bottom up after all file hashes are set
		foreach (array_reverse($arrPaths) as $strPath)
		{
			if (is_dir($rootDir . '/' . $strPath))
			{
				$objModel = FilesModel::findByPath($strPath);
				$objModel->hash = static::getFolderHash($strPath);
				$objModel->save();
			}
		}

		// Update the folder hashes
		if ($blnUpdateFolders)
		{
			static::updateFolderHashes($arrUpdate);
		}

		return $objReturn;
	}

	/**
	 * Moves a file or folder to a new location
	 *
	 * @param string $strSource      The source path
	 * @param string $strDestination The target path
	 *
	 * @return FilesModel The files model
	 */
	public static function moveResource($strSource, $strDestination)
	{
		$objFile = FilesModel::findByPath($strSource);

		// If there is no entry, directly add the destination
		if ($objFile === null)
		{
			$objFile = static::addResource($strDestination);
		}

		$strFolder = \dirname($strDestination);

		// Set the new parent ID
		if ($strFolder == Config::get('uploadPath'))
		{
			$objFile->pid = null;
		}
		else
		{
			$objFolder = FilesModel::findByPath($strFolder);

			if ($objFolder === null)
			{
				$objFolder = static::addResource($strFolder);
			}

			$objFile->pid = $objFolder->uuid;
		}

		// Save the resource
		$objFile->path = $strDestination;
		$objFile->name = basename($strDestination);
		$objFile->save();

		// Update all child records
		if ($objFile->type == 'folder')
		{
			$objFiles = FilesModel::findMultipleByBasepath($strSource . '/');

			if ($objFiles !== null)
			{
				while ($objFiles->next())
				{
					$objFiles->path = preg_replace('@^' . preg_quote($strSource, '@') . '/@', $strDestination . '/', $objFiles->path);
					$objFiles->save();
				}
			}
		}

		// Update the MD5 hash of the parent folders
		if (($strPath = \dirname($strSource)) != Config::get('uploadPath'))
		{
			static::updateFolderHashes($strPath);
		}
		if (($strPath = \dirname($strDestination)) != Config::get('uploadPath'))
		{
			static::updateFolderHashes($strPath);
		}

		return $objFile;
	}

	/**
	 * Copies a file or folder to a new location
	 *
	 * @param string $strSource      The source path
	 * @param string $strDestination The target path
	 *
	 * @return FilesModel The files model
	 */
	public static function copyResource($strSource, $strDestination)
	{
		$objDatabase = Database::getInstance();
		$objFile = FilesModel::findByPath($strSource);

		// Add the source entry
		if ($objFile === null)
		{
			$objFile = static::addResource($strSource);
		}

		$strFolder = \dirname($strDestination);

		/** @var FilesModel $objNewFile */
		$objNewFile = clone $objFile->current();

		// Set the new parent ID
		if ($strFolder == Config::get('uploadPath'))
		{
			$objNewFile->pid = null;
		}
		else
		{
			$objFolder = FilesModel::findByPath($strFolder);

			if ($objFolder === null)
			{
				$objFolder = static::addResource($strFolder);
			}

			$objNewFile->pid = $objFolder->uuid;
		}

		// Save the resource
		$objNewFile->tstamp = time();
		$objNewFile->uuid   = $objDatabase->getUuid();
		$objNewFile->path   = $strDestination;
		$objNewFile->name   = basename($strDestination);
		$objNewFile->save();

		// Update all child records
		if ($objFile->type == 'folder')
		{
			$objFiles = FilesModel::findMultipleByBasepath($strSource . '/');

			if ($objFiles !== null)
			{
				while ($objFiles->next())
				{
					/** @var FilesModel $objNew */
					$objNew = clone $objFiles->current();

					$objNew->pid    = $objNewFile->uuid;
					$objNew->tstamp = time();
					$objNew->uuid   = $objDatabase->getUuid();
					$objNew->path   = str_replace($strSource . '/', $strDestination . '/', $objFiles->path);
					$objNew->save();
				}
			}
		}

		// Update the MD5 hash of the parent folders
		if (($strPath = \dirname($strSource)) != Config::get('uploadPath'))
		{
			static::updateFolderHashes($strPath);
		}
		if (($strPath = \dirname($strDestination)) != Config::get('uploadPath'))
		{
			static::updateFolderHashes($strPath);
		}

		return $objNewFile;
	}

	/**
	 * Removes a file or folder
	 *
	 * @param string $strResource The path to the file or folder
	 *
	 * @return null Explicitly return null
	 */
	public static function deleteResource($strResource)
	{
		$objModel = FilesModel::findByPath($strResource);

		// Remove the resource
		if ($objModel !== null)
		{
			$objModel->delete();
		}

		// Look for subfolders and files
		$objFiles = FilesModel::findMultipleByBasepath($strResource . '/');

		// Remove subfolders and files as well
		if ($objFiles !== null)
		{
			while ($objFiles->next())
			{
				$objFiles->delete();
			}
		}

		static::updateFolderHashes(\dirname($strResource));

		return null;
	}

	/**
	 * Update the hashes of all parent folders of a resource
	 *
	 * @param mixed $varResource A path or an array of paths to update
	 */
	public static function updateFolderHashes($varResource)
	{
		$arrPaths  = array();

		if (!\is_array($varResource))
		{
			$varResource = array($varResource);
		}

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

		foreach ($varResource as $strResource)
		{
			$arrChunks = explode('/', $strResource);
			$strPath   = array_shift($arrChunks);

			// Do not check files
			if (is_file($rootDir . '/' . $strResource))
			{
				array_pop($arrChunks);
			}

			// Build the paths
			while (\count($arrChunks))
			{
				$strPath .= '/' . array_shift($arrChunks);
				$arrPaths[] = $strPath;
			}

			unset($arrChunks);
		}

		$arrPaths = array_values(array_unique($arrPaths));

		// Store the hash of each folder
		foreach (array_reverse($arrPaths) as $strPath)
		{
			$objModel  = FilesModel::findByPath($strPath);

			// The DB entry does not yet exist
			if ($objModel === null)
			{
				$objModel = static::addResource($strPath, false);
			}

			$objModel->hash = static::getFolderHash($strPath);
			$objModel->save();
		}
	}

	/**
	 * Synchronize the file system with the database
	 *
	 * @return string The path to the synchronization log file
	 *
	 * @throws \Exception If a parent ID entry is missing
	 */
	public static function syncFiles()
	{
		@ini_set('max_execution_time', 0);

		// Consider the suhosin.memory_limit (see #7035)
		if (\extension_loaded('suhosin'))
		{
			if (($limit = ini_get('suhosin.memory_limit')) !== '')
			{
				@ini_set('memory_limit', $limit);
			}
		}
		else
		{
			@ini_set('memory_limit', -1);
		}

		$objDatabase = Database::getInstance();

		// Lock the files table
		$objDatabase->lockTables(array('tl_files'=>'WRITE'));

		// Reset the "found" flag
		$objDatabase->query("UPDATE tl_files SET found=''");

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

		/** @var \SplFileInfo[] $objFiles */
		$objFiles = new \RecursiveIteratorIterator(
			new SyncExclude(
				new \RecursiveDirectoryIterator(
					$rootDir . '/' . Config::get('uploadPath'),
					\FilesystemIterator::UNIX_PATHS|\FilesystemIterator::FOLLOW_SYMLINKS|\FilesystemIterator::SKIP_DOTS
				)
			), \RecursiveIteratorIterator::SELF_FIRST
		);

		$strLog = 'system/tmp/' . md5(uniqid(mt_rand(), true));

		// Open the log file
		$objLog = new File($strLog);
		$objLog->truncate();

		$arrModels = array();
		$arrFoldersToHash = array();
		$arrFoldersToCompare = array();

		// Create or update the database entries
		foreach ($objFiles as $objFile)
		{
			$strRelpath = StringUtil::stripRootDir($objFile->getPathname());

			// Get all subfiles in a single query
			if ($objFile->isDir())
			{
				$objSubfiles = FilesModel::findMultipleFilesByFolder($strRelpath);

				if ($objSubfiles !== null)
				{
					while ($objSubfiles->next())
					{
						$arrModels[$objSubfiles->path] = $objSubfiles->current();
					}
				}
			}

			// Get the model
			if (isset($arrModels[$strRelpath]))
			{
				/** @var Model $objModel */
				$objModel = $arrModels[$strRelpath];
			}
			else
			{
				$objModel = FilesModel::findByPath($strRelpath);
			}

			if ($objModel === null)
			{
				// Add a log entry
				$objLog->append("[Added] $strRelpath");

				// Get the parent folder
				$strParent = \dirname($strRelpath);

				// Get the parent ID
				if ($strParent == Config::get('uploadPath'))
				{
					$strPid = null;
				}
				else
				{
					$objParent = FilesModel::findByPath($strParent);

					if ($objParent === null)
					{
						throw new \Exception("No parent entry for $strParent");
					}

					$strPid = $objParent->uuid;
				}

				// Create the file or folder
				if (is_file($rootDir . '/' . $strRelpath))
				{
					$objFile = new File($strRelpath);

					$objModel = new FilesModel();
					$objModel->pid       = $strPid;
					$objModel->tstamp    = time();
					$objModel->name      = $objFile->name;
					$objModel->type      = 'file';
					$objModel->path      = $objFile->path;
					$objModel->extension = $objFile->extension;
					$objModel->found     = 2;
					$objModel->hash      = $objFile->hash;
					$objModel->uuid      = $objDatabase->getUuid();
					$objModel->save();
				}
				else
				{
					$objFolder = new Folder($strRelpath);

					$objModel = new FilesModel();
					$objModel->pid       = $strPid;
					$objModel->tstamp    = time();
					$objModel->name      = $objFolder->name;
					$objModel->type      = 'folder';
					$objModel->path      = $objFolder->path;
					$objModel->extension = '';
					$objModel->found     = 2;
					$objModel->uuid      = $objDatabase->getUuid();
					$objModel->save();

					$arrFoldersToHash[] = $strRelpath;
				}
			}
			else
			{
				if ($objFile->isDir())
				{
					$arrFoldersToCompare[] = $objModel;
				}
				else
				{
					// Check whether the MD5 hash has changed
					$strHash = (new File($strRelpath))->hash;
					$strType = ($objModel->hash != $strHash) ? 'Changed' : 'Unchanged';

					// Add a log entry
					$objLog->append("[$strType] $strRelpath");

					// Update the record
					$objModel->found = 1;
					$objModel->hash  = $strHash;
					$objModel->save();
				}
			}
		}

		// Update the folder hashes from bottom up after all file hashes are set
		foreach (array_reverse($arrFoldersToHash) as $strPath)
		{
			$objModel = FilesModel::findByPath($strPath);
			$objModel->hash = static::getFolderHash($strPath);
			$objModel->save();
		}

		// Compare the folders after all hashes are set
		foreach (array_reverse($arrFoldersToCompare) as $objModel)
		{
			// Check whether the MD5 hash has changed
			$strHash = static::getFolderHash($objModel->path);
			$strType = ($objModel->hash != $strHash) ? 'Changed' : 'Unchanged';

			// Add a log entry
			$objLog->append("[$strType] {$objModel->path}");

			// Update the record
			$objModel->found = 1;
			$objModel->hash  = $strHash;
			$objModel->save();
		}

		// Check for left-over entries in the DB
		$objFiles = FilesModel::findByFound('');

		if ($objFiles !== null)
		{
			$arrMapped = array();
			$arrPidUpdate = array();

			/** @var Collection|FilesModel $objFiles */
			while ($objFiles->next())
			{
				$objFound = FilesModel::findBy(array('hash=?', 'found=2'), $objFiles->hash);

				if ($objFound !== null)
				{
					// Check for matching file names if the result is ambiguous (see #5644)
					if ($objFound->count() > 1)
					{
						while ($objFound->next())
						{
							if ($objFound->name == $objFiles->name)
							{
								$objFound = $objFound->current();
								break;
							}
						}
					}

					// If another file has been mapped already, delete the entry (see #6008)
					if (\in_array($objFound->path, $arrMapped))
					{
						$objLog->append("[Deleted] {$objFiles->path}");
						$objFiles->delete();
						continue;
					}

					$arrMapped[] = $objFound->path;

					// Store the PID change
					if ($objFiles->type == 'folder')
					{
						$arrPidUpdate[$objFound->uuid] = $objFiles->uuid;
					}

					// Add a log entry BEFORE changing the object
					$objLog->append("[Moved] {$objFiles->path} to {$objFound->path}");

					// Update the original entry
					$objFiles->pid    = $objFound->pid;
					$objFiles->tstamp = $objFound->tstamp;
					$objFiles->name   = $objFound->name;
					$objFiles->type   = $objFound->type;
					$objFiles->path   = $objFound->path;
					$objFiles->found  = 1;

					// Delete the newer (duplicate) entry
					$objFound->delete();

					// Then save the modified original entry (prevents duplicate key errors)
					$objFiles->save();
				}
				else
				{
					// Add a log entry BEFORE changing the object
					$objLog->append("[Deleted] {$objFiles->path}");

					// Delete the entry if the resource has gone
					$objFiles->delete();
				}
			}

			// Update the PID of the child records
			if (!empty($arrPidUpdate))
			{
				foreach ($arrPidUpdate as $from=>$to)
				{
					$objChildren = FilesModel::findByPid($from);

					if ($objChildren !== null)
					{
						while ($objChildren->next())
						{
							$objChildren->pid = $to;
							$objChildren->save();
						}
					}
				}
			}
		}

		// Close the log file
		$objLog->close();

		// Reset the found flag
		$objDatabase->query("UPDATE tl_files SET found=1 WHERE found=2");

		// Unlock the tables
		$objDatabase->unlockTables();

		// Return the path to the log file
		return $strLog;
	}

	/**
	 * Get the folder hash from the databse by combining the hashes of all children
	 *
	 * @param string $strPath The relative path
	 *
	 * @return string MD5 hash
	 */
	public static function getFolderHash($strPath)
	{
		$strPath = str_replace(array('\\', '%', '_'), array('\\\\', '\\%', '\\_'), $strPath);
		$arrHash = array();

		$objChildren = Database::getInstance()
			->prepare("SELECT hash, name FROM tl_files WHERE path LIKE ? AND path NOT LIKE ? ORDER BY name")
			->execute($strPath.'/%', $strPath.'/%/%')
		;

		if ($objChildren !== null)
		{
			while ($objChildren->next())
			{
				$arrHash[] = $objChildren->hash . $objChildren->name;
			}
		}

		return md5(implode("\0", $arrHash));
	}

	/**
	 * Check if the current resource should be synchronized with the database
	 *
	 * @param string $strPath The relative path
	 *
	 * @return bool True if the current resource needs to be synchronized with the database
	 */
	public static function shouldBeSynchronized($strPath)
	{
		if (!isset(static::$arrShouldBeSynchronized[$strPath]) || !\is_bool(static::$arrShouldBeSynchronized[$strPath]))
		{
			static::$arrShouldBeSynchronized[$strPath] = !static::isFileSyncExclude($strPath);
		}

		return static::$arrShouldBeSynchronized[$strPath];
	}

	/**
	 * Check if a file or folder is excluded from synchronization
	 *
	 * @param string $strPath The relative path
	 *
	 * @return bool True if the file or folder is excluded from synchronization
	 */
	protected static function isFileSyncExclude($strPath)
	{
		if (Config::get('uploadPath') == 'templates')
		{
			return true;
		}

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

		// Look for an existing parent folder (see #410)
		while ($strPath != '.' && !is_dir($rootDir . '/' . $strPath))
		{
			$strPath = \dirname($strPath);
		}

		if ($strPath == '.')
		{
			return true;
		}

		$uploadPath = System::getContainer()->getParameter('contao.upload_path');

		// Outside the files directory
		if (strncmp($strPath . '/', $uploadPath . '/', \strlen($uploadPath) + 1) !== 0)
		{
			return true;
		}

		return (new Folder($strPath))->isUnsynchronized();
	}
}

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


<?php

declare(strict_types=1);

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

namespace Contao\ManagerBundle\EventListener;

use Contao\BackendUser;
use Contao\CoreBundle\Event\MenuEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

final class BackendMenuListener
{
    /**
     * @var TokenStorageInterface
     */
    private $tokenStorage;

    /**
     * @var string
     */
    private $managerPath;

    public function __construct(TokenStorageInterface $tokenStorage, ?string $managerPath)
    {
        $this->tokenStorage = $tokenStorage;
        $this->managerPath = $managerPath;
    }

    /**
     * Adds a link to the Contao Manager in the back end navigation.
     */
    public function onBuild(MenuEvent $event): void
    {
        if (null === $this->managerPath || !$this->isAdminUser()) {
            return;
        }

        $categoryNode = $event->getTree()->getChild('system');

        if (null === $categoryNode) {
            return;
        }

        $item = $event->getFactory()->createItem(
            'contao_manager',
            [
                'label' => 'Contao Manager',
                'attributes' => [
                    'title' => 'Contao Manager',
                    'href' => '/'.$this->managerPath,
                    'class' => 'navigation contao_manager',
                ],
            ]
        );

        $categoryNode->addChild($item);
    }

    private function isAdminUser(): bool
    {
        $token = $this->tokenStorage->getToken();

        if (null === $token) {
            return false;
        }

        $user = $token->getUser();

        if (!$user instanceof BackendUser) {
            return false;
        }

        return $user->isAdmin;
    }
}


<?php

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

namespace Contao;

use Contao\CoreBundle\OptIn\OptIn;
use Patchwork\Utf8;
use Symfony\Component\HttpFoundation\Session\Session;

/**
 * Front end module "lost password".
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class ModulePassword extends Module
{

	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'mod_lostPassword';

	/**
	 * Display a wildcard in the back end
	 *
	 * @return string
	 */
	public function generate()
	{
		if (TL_MODE == 'BE')
		{
			$objTemplate = new BackendTemplate('be_wildcard');
			$objTemplate->wildcard = '### ' . Utf8::strtoupper($GLOBALS['TL_LANG']['FMD']['lostPassword'][0]) . ' ###';
			$objTemplate->title = $this->headline;
			$objTemplate->id = $this->id;
			$objTemplate->link = $this->name;
			$objTemplate->href = 'contao/main.php?do=themes&table=tl_module&act=edit&id=' . $this->id;

			return $objTemplate->parse();
		}

		return parent::generate();
	}

	/**
	 * Generate the module
	 */
	protected function compile()
	{
		/** @var PageModel $objPage */
		global $objPage;

		$GLOBALS['TL_LANGUAGE'] = $objPage->language;

		System::loadLanguageFile('tl_member');
		$this->loadDataContainer('tl_member');

		// Set new password
		if (strncmp(Input::get('token'), 'pw-', 3) === 0)
		{
			$this->setNewPassword();

			return;
		}

		// Username widget
		if (!$this->reg_skipName)
		{
			$arrFields['username'] = $GLOBALS['TL_DCA']['tl_member']['fields']['username'];
			$arrFields['username']['name'] = 'username';
		}

		// E-mail widget
		$arrFields['email'] = $GLOBALS['TL_DCA']['tl_member']['fields']['email'];
		$arrFields['email']['name'] = 'email';

		// Captcha widget
		if (!$this->disableCaptcha)
		{
			$arrFields['captcha'] = array
			(
				'name' => 'lost_password',
				'label' => $GLOBALS['TL_LANG']['MSC']['securityQuestion'],
				'inputType' => 'captcha',
				'eval' => array('mandatory'=>true)
			);
		}

		$row = 0;
		$strFields = '';
		$doNotSubmit = false;
		$strFormId = 'tl_lost_password_' . $this->id;

		// Initialize the widgets
		foreach ($arrFields as $arrField)
		{
			/** @var Widget $strClass */
			$strClass = $GLOBALS['TL_FFL'][$arrField['inputType']];

			// Continue if the class is not defined
			if (!class_exists($strClass))
			{
				continue;
			}

			$arrField['eval']['required'] = $arrField['eval']['mandatory'];

			/** @var Widget $objWidget */
			$objWidget = new $strClass($strClass::getAttributesFromDca($arrField, $arrField['name']));

			$objWidget->storeValues = true;
			$objWidget->rowClass = 'row_' . $row . (($row == 0) ? ' row_first' : '') . ((($row % 2) == 0) ? ' even' : ' odd');
			++$row;

			// Validate the widget
			if (Input::post('FORM_SUBMIT') == $strFormId)
			{
				$objWidget->validate();

				if ($objWidget->hasErrors())
				{
					$doNotSubmit = true;
				}
			}

			$strFields .= $objWidget->parse();
		}

		$this->Template->fields = $strFields;
		$this->Template->hasError = $doNotSubmit;

		// Look for an account and send the password link
		if (Input::post('FORM_SUBMIT') == $strFormId && !$doNotSubmit)
		{
			if ($this->reg_skipName)
			{
				$objMember = MemberModel::findActiveByEmailAndUsername(Input::post('email', true), null);
			}
			else
			{
				$objMember = MemberModel::findActiveByEmailAndUsername(Input::post('email', true), Input::post('username'));
			}

			if ($objMember === null)
			{
				sleep(2); // Wait 2 seconds while brute forcing :)
				$this->Template->error = $GLOBALS['TL_LANG']['MSC']['accountNotFound'];
			}
			else
			{
				$this->sendPasswordLink($objMember);
			}
		}

		$this->Template->formId = $strFormId;
		$this->Template->username = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['username']);
		$this->Template->email = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['emailAddress']);
		$this->Template->action = Environment::get('indexFreeRequest');
		$this->Template->slabel = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['requestPassword']);
		$this->Template->rowLast = 'row_' . $row . ' row_last' . ((($row % 2) == 0) ? ' even' : ' odd');
	}

	/**
	 * Set the new password
	 */
	protected function setNewPassword()
	{
		/** @var OptIn $optIn */
		$optIn = System::getContainer()->get('contao.opt-in');

		// Find an unconfirmed token with only one related record
		if ((!$optInToken = $optIn->find(Input::get('token'))) || !$optInToken->isValid() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_member' || \count($arrIds = current($arrRelated)) != 1 || (!$objMember = MemberModel::findByPk($arrIds[0])))
		{
			$this->strTemplate = 'mod_message';

			$this->Template = new FrontendTemplate($this->strTemplate);
			$this->Template->type = 'error';
			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['invalidToken'];

			return;
		}

		if ($optInToken->isConfirmed())
		{
			$this->strTemplate = 'mod_message';

			$this->Template = new FrontendTemplate($this->strTemplate);
			$this->Template->type = 'error';
			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenConfirmed'];

			return;
		}

		if ($optInToken->getEmail() != $objMember->email)
		{
			$this->strTemplate = 'mod_message';

			$this->Template = new FrontendTemplate($this->strTemplate);
			$this->Template->type = 'error';
			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenEmailMismatch'];

			return;
		}

		// Initialize the versioning (see #8301)
		$objVersions = new Versions('tl_member', $objMember->id);
		$objVersions->setUsername($objMember->username);
		$objVersions->setUserId(0);
		$objVersions->setEditUrl('contao/main.php?do=member&act=edit&id=%s&rt=1');
		$objVersions->initialize();

		// Define the form field
		$arrField = $GLOBALS['TL_DCA']['tl_member']['fields']['password'];

		/** @var Widget $strClass */
		$strClass = $GLOBALS['TL_FFL']['password'];

		// Fallback to default if the class is not defined
		if (!class_exists($strClass))
		{
			$strClass = 'FormPassword';
		}

		/** @var Widget $objWidget */
		$objWidget = new $strClass($strClass::getAttributesFromDca($arrField, 'password'));

		// Set row classes
		$objWidget->rowClass = 'row_0 row_first even';
		$objWidget->rowClassConfirm = 'row_1 odd';
		$this->Template->rowLast = 'row_2 row_last even';

		/** @var Session $objSession */
		$objSession = System::getContainer()->get('session');

		// Validate the field
		if (\strlen(Input::post('FORM_SUBMIT')) && Input::post('FORM_SUBMIT') == $objSession->get('setPasswordToken'))
		{
			$objWidget->validate();

			// Set the new password and redirect
			if (!$objWidget->hasErrors())
			{
				$objSession->set('setPasswordToken', '');

				$objMember->tstamp = time();
				$objMember->locked = 0; // see #8545
				$objMember->password = $objWidget->value;
				$objMember->save();

				$optInToken->confirm();

				// Create a new version
				if ($GLOBALS['TL_DCA']['tl_member']['config']['enableVersioning'])
				{
					$objVersions->create();
				}

				// HOOK: set new password callback
				if (isset($GLOBALS['TL_HOOKS']['setNewPassword']) && \is_array($GLOBALS['TL_HOOKS']['setNewPassword']))
				{
					foreach ($GLOBALS['TL_HOOKS']['setNewPassword'] as $callback)
					{
						$this->import($callback[0]);
						$this->{$callback[0]}->{$callback[1]}($objMember, $objWidget->value, $this);
					}
				}

				// Redirect to the jumpTo page
				if (($objTarget = $this->objModel->getRelated('reg_jumpTo')) instanceof PageModel)
				{
					/** @var PageModel $objTarget */
					$this->redirect($objTarget->getFrontendUrl());
				}

				// Confirm
				$this->strTemplate = 'mod_message';

				$this->Template = new FrontendTemplate($this->strTemplate);
				$this->Template->type = 'confirm';
				$this->Template->message = $GLOBALS['TL_LANG']['MSC']['newPasswordSet'];

				return;
			}
		}

		$strToken = md5(uniqid(mt_rand(), true));
		$objSession->set('setPasswordToken', $strToken);

		$this->Template->formId = $strToken;
		$this->Template->fields = $objWidget->parse();
		$this->Template->action = Environment::get('indexFreeRequest');
		$this->Template->slabel = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['setNewPassword']);
	}

	/**
	 * Create a new user and redirect
	 *
	 * @param MemberModel $objMember
	 */
	protected function sendPasswordLink($objMember)
	{
		/** @var OptIn $optIn */
		$optIn = System::getContainer()->get('contao.opt-in');
		$optInToken = $optIn->create('pw', $objMember->email, array('tl_member'=>array($objMember->id)));

		// Prepare the simple token data
		$arrData = $objMember->row();
		$arrData['activation'] = $optInToken->getIdentifier();
		$arrData['domain'] = Idna::decode(Environment::get('host'));
		$arrData['link'] = Idna::decode(Environment::get('base')) . Environment::get('request') . ((strpos(Environment::get('request'), '?') !== false) ? '&' : '?') . 'token=' . $optInToken->getIdentifier();

		// Send the token
		$optInToken->send(sprintf($GLOBALS['TL_LANG']['MSC']['passwordSubject'], Idna::decode(Environment::get('host'))), StringUtil::parseSimpleTokens($this->reg_password, $arrData));

		$this->log('A new password has been requested for user ID ' . $objMember->id . ' (' . Idna::decodeEmail($objMember->email) . ')', __METHOD__, TL_ACCESS);

		// Check whether there is a jumpTo page
		if (($objJumpTo = $this->objModel->getRelated('jumpTo')) instanceof PageModel)
		{
			$this->jumpToOrReload($objJumpTo->row());
		}

		$this->reload();
	}
}

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


<?php

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

namespace Contao;

use Symfony\Component\HttpFoundation\Response;

/**
 * Command scheduler controller.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class FrontendCron extends Frontend
{

	/**
	 * Run the controller
	 *
	 * @return Response
	 */
	public function run()
	{
		$objResponse = new Response('', 204);

		// Do not run if there is POST data or the last execution was less than a minute ago
		if (!empty($_POST) || $this->hasToWait())
		{
			return $objResponse;
		}

		$arrLock = array();
		$arrIntervals = array('monthly', 'weekly', 'daily', 'hourly', 'minutely');

		// Store the current timestamps
		$arrCurrent = array
		(
			'monthly'  => date('Ym'),
			'weekly'   => date('YW'),
			'daily'    => date('Ymd'),
			'hourly'   => date('YmdH'),
			'minutely' => date('YmdHi')
		);

		// Get the timestamps from tl_cron
		$objLock = $this->Database->query("SELECT * FROM tl_cron WHERE name !='lastrun'");

		while ($objLock->next())
		{
			$arrLock[$objLock->name] = $objLock->value;
		}

		// Create the database entries
		foreach ($arrIntervals as $strInterval)
		{
			if (!isset($arrLock[$strInterval]))
			{
				$arrLock[$strInterval] = 0;
				$this->Database->query("INSERT INTO tl_cron (name, value) VALUES ('$strInterval', 0)");
			}
		}

		// Load the default language file (see #8719)
		System::loadLanguageFile('default');

		// Run the jobs
		foreach ($arrIntervals as $strInterval)
		{
			$intCurrent = $arrCurrent[$strInterval];

			// Skip empty intervals and jobs that have been executed already
			if (empty($GLOBALS['TL_CRON'][$strInterval]) || $arrLock[$strInterval] == $intCurrent)
			{
				continue;
			}

			// Update the database before the jobs are executed, in case one of them fails
			$this->Database->query("UPDATE tl_cron SET value=$intCurrent WHERE name='$strInterval'");

			// Add a log entry if in debug mode (see #4729)
			if (Config::get('debugMode'))
			{
				$this->log('Running the ' . $strInterval . ' cron jobs', __METHOD__, TL_CRON);
			}

			foreach ($GLOBALS['TL_CRON'][$strInterval] as $callback)
			{
				$this->import($callback[0]);
				$this->{$callback[0]}->{$callback[1]}();
			}

			// Add a log entry if in debug mode (see #4729)
			if (Config::get('debugMode'))
			{
				$this->log(ucfirst($strInterval) . ' cron jobs complete', __METHOD__, TL_CRON);
			}
		}

		return $objResponse;
	}

	/**
	 * Check whether the last script execution was less than a minute ago
	 *
	 * @return boolean
	 */
	protected function hasToWait()
	{
		$return = true;

		// Get the timestamp without seconds (see #5775)
		$time = strtotime(date('Y-m-d H:i'));

		// Lock the table
		$this->Database->lockTables(array('tl_cron'=>'WRITE'));

		// Get the last execution date
		$objCron = $this->Database->prepare("SELECT * FROM tl_cron WHERE name='lastrun'")
								  ->limit(1)
								  ->execute();

		// Add the cron entry
		if ($objCron->numRows < 1)
		{
			$this->Database->query("INSERT INTO tl_cron (name, value) VALUES ('lastrun', $time)");
			$return = false;
		}

		// Check the last execution time
		elseif ($objCron->value <= ($time - $this->getCronTimeout()))
		{
			$this->Database->query("UPDATE tl_cron SET value=$time WHERE name='lastrun'");
			$return = false;
		}

		$this->Database->unlockTables();

		return $return;
	}
}

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


<?php

declare(strict_types=1);

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

namespace Contao\ManagerBundle\Composer;

use Composer\Script\Event;
use Composer\Util\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;

class ScriptHandler
{
    /**
     * Runs all Composer tasks to initialize a Contao Managed Edition.
     */
    public static function initializeApplication(Event $event): void
    {
        $webDir = self::getWebDir($event);

        static::purgeCacheFolder();
        static::addAppDirectory();
        static::executeCommand('contao:install-web-dir', $event);
        static::executeCommand('cache:clear --no-warmup', $event);
        static::executeCommand('cache:warmup', $event);
        static::executeCommand(sprintf('assets:install %s --symlink --relative', $webDir), $event);
        static::executeCommand(sprintf('contao:install %s', $webDir), $event);
        static::executeCommand(sprintf('contao:symlinks %s', $webDir), $event);

        $event->getIO()->write('<info>Done! Please open the Contao install tool and make sure the database is up-to-date.</info>');
    }

    public static function purgeCacheFolder(): void
    {
        $filesystem = new Filesystem();
        $filesystem->removeDirectory(getcwd().'/var/cache/prod');
    }

    /**
     * Adds the app directory if it does not exist.
     */
    public static function addAppDirectory(): void
    {
        $filesystem = new Filesystem();
        $filesystem->ensureDirectoryExists(getcwd().'/app');
    }

    /**
     * @throws \RuntimeException
     */
    private static function executeCommand(string $cmd, Event $event): void
    {
        $phpFinder = new PhpExecutableFinder();

        if (false === ($phpPath = $phpFinder->find())) {
            throw new \RuntimeException('The php executable could not be found.');
        }

        $process = new Process(
            sprintf(
                '%s %s%s %s%s --env=%s',
                escapeshellarg($phpPath),
                escapeshellarg(__DIR__.'/../../bin/contao-console'),
                $event->getIO()->isDecorated() ? ' --ansi' : '',
                $cmd,
                self::getVerbosityFlag($event),
                getenv('SYMFONY_ENV') ?: 'prod'
            )
        );

        // Increase the timeout according to terminal42/background-process (see #54)
        $process->setTimeout(500);

        $process->run(
            static function (string $type, string $buffer) use ($event): void {
                $event->getIO()->write($buffer, false);
            }
        );

        if (!$process->isSuccessful()) {
            throw new \RuntimeException(
                sprintf('An error occurred while executing the "%s" command: %s', $cmd, $process->getErrorOutput())
            );
        }
    }

    private static function getWebDir(Event $event): string
    {
        $extra = $event->getComposer()->getPackage()->getExtra();

        return $extra['symfony-web-dir'] ?? 'web';
    }

    private static function getVerbosityFlag(Event $event): string
    {
        $io = $event->getIO();

        switch (true) {
            case $io->isDebug():
                return ' -vvv';

            case $io->isVeryVerbose():
                return ' -vv';

            case $io->isVerbose():
                return ' -v';

            default:
                return '';
        }
    }
}


<?php

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

$GLOBALS['TL_DCA']['tl_style_sheet'] = array
(

	// Config
	'config' => array
	(
		'dataContainer'               => 'Table',
		'ptable'                      => 'tl_theme',
		'ctable'                      => array('tl_style'),
		'switchToEdit'                => true,
		'enableVersioning'            => true,
		'onload_callback' => array
		(
			array('tl_style_sheet', 'checkPermission'),
			array('tl_style_sheet', 'updateStyleSheet')
		),
		'oncopy_callback' => array
		(
			array('tl_style_sheet', 'scheduleUpdate')
		),
		'onsubmit_callback' => array
		(
			array('tl_style_sheet', 'scheduleUpdate')
		),
		'sql' => array
		(
			'keys' => array
			(
				'id' => 'primary',
				'name' => 'unique'
			)
		)
	),

	// List
	'list' => array
	(
		'sorting' => array
		(
			'mode'                    => 4,
			'fields'                  => array('name'),
			'panelLayout'             => 'filter;search,limit',
			'headerFields'            => array('name', 'author', 'tstamp'),
			'child_record_callback'   => array('tl_style_sheet', 'listStyleSheet')
		),
		'global_operations' => array
		(
			'import' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['import'],
				'href'                => 'key=import',
				'class'               => 'header_css_import',
				'attributes'          => 'onclick="Backend.getScrollOffset()"'
			),
			'all' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['MSC']['all'],
				'href'                => 'act=select',
				'class'               => 'header_edit_all',
				'attributes'          => 'onclick="Backend.getScrollOffset()" accesskey="e"'
			)
		),
		'operations' => array
		(
			'edit' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['edit'],
				'href'                => 'table=tl_style',
				'icon'                => 'edit.svg'
			),
			'editheader' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['editheader'],
				'href'                => 'table=tl_style_sheet&act=edit',
				'icon'                => 'header.svg',
				'button_callback'     => array('tl_style_sheet', 'editHeader')
			),
			'copy' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['copy'],
				'href'                => 'act=paste&mode=copy',
				'icon'                => 'copy.svg'
			),
			'cut' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['cut'],
				'href'                => 'act=paste&mode=cut',
				'icon'                => 'cut.svg',
				'attributes'          => 'onclick="Backend.getScrollOffset()"'
			),
			'delete' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['delete'],
				'href'                => 'act=delete',
				'icon'                => 'delete.svg',
				'attributes'          => 'onclick="if(!confirm(\'' . $GLOBALS['TL_LANG']['MSC']['deleteConfirm'] . '\'))return false;Backend.getScrollOffset()"'
			),
			'show' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['show'],
				'href'                => 'act=show',
				'icon'                => 'show.svg'
			),
			'export' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_style_sheet']['export'],
				'href'                => 'key=export',
				'icon'                => 'theme_export.svg'
			)
		)
	),

	// Palettes
	'palettes' => array
	(
		'default'                     => '{title_legend},name;{media_legend},media,mediaQuery;{vars_legend},vars;{expert_legend:hide},embedImages,cc'
	),

	// Fields
	'fields' => array
	(
		'id' => array
		(
			'sql'                     => "int(10) unsigned NOT NULL auto_increment"
		),
		'pid' => array
		(
			'foreignKey'              => 'tl_theme.name',
			'sql'                     => "int(10) unsigned NOT NULL default 0",
			'relation'                => array('type'=>'belongsTo', 'load'=>'lazy')
		),
		'tstamp' => array
		(
			'sql'                     => "int(10) unsigned NOT NULL default 0"
		),
		'name' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_style_sheet']['name'],
			'inputType'               => 'text',
			'exclude'                 => true,
			'search'                  => true,
			'flag'                    => 1,
			'eval'                    => array('mandatory'=>true, 'unique'=>true, 'rgxp'=>'alnum', 'maxlength'=>64, 'spaceToUnderscore'=>true, 'tl_class'=>'w50'),
			'save_callback' => array
			(
				array('tl_style_sheet', 'romanizeName')
			),
			'sql'                     => "varchar(64) NULL"
		),
		'embedImages' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_style_sheet']['embedImages'],
			'inputType'               => 'text',
			'exclude'                 => true,
			'eval'                    => array('rgxp'=>'natural', 'tl_class'=>'w50'),
			'sql'                     => "int(10) unsigned NOT NULL default 0"
		),
		'cc' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_style_sheet']['cc'],
			'inputType'               => 'text',
			'exclude'                 => true,
			'search'                  => true,
			'eval'                    => array('decodeEntities'=>true, 'tl_class'=>'w50'),
			'save_callback' => array
			(
				array('tl_style_sheet', 'sanitizeCc')
			),
			'sql'                     => "varchar(32) NOT NULL default ''"
		),
		'media' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_style_sheet']['media'],
			'inputType'               => 'checkbox',
			'exclude'                 => true,
			'filter'                  => true,
			'options'                 => array('all', 'aural', 'braille', 'embossed', 'handheld', 'print', 'projection', 'screen', 'tty', 'tv'),
			'eval'                    => array('multiple'=>true, 'mandatory'=>true, 'tl_class'=>'clr'),
			'sql'                     => "varchar(255) NOT NULL default 'a:1:{i:0;s:3:\"all\";}'"
		),
		'mediaQuery' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_style_sheet']['mediaQuery'],
			'inputType'               => 'textarea',
			'exclude'                 => true,
			'search'                  => true,
			'eval'                    => array('decodeEntities'=>true, 'style'=>'height:60px'),
			'sql'                     => "text NULL"
		),
		'vars' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_style_sheet']['vars'],
			'inputType'               => 'keyValueWizard',
			'exclude'                 => true,
			'sql'                     => "text NULL"
		)
	)
);

/**
 * Provide miscellaneous methods that are used by the data configuration array.
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class tl_style_sheet extends Contao\Backend
{

	/**
	 * Import the back end user object
	 */
	public function __construct()
	{
		parent::__construct();
		$this->import('Contao\BackendUser', 'User');
	}

	/**
	 * Check permissions to edit the table
	 *
	 * @throws Contao\CoreBundle\Exception\AccessDeniedException
	 */
	public function checkPermission()
	{
		if ($this->User->isAdmin)
		{
			return;
		}

		if (!$this->User->hasAccess('css', 'themes'))
		{
			throw new Contao\CoreBundle\Exception\AccessDeniedException('Not enough permissions to access the style sheets module.');
		}
	}

	/**
	 * Check for modified style sheets and update them if necessary
	 */
	public function updateStyleSheet()
	{
		/** @var Symfony\Component\HttpFoundation\Session\SessionInterface $objSession */
		$objSession = Contao\System::getContainer()->get('session');

		$session = $objSession->get('style_sheet_updater');

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

		$this->import('Contao\StyleSheets', 'StyleSheets');

		foreach ($session as $id)
		{
			$this->StyleSheets->updateStyleSheet($id);
		}

		$objSession->set('style_sheet_updater', null);
	}

	/**
	 * Schedule a style sheet update
	 *
	 * This method is triggered when a single style sheet or multiple style
	 * sheets are modified (edit/editAll) or duplicated (copy/copyAll).
	 *
	 * @param mixed $id
	 */
	public function scheduleUpdate($id)
	{
		// The onsubmit_callback passes a DataContainer object
		if (\is_object($id))
		{
			$id = $id->id;
		}

		// Return if there is no ID
		if (!$id || Contao\Input::get('act') == 'copy')
		{
			return;
		}

		/** @var Symfony\Component\HttpFoundation\Session\SessionInterface $objSession */
		$objSession = Contao\System::getContainer()->get('session');

		// Store the ID in the session
		$session = $objSession->get('style_sheet_updater');
		$session[] = $id;
		$objSession->set('style_sheet_updater', array_unique($session));
	}

	/**
	 * List a style sheet
	 *
	 * @param array $row
	 *
	 * @return string
	 */
	public function listStyleSheet($row)
	{
		$cc = '';
		$media = Contao\StringUtil::deserialize($row['media']);

		if ($row['cc'] != '')
		{
			$cc = ' <!--['. $row['cc'] .']>';
		}

		if ($row['mediaQuery'] != '')
		{
			return '
'. $row['name'] .' @media '. $row['mediaQuery'] . $cc .'' . "
\n";
		}
		elseif (!empty($media) && \is_array($media))
		{
			return '
'. $row['name'] .' @media '. implode(', ', $media) . $cc .'' . "
\n";
		}
		else
		{
			return '
'. $row['name'] . $cc ."
\n";
		}
	}

	/**
	 * Romanize the file name (see #7526)
	 *
	 * @param mixed $varValue
	 *
	 * @return mixed
	 */
	public function romanizeName($varValue)
	{
		return Patchwork\Utf8::toAscii($varValue);
	}

	/**
	 * Sanitize the conditional comments field
	 *
	 * @param mixed $varValue
	 *
	 * @return mixed
	 */
	public function sanitizeCc($varValue)
	{
		if ($varValue != '')
		{
			$varValue = str_replace(array('


<?php

declare(strict_types=1);

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

namespace Contao\ManagerBundle\ContaoManager\ApiCommand;

use Contao\ManagerBundle\Api\Application;
use Contao\ManagerBundle\Api\ManagerConfig;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class SetConfigCommand extends Command
{
    /**
     * @var ManagerConfig
     */
    private $managerConfig;

    public function __construct(Application $application)
    {
        parent::__construct();

        $this->managerConfig = $application->getManagerConfig();
    }

    /**
     * {@inheritdoc}
     */
    protected function configure(): void
    {
        parent::configure();

        $this
            ->setName('config:set')
            ->setDescription('Sets the Contao Manager configuration from a JSON string.')
            ->addArgument('json', InputArgument::REQUIRED, 'The configuration as JSON string')
        ;
    }

    /**
     * {@inheritdoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output): void
    {
        $data = @json_decode($input->getArgument('json'), true);

        if (null === $data) {
            throw new \RuntimeException('Invalid JSON: '.json_last_error_msg());
        }

        $this->managerConfig->write($data);
    }
}


<?php

declare(strict_types=1);

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

namespace Contao\CoreBundle\Command;

use Doctrine\Bundle\MigrationsBundle\Command\DoctrineCommand;
use Doctrine\Bundle\MigrationsBundle\Command\Helper\DoctrineCommandHelper;
use Doctrine\DBAL\Migrations\Tools\Console\Command\DiffCommand;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * This Command replaces Doctrine\Bundle\MigrationsBundle\Command\MigrationsDiffDoctrineCommand
 * if ORM is not installed. The regular doctrine:migrations:diff command only works with ORM.
 */
class DoctrineMigrationsDiffCommand extends DiffCommand
{
    public const COMMAND_ID = 'console.command.contao_corebundle_command_doctrinemigrationsdiffcommand';

    /**
     * {@inheritdoc}
     */
    public function execute(InputInterface $input, OutputInterface $output): ?int
    {
        /** @var Application $application */
        $application = $this->getApplication();

        DoctrineCommandHelper::setApplicationConnection($application, $input->getOption('db-configuration'));

        DoctrineCommand::configureMigrations(
            $application->getKernel()->getContainer(),
            $this->getMigrationConfiguration($input, $output)
        );

        parent::execute($input, $output);

        return 0;
    }

    /**
     * {@inheritdoc}
     */
    protected function configure(): void
    {
        parent::configure();

        $this->setName('doctrine:migrations:diff');
    }
}


<?php

declare(strict_types=1);

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

namespace Contao\ManagerBundle\EventListener;

use Symfony\Component\Filesystem\Filesystem;

class InitializeApplicationListener
{
    /**
     * @var string
     */
    private $projectDir;

    public function __construct(string $projectDir)
    {
        $this->projectDir = $projectDir;
    }

    /**
     * Adds the initialize.php file.
     */
    public function onInitializeApplication(): void
    {
        if (file_exists($this->projectDir.'/system/initialize.php')) {
            return;
        }

        (new Filesystem())
            ->copy(
                __DIR__.'/../Resources/skeleton/system/initialize.php',
                $this->projectDir.'/system/initialize.php',
                true
            )
        ;
    }
}


<?php

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

namespace Contao;

use Patchwork\Utf8;

/**
 * Front end module "article list".
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class ModuleArticleList extends Module
{

	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'mod_articlelist';

	/**
	 * Do not display the module if there are no articles
	 *
	 * @return string
	 */
	public function generate()
	{
		if (TL_MODE == 'BE')
		{
			$objTemplate = new BackendTemplate('be_wildcard');
			$objTemplate->wildcard = '### ' . Utf8::strtoupper($GLOBALS['TL_LANG']['FMD']['articlelist'][0]) . ' ###';
			$objTemplate->title = $this->headline;
			$objTemplate->id = $this->id;
			$objTemplate->link = $this->name;
			$objTemplate->href = 'contao/main.php?do=themes&table=tl_module&act=edit&id=' . $this->id;

			return $objTemplate->parse();
		}

		$strBuffer = parent::generate();

		return !empty($this->Template->articles) ? $strBuffer : '';
	}

	/**
	 * Generate the module
	 */
	protected function compile()
	{
		/** @var PageModel $objPage */
		global $objPage;

		if (!\strlen($this->inColumn))
		{
			$this->inColumn = 'main';
		}

		$id = $objPage->id;
		$objTarget = null;

		$this->Template->request = Environment::get('request');

		// Show the articles of a different page
		if ($this->defineRoot && ($objTarget = $this->objModel->getRelated('rootPage')) instanceof PageModel)
		{
			$id = $objTarget->id;

			/** @var PageModel $objTarget */
			$this->Template->request = $objTarget->getFrontendUrl();
		}

		// Get published articles
		$objArticles = ArticleModel::findPublishedByPidAndColumn($id, $this->inColumn);

		if ($objArticles === null)
		{
			$this->Template->articles = array();

			return;
		}

		$intCount = 0;
		$articles = array();
		$objHelper = $objTarget ?: $objPage; // PHP 5.6 compatibility (see #939)

		while ($objArticles->next())
		{
			// Skip first article
			if (++$intCount <= (int) $this->skipFirst)
			{
				continue;
			}

			$cssID = StringUtil::deserialize($objArticles->cssID, true);

			$articles[] = array
			(
				'link' => $objArticles->title,
				'title' => StringUtil::specialchars($objArticles->title),
				'id' => $cssID[0] ?: 'article-' . $objArticles->id,
				'articleId' => $objArticles->id,
				'href' => $objHelper->getFrontendUrl('/articles/' . ($objArticles->alias ?: $objArticles->id))
			);
		}

		$this->Template->articles = $articles;
	}
}

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


<?php

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

namespace Contao;

/**
 * Front end content element "accordion".
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class ContentAccordion extends ContentElement
{

	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'ce_accordionSingle';

	/**
	 * Generate the content element
	 */
	protected function compile()
	{
		// Clean the RTE output
		$this->text = StringUtil::toHtml5($this->text);

		$this->Template->text = StringUtil::encodeEmail($this->text);
		$this->Template->addImage = false;

		// Add an image
		if ($this->addImage && $this->singleSRC != '')
		{
			$objModel = FilesModel::findByUuid($this->singleSRC);

			if ($objModel !== null && is_file(System::getContainer()->getParameter('kernel.project_dir') . '/' . $objModel->path))
			{
				$this->singleSRC = $objModel->path;
				$this->addImageToTemplate($this->Template, $this->arrData, null, null, $objModel);
			}
		}

		$classes = StringUtil::deserialize($this->mooClasses);

		$this->Template->toggler = $classes[0] ?: 'toggler';
		$this->Template->accordion = $classes[1] ?: 'accordion';
		$this->Template->headlineStyle = $this->mooStyle;
		$this->Template->headline = $this->mooHeadline;
	}
}

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