<?php

declare(strict_types=1);

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

namespace Contao\CoreBundle\DataContainer;

use Contao\StringUtil;

class PaletteManipulator
{
    public const POSITION_BEFORE = 'before';
    public const POSITION_AFTER = 'after';
    public const POSITION_PREPEND = 'prepend';
    public const POSITION_APPEND = 'append';

    /**
     * @var array
     */
    private $legends = [];

    /**
     * @var array
     */
    private $fields = [];

    /**
     * @var array
     */
    private $removes = [];

    public static function create(): self
    {
        return new static();
    }

    /**
     * If the legend already exists, nothing will be changed.
     *
     * @param string|array $parent
     * @param bool         $hide
     */
    public function addLegend(string $name, $parent, string $position = self::POSITION_AFTER, $hide = false): self
    {
        $this->validatePosition($position);

        $this->legends[] = [
            'name' => $name,
            'parents' => (array) $parent,
            'position' => $position,
            'hide' => (bool) $hide,
        ];

        return $this;
    }

    /**
     * If $position is PREPEND or APPEND, pass a legend as parent; otherwise pass a field name.
     *
     * @param string|array               $name
     * @param string|array               $parent
     * @param string|array|\Closure|null $fallback
     * @param string                     $fallbackPosition
     *
     * @throws PalettePositionException
     */
    public function addField($name, $parent, string $position = self::POSITION_AFTER, $fallback = null, $fallbackPosition = self::POSITION_APPEND): self
    {
        $this->validatePosition($position);

        if (self::POSITION_BEFORE === $fallbackPosition || self::POSITION_AFTER === $fallbackPosition) {
            throw new PalettePositionException('Fallback legend position can only be PREPEND or APPEND');
        }

        $this->fields[] = [
            'fields' => (array) $name,
            'parents' => (array) $parent,
            'position' => $position,
            'fallback' => is_scalar($fallback) ? [$fallback] : $fallback,
            'fallbackPosition' => $fallbackPosition,
        ];

        return $this;
    }

    /**
     * If no legend is given, the field is removed everywhere.
     *
     * @param string|array $name
     */
    public function removeField($name, string $legend = null): self
    {
        $this->removes[] = [
            'fields' => (array) $name,
            'parents' => (array) $legend,
        ];

        return $this;
    }

    /**
     * @param string $name
     */
    public function applyToPalette($name, string $table): self
    {
        $palettes = &$GLOBALS['TL_DCA'][$table]['palettes'];

        if (!isset($palettes[$name])) {
            throw new PaletteNotFoundException(sprintf('Palette "%s" not found in table "%s"', $name, $table));
        }

        $palettes[$name] = $this->applyToString($palettes[$name]);

        return $this;
    }

    public function applyToSubpalette(string $name, string $table): self
    {
        $subpalettes = &$GLOBALS['TL_DCA'][$table]['subpalettes'];

        if (!isset($subpalettes[$name])) {
            throw new PaletteNotFoundException(sprintf('Subpalette "%s" not found in table "%s"', $name, $table));
        }

        $subpalettes[$name] = $this->applyToString($subpalettes[$name], true);

        return $this;
    }

    public function applyToString(string $palette, bool $skipLegends = false): string
    {
        $config = $this->explode($palette);

        if (!$skipLegends) {
            foreach ($this->legends as $legend) {
                $this->applyLegend($config, $legend);
            }
        }

        // Make sure there is at least one legend
        if (0 === \count($config)) {
            $config = [['fields' => [], 'hide' => false]];
        }

        foreach ($this->fields as $field) {
            $this->applyField($config, $field, $skipLegends);
        }

        foreach ($this->removes as $remove) {
            $this->applyRemove($config, $remove);
        }

        return $this->implode($config);
    }

    /**
     * @throws PalettePositionException
     */
    private function validatePosition(string $position): void
    {
        static $positions = [
            self::POSITION_BEFORE,
            self::POSITION_AFTER,
            self::POSITION_PREPEND,
            self::POSITION_APPEND,
        ];

        if (!\in_array($position, $positions, true)) {
            throw new PalettePositionException('Invalid legend position');
        }
    }

    /**
     * Converts a palette string to a configuration array.
     *
     * @return array<string,array<string,string[]|bool>>
     */
    private function explode(string $palette): array
    {
        if ('' === $palette) {
            return [];
        }

        $legendCount = 0;
        $legendMap = [];
        $groups = StringUtil::trimsplit(';', $palette);

        foreach ($groups as $group) {
            if ('' === $group) {
                continue;
            }

            $hide = false;
            $fields = StringUtil::trimsplit(',', $group);

            if (preg_match('#\{(.+?)(:hide)?\}#', $fields[0], $matches)) {
                $legend = $matches[1];
                $hide = \count($matches) > 2 && ':hide' === $matches[2];
                array_shift($fields);
            } else {
                $legend = $legendCount++;
            }

            $legendMap[$legend] = compact('fields', 'hide');
        }

        return $legendMap;
    }

    /**
     * Converts a configuration array to a palette string.
     */
    private function implode(array $config): string
    {
        $palette = '';

        foreach ($config as $legend => $group) {
            if (\count($group['fields']) < 1) {
                continue;
            }

            if ('' !== $palette) {
                $palette .= ';';
            }

            if (!\is_int($legend)) {
                $palette .= sprintf('{%s%s},', $legend, ($group['hide'] ? ':hide' : ''));
            }

            $palette .= implode(',', $group['fields']);
        }

        return $palette;
    }

    private function applyLegend(array &$config, array $action): void
    {
        // Legend already exists, do nothing
        if (\array_key_exists($action['name'], $config)) {
            return;
        }

        $template = [$action['name'] => ['fields' => [], 'hide' => $action['hide']]];

        if (self::POSITION_PREPEND === $action['position']) {
            $config = $template + $config;

            return;
        }

        if (self::POSITION_APPEND === $action['position']) {
            $config += $template;

            return;
        }

        foreach ($action['parents'] as $parent) {
            if (\array_key_exists($parent, $config)) {
                $offset = array_search($parent, array_keys($config), true);
                $offset += (int) (self::POSITION_AFTER === $action['position']);

                // Necessary because array_splice() would remove the keys from the replacement array
                $before = array_splice($config, 0, $offset);
                $config = $before + $template + $config;

                return;
            }
        }

        // If everything fails, append the new legend at the end
        $config += $template;
    }

    private function applyField(array &$config, array $action, bool $skipLegends = false): void
    {
        if (self::POSITION_PREPEND === $action['position'] || self::POSITION_APPEND === $action['position']) {
            $this->applyFieldToLegend($config, $action, $skipLegends);
        } else {
            $this->applyFieldToField($config, $action, $skipLegends);
        }
    }

    private function applyFieldToLegend(array &$config, array $action, bool $skipLegends = false): void
    {
        // If $skipLegends is true, we usually only have one legend without name, so we simply append to that
        if ($skipLegends) {
            if (self::POSITION_PREPEND === $action['position']) {
                reset($config);
            } else {
                end($config);
            }

            $action['parents'] = [key($config)];
        }

        if ($this->canApplyToParent($config, $action, 'parents', 'position')) {
            return;
        }

        $this->applyFallback($config, $action, $skipLegends);
    }

    private function applyFieldToField(array &$config, array $action, bool $skipLegends = false): void
    {
        $offset = (int) (self::POSITION_AFTER === $action['position']);

        foreach ($action['parents'] as $parent) {
            $legend = $this->findLegendForField($config, $parent);

            if (false !== $legend) {
                $legend = (string) $legend;
                $offset += array_search($parent, $config[$legend]['fields'], true);
                array_splice($config[$legend]['fields'], $offset, 0, $action['fields']);

                return;
            }
        }

        $this->applyFallback($config, $action, $skipLegends);
    }

    /**
     * Adds a new legend if possible or appends to the last one.
     */
    private function applyFallback(array &$config, array $action, bool $skipLegends = false): void
    {
        if (\is_callable($action['fallback'])) {
            $action['fallback']($config, $action, $skipLegends);
        } else {
            $this->applyFallbackPalette($config, $action);
        }
    }

    private function applyFallbackPalette(array &$config, array $action): void
    {
        end($config);
        $fallback = key($config);

        if (null !== $action['fallback']) {
            if ($this->canApplyToParent($config, $action, 'fallback', 'fallbackPosition')) {
                return;
            }

            // If the fallback palette was not found, create a new one
            $fallback = reset($action['fallback']);

            $this->applyLegend(
                $config,
                [
                    'name' => $fallback,
                    'position' => self::POSITION_APPEND,
                    'hide' => false,
                ]
            );
        }

        // If everything fails, add to the last legend
        $offset = self::POSITION_PREPEND === $action['fallbackPosition'] ? 0 : \count($config[$fallback]['fields']);
        array_splice($config[$fallback]['fields'], $offset, 0, $action['fields']);
    }

    private function applyRemove(array &$config, array $remove): void
    {
        foreach ($config as $legend => $group) {
            if (empty($remove['parents']) || \in_array($legend, $remove['parents'], true)) {
                $config[$legend]['fields'] = array_diff($group['fields'], $remove['fields']);
            }
        }
    }

    /**
     * Having the same field in multiple legends is not supported by Contao, so we don't handle that case.
     *
     * @return string|false
     */
    private function findLegendForField(array &$config, string $field)
    {
        foreach ($config as $legend => $group) {
            if (\in_array($field, $group['fields'], true)) {
                return $legend;
            }
        }

        return false;
    }

    private function canApplyToParent(array &$config, array $action, string $key, string $position): bool
    {
        foreach ($action[$key] as $parent) {
            if (\array_key_exists($parent, $config)) {
                $offset = self::POSITION_PREPEND === $action[$position] ? 0 : \count($config[$parent]['fields']);
                array_splice($config[$parent]['fields'], $offset, 0, $action['fields']);

                return true;
            }
        }

        return false;
    }
}


<?php

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

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

	// Config
	'config' => array
	(
		'dataContainer'               => 'Table',
		'enableVersioning'            => true,
		'markAsCopy'                  => 'title',
		'onload_callback' => array
		(
			array('tl_calendar_feed', 'checkPermission'),
			array('tl_calendar_feed', 'generateFeed')
		),
		'oncreate_callback' => array
		(
			array('tl_calendar_feed', 'adjustPermissions')
		),
		'oncopy_callback' => array
		(
			array('tl_calendar_feed', 'adjustPermissions')
		),
		'onsubmit_callback' => array
		(
			array('tl_calendar_feed', 'scheduleUpdate')
		),
		'sql' => array
		(
			'keys' => array
			(
				'id' => 'primary',
				'alias' => 'index'
			)
		),
		'backlink'                    => 'do=calendar'
	),

	// List
	'list' => array
	(
		'sorting' => array
		(
			'mode'                    => 1,
			'fields'                  => array('title'),
			'flag'                    => 1,
			'panelLayout'             => 'filter;search,limit'
		),
		'label' => array
		(
			'fields'                  => array('title'),
			'format'                  => '%s'
		),
		'global_operations' => array
		(
			'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_calendar_feed']['edit'],
				'href'                => 'act=edit',
				'icon'                => 'edit.svg'
			),
			'copy' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_calendar_feed']['copy'],
				'href'                => 'act=copy',
				'icon'                => 'copy.svg',
				'button_callback'     => array('tl_calendar_feed', 'copyFeed')
			),
			'delete' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_calendar_feed']['delete'],
				'href'                => 'act=delete',
				'icon'                => 'delete.svg',
				'attributes'          => 'onclick="if(!confirm(\'' . $GLOBALS['TL_LANG']['MSC']['deleteConfirm'] . '\'))return false;Backend.getScrollOffset()"',
				'button_callback'     => array('tl_calendar_feed', 'deleteFeed')
			),
			'show' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_calendar_feed']['show'],
				'href'                => 'act=show',
				'icon'                => 'show.svg'
			)
		)
	),

	// Palettes
	'palettes' => array
	(
		'default'                     => '{title_legend},title,alias,language;{calendars_legend},calendars;{config_legend},format,source,maxItems,feedBase,description'
	),

	// Fields
	'fields' => array
	(
		'id' => array
		(
			'sql'                     => "int(10) unsigned NOT NULL auto_increment"
		),
		'tstamp' => array
		(
			'sql'                     => "int(10) unsigned NOT NULL default 0"
		),
		'title' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['title'],
			'exclude'                 => true,
			'search'                  => true,
			'inputType'               => 'text',
			'eval'                    => array('mandatory'=>true, 'maxlength'=>255, 'tl_class'=>'w50'),
			'sql'                     => "varchar(255) NOT NULL default ''"
		),
		'alias' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['alias'],
			'exclude'                 => true,
			'search'                  => true,
			'inputType'               => 'text',
			'eval'                    => array('mandatory'=>true, 'rgxp'=>'alias', 'unique'=>true, 'maxlength'=>128, 'tl_class'=>'w50 clr'),
			'save_callback' => array
			(
				array('tl_calendar_feed', 'checkFeedAlias')
			),
			'sql'                     => "varchar(128) BINARY NOT NULL default ''"
		),
		'language' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['language'],
			'exclude'                 => true,
			'search'                  => true,
			'filter'                  => true,
			'inputType'               => 'text',
			'eval'                    => array('mandatory'=>true, 'maxlength'=>32, 'tl_class'=>'w50'),
			'sql'                     => "varchar(32) NOT NULL default ''"
		),
		'calendars' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['calendars'],
			'exclude'                 => true,
			'search'                  => true,
			'inputType'               => 'checkbox',
			'options_callback'        => array('tl_calendar_feed', 'getAllowedCalendars'),
			'eval'                    => array('multiple'=>true, 'mandatory'=>true),
			'sql'                     => "blob NULL"
		),
		'format' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['format'],
			'exclude'                 => true,
			'filter'                  => true,
			'inputType'               => 'select',
			'options'                 => array('rss'=>'RSS 2.0', 'atom'=>'Atom'),
			'eval'                    => array('tl_class'=>'w50'),
			'sql'                     => "varchar(32) NOT NULL default 'rss'"
		),
		'source' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['source'],
			'exclude'                 => true,
			'inputType'               => 'select',
			'options'                 => array('source_teaser', 'source_text'),
			'reference'               => &$GLOBALS['TL_LANG']['tl_calendar_feed'],
			'eval'                    => array('tl_class'=>'w50'),
			'sql'                     => "varchar(32) NOT NULL default 'source_teaser'"
		),
		'maxItems' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['maxItems'],
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('mandatory'=>true, 'rgxp'=>'natural', 'tl_class'=>'w50'),
			'sql'                     => "smallint(5) unsigned NOT NULL default 25"
		),
		'feedBase' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['feedBase'],
			'default'                 => Contao\Environment::get('base'),
			'exclude'                 => true,
			'search'                  => true,
			'inputType'               => 'text',
			'eval'                    => array('trailingSlash'=>true, 'rgxp'=>'url', 'decodeEntities'=>true, 'maxlength'=>255, 'tl_class'=>'w50'),
			'sql'                     => "varchar(255) NOT NULL default ''"
		),
		'description' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_calendar_feed']['description'],
			'exclude'                 => true,
			'search'                  => true,
			'inputType'               => 'textarea',
			'eval'                    => array('style'=>'height:60px', 'tl_class'=>'clr'),
			'sql'                     => "text NULL"
		)
	)
);

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

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

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

		// Set the root IDs
		if (empty($this->User->calendarfeeds) || !\is_array($this->User->calendarfeeds))
		{
			$root = array(0);
		}
		else
		{
			$root = $this->User->calendarfeeds;
		}

		$GLOBALS['TL_DCA']['tl_calendar_feed']['list']['sorting']['root'] = $root;

		// Check permissions to add feeds
		if (!$this->User->hasAccess('create', 'calendarfeedp'))
		{
			$GLOBALS['TL_DCA']['tl_calendar_feed']['config']['closed'] = true;
			$GLOBALS['TL_DCA']['tl_calendar_feed']['config']['notCreatable'] = true;
			$GLOBALS['TL_DCA']['tl_calendar_feed']['config']['notCopyable'] = true;
		}

		// Check permissions to delete feeds
		if (!$this->User->hasAccess('delete', 'calendarfeedp'))
		{
			$GLOBALS['TL_DCA']['tl_calendar_feed']['config']['notDeletable'] = true;
		}

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

		// Check current action
		switch (Contao\Input::get('act'))
		{
			case 'select':
				// Allow
				break;

			case 'create':
				if (!$this->User->hasAccess('create', 'calendarfeedp'))
				{
					throw new Contao\CoreBundle\Exception\AccessDeniedException('Not enough permissions to create calendar feeds.');
				}
				break;

			case 'edit':
			case 'copy':
			case 'delete':
			case 'show':
				if (!\in_array(Contao\Input::get('id'), $root) || (Contao\Input::get('act') == 'delete' && !$this->User->hasAccess('delete', 'calendarfeedp')))
				{
					throw new Contao\CoreBundle\Exception\AccessDeniedException('Not enough permissions to ' . Contao\Input::get('act') . ' calendar feed ID ' . Contao\Input::get('id') . '.');
				}
				break;

			case 'editAll':
			case 'deleteAll':
			case 'overrideAll':
			case 'copyAll':
				$session = $objSession->all();
				if (Contao\Input::get('act') == 'deleteAll' && !$this->User->hasAccess('delete', 'calendarfeedp'))
				{
					$session['CURRENT']['IDS'] = array();
				}
				else
				{
					$session['CURRENT']['IDS'] = array_intersect((array) $session['CURRENT']['IDS'], $root);
				}
				$objSession->replace($session);
				break;

			default:
				if (\strlen(Contao\Input::get('act')))
				{
					throw new Contao\CoreBundle\Exception\AccessDeniedException('Not enough permissions to ' . Contao\Input::get('act') . ' calendar feeds.');
				}
				break;
		}
	}

	/**
	 * Add the new calendar feed to the permissions
	 *
	 * @param $insertId
	 */
	public function adjustPermissions($insertId)
	{
		// The oncreate_callback passes $insertId as second argument
		if (\func_num_args() == 4)
		{
			$insertId = func_get_arg(1);
		}

		if ($this->User->isAdmin)
		{
			return;
		}

		// Set root IDs
		if (empty($this->User->calendarfeeds) || !\is_array($this->User->calendarfeeds))
		{
			$root = array(0);
		}
		else
		{
			$root = $this->User->calendarfeeds;
		}

		// The calendar feed is enabled already
		if (\in_array($insertId, $root))
		{
			return;
		}

		/** @var Symfony\Component\HttpFoundation\Session\Attribute\AttributeBagInterface $objSessionBag */
		$objSessionBag = Contao\System::getContainer()->get('session')->getBag('contao_backend');

		$arrNew = $objSessionBag->get('new_records');

		if (\is_array($arrNew['tl_calendar_feed']) && \in_array($insertId, $arrNew['tl_calendar_feed']))
		{
			// Add the permissions on group level
			if ($this->User->inherit != 'custom')
			{
				$objGroup = $this->Database->execute("SELECT id, calendarfeeds, calendarfeedp FROM tl_user_group WHERE id IN(" . implode(',', array_map('\intval', $this->User->groups)) . ")");

				while ($objGroup->next())
				{
					$arrCalendarfeedp = Contao\StringUtil::deserialize($objGroup->calendarfeedp);

					if (\is_array($arrCalendarfeedp) && \in_array('create', $arrCalendarfeedp))
					{
						$arrCalendarfeeds = Contao\StringUtil::deserialize($objGroup->calendarfeeds, true);
						$arrCalendarfeeds[] = $insertId;

						$this->Database->prepare("UPDATE tl_user_group SET calendarfeeds=? WHERE id=?")
									   ->execute(serialize($arrCalendarfeeds), $objGroup->id);
					}
				}
			}

			// Add the permissions on user level
			if ($this->User->inherit != 'group')
			{
				$objUser = $this->Database->prepare("SELECT calendarfeeds, calendarfeedp FROM tl_user WHERE id=?")
										   ->limit(1)
										   ->execute($this->User->id);

				$arrCalendarfeedp = Contao\StringUtil::deserialize($objUser->calendarfeedp);

				if (\is_array($arrCalendarfeedp) && \in_array('create', $arrCalendarfeedp))
				{
					$arrCalendarfeeds = Contao\StringUtil::deserialize($objUser->calendarfeeds, true);
					$arrCalendarfeeds[] = $insertId;

					$this->Database->prepare("UPDATE tl_user SET calendarfeeds=? WHERE id=?")
								   ->execute(serialize($arrCalendarfeeds), $this->User->id);
				}
			}

			// Add the new element to the user object
			$root[] = $insertId;
			$this->User->calendarfeeds = $root;
		}
	}

	/**
	 * Return the copy calendar feed button
	 *
	 * @param array  $row
	 * @param string $href
	 * @param string $label
	 * @param string $title
	 * @param string $icon
	 * @param string $attributes
	 *
	 * @return string
	 */
	public function copyFeed($row, $href, $label, $title, $icon, $attributes)
	{
		return $this->User->hasAccess('create', 'calendarfeedp') ? 'addToUrl($href.'&id='.$row['id']).'" title="'.Contao\StringUtil::specialchars($title).'"'.$attributes.'>'.Contao\Image::getHtml($icon, $label).' ' : Contao\Image::getHtml(preg_replace('/\.svg/i', '_.svg', $icon)).' ';
	}

	/**
	 * Return the delete calendar feed button
	 *
	 * @param array  $row
	 * @param string $href
	 * @param string $label
	 * @param string $title
	 * @param string $icon
	 * @param string $attributes
	 *
	 * @return string
	 */
	public function deleteFeed($row, $href, $label, $title, $icon, $attributes)
	{
		return $this->User->hasAccess('delete', 'calendarfeedp') ? 'addToUrl($href.'&id='.$row['id']).'" title="'.Contao\StringUtil::specialchars($title).'"'.$attributes.'>'.Contao\Image::getHtml($icon, $label).' ' : Contao\Image::getHtml(preg_replace('/\.svg/i', '_.svg', $icon)).' ';
	}

	/**
	 * Check for modified calendar feeds and update the XML files if necessary
	 */
	public function generateFeed()
	{
		/** @var Symfony\Component\HttpFoundation\Session\SessionInterface $objSession */
		$objSession = Contao\System::getContainer()->get('session');

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

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

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

		foreach ($session as $id)
		{
			$this->Calendar->generateFeedsByCalendar($id);
		}

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

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

	/**
	 * Schedule a calendar feed update
	 *
	 * This method is triggered when a single calendar or multiple calendars
	 * are modified (edit/editAll).
	 *
	 * @param Contao\DataContainer $dc
	 */
	public function scheduleUpdate(Contao\DataContainer $dc)
	{
		// Return if there is no ID
		if (!$dc->id)
		{
			return;
		}

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

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

	/**
	 * Return the IDs of the allowed calendars as array
	 *
	 * @return array
	 */
	public function getAllowedCalendars()
	{
		if ($this->User->isAdmin)
		{
			$objCalendar = Contao\CalendarModel::findAll();
		}
		else
		{
			$objCalendar = Contao\CalendarModel::findMultipleByIds($this->User->calendars);
		}

		$return = array();

		if ($objCalendar !== null)
		{
			while ($objCalendar->next())
			{
				$return[$objCalendar->id] = $objCalendar->title;
			}
		}

		return $return;
	}

	/**
	 * Check the RSS-feed alias
	 *
	 * @param mixed                $varValue
	 * @param Contao\DataContainer $dc
	 *
	 * @return mixed
	 *
	 * @throws Exception
	 */
	public function checkFeedAlias($varValue, Contao\DataContainer $dc)
	{
		// No change or empty value
		if ($varValue == $dc->value || $varValue == '')
		{
			return $varValue;
		}

		$varValue = Contao\StringUtil::standardize($varValue); // see #5096

		$this->import('Contao\Automator', 'Automator');
		$arrFeeds = $this->Automator->purgeXmlFiles(true);

		// Alias exists
		if (\in_array($varValue, $arrFeeds))
		{
			throw new Exception(sprintf($GLOBALS['TL_LANG']['ERR']['aliasExists'], $varValue));
		}

		return $varValue;
	}
}


<?php

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

namespace Contao;

/**
 * Creates .zip files and stores them on the disk
 *
 * Usage:
 *
 *     $zip = new ZipWriter('test.zip');
 *     $zip->addFile('test.txt');
 *     $zip->close();
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class ZipWriter
{

	/**
	 * File signatur
	 * @var string
	 */
	const FILE_SIGNATURE = "\x50\x4b\x03\x04";

	/**
	 * Central directory begin marker
	 * @var string
	 */
	const CENTRAL_DIR_START = "\x50\x4b\x01\x02";

	/**
	 * Central directory end marker
	 * @var string
	 */
	const CENTRAL_DIR_END   = "\x50\x4b\x05\x06";

	/**
	 * Temp folder path
	 * @var string
	 */
	const TEMPORARY_FOLDER  = 'system/tmp';

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

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

	/**
	 * Temporary name
	 * @var resource
	 */
	protected $strTemp;

	/**
	 * Central directory
	 * @var string
	 */
	protected $strCentralDir;

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

	/**
	 * File count
	 * @var integer
	 */
	protected $intCount = 0;

	/**
	 * Create a new zip archive
	 *
	 * @param string $strFile The file path
	 *
	 * @throws \Exception If the temporary file cannot be created or opened
	 */
	public function __construct($strFile)
	{
		$this->strFile = $strFile;
		$this->strRootDir = System::getContainer()->getParameter('kernel.project_dir');

		// Create temporary file
		if (!$this->strTemp = tempnam($this->strRootDir . '/' . self::TEMPORARY_FOLDER, 'zip'))
		{
			throw new \Exception("Cannot create temporary file");
		}

		// Open temporary file
		if (!$this->resFile = @fopen($this->strTemp, 'wb'))
		{
			throw new \Exception("Cannot open temporary file");
		}
	}

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

		if (file_exists($this->strTemp))
		{
			unlink($this->strTemp);
		}
	}

	/**
	 * Add a file to the archive
	 *
	 * @param string $strFile The file path
	 * @param string $strName An optional file name
	 *
	 * @throws \Exception If $strFile does not exist
	 */
	public function addFile($strFile, $strName=null)
	{
		if (!file_exists($this->strRootDir . '/' . $strFile))
		{
			throw new \Exception("File $strFile does not exist");
		}

		// Remove leading slashes (see #4502)
		if (strncmp($strName, '/', 1) === 0)
		{
			$strName = substr($strName, 1);
		}

		$this->addString(file_get_contents($this->strRootDir . '/' . $strFile), $strName ?: $strFile, filemtime($this->strRootDir . '/' . $strFile));
	}

	/**
	 * Add a file from a string to the archive
	 *
	 * @param string  $strData The data to be added
	 * @param string  $strName The file path
	 * @param integer $intTime An optional modification timestamp
	 */
	public function addString($strData, $strName, $intTime=0)
	{
		++$this->intCount;
		$strName = strtr($strName, '\\', '/');

		// Start file
		$arrFile['file_signature']            = self::FILE_SIGNATURE;
		$arrFile['version_needed_to_extract'] = "\x14\x00";
		$arrFile['general_purpose_bit_flag']  = "\x00\x00";
		$arrFile['compression_method']        = "\x08\x00";
		$arrFile['last_mod_file_hex']         = $this->unixToHex($intTime);
		$arrFile['crc-32']                    = pack('V', crc32($strData));

		$intUncompressed = \strlen($strData);

		// Compress data
		$strData = gzcompress($strData);
		$strData = substr(substr($strData, 0, -4), 2);

		$intCompressed = \strlen($strData);

		// Continue file
		$arrFile['compressed_size']           = pack('V', $intCompressed);
		$arrFile['uncompressed_size']         = pack('V', $intUncompressed);
		$arrFile['file_name_length']          = pack('v', \strlen($strName));
		$arrFile['extra_field_length']        = "\x00\x00";
		$arrFile['file_name']                 = $strName;
		$arrFile['extra_field']               = '';

		// Store file offset
		$intOffset = @ftell($this->resFile);

		// Add file to archive
		fwrite($this->resFile, implode('', $arrFile));
		fwrite($this->resFile, $strData);

		// Start central directory
		$arrHeader['header_signature']          = self::CENTRAL_DIR_START;
		$arrHeader['version_made_by']           = "\x00\x00";
		$arrHeader['version_needed_to_extract'] = $arrFile['version_needed_to_extract'];
		$arrHeader['general_purpose_bit_flag']  = $arrFile['general_purpose_bit_flag'];
		$arrHeader['compression_method']        = $arrFile['compression_method'];
		$arrHeader['last_mod_file_hex']         = $arrFile['last_mod_file_hex'];
		$arrHeader['crc-32']                    = $arrFile['crc-32'];
		$arrHeader['compressed_size']           = $arrFile['compressed_size'];
		$arrHeader['uncompressed_size']         = $arrFile['uncompressed_size'];
		$arrHeader['file_name_length']          = $arrFile['file_name_length'];
		$arrHeader['extra_field_length']        = $arrFile['extra_field_length'];
		$arrHeader['file_comment_length']       = "\x00\x00";
		$arrHeader['disk_number_start']         = "\x00\x00";
		$arrHeader['internal_file_attributes']  = "\x00\x00";
		$arrHeader['external_file_attributes']  = pack('V', 32);
		$arrHeader['offset_of_local_header']    = pack('V', $intOffset);
		$arrHeader['file_name']                 = $arrFile['file_name'];
		$arrHeader['extra_field']               = $arrFile['extra_field'];
		$arrHeader['file_comment']              = '';

		// Add entry to central directory
		$this->strCentralDir .= implode('', $arrHeader);
	}

	/**
	 * Write the central directory and close the file handle
	 */
	public function close()
	{
		// Add archive header
		$arrArchive['archive_signature']      = self::CENTRAL_DIR_END;
		$arrArchive['number_of_this_disk']    = "\x00\x00";
		$arrArchive['number_of_disk_with_cd'] = "\x00\x00";
		$arrArchive['total_cd_entries_disk']  = pack('v', $this->intCount);
		$arrArchive['total_cd_entries']       = pack('v', $this->intCount);
		$arrArchive['size_of_cd']             = pack('V', \strlen($this->strCentralDir));
		$arrArchive['offset_start_cd']        = pack('V', @ftell($this->resFile));
		$arrArchive['zipfile_comment_length'] = "\x00\x00";
		$arrArchive['zipfile_comment']        = '';

		// Add central directory and archive header (do not change this order)
		fwrite($this->resFile, $this->strCentralDir);
		fwrite($this->resFile, implode('', $arrArchive));

		// Close the file before renaming it
		fclose($this->resFile);

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

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

		// Rename file
		Files::getInstance()->rename(self::TEMPORARY_FOLDER . '/' . basename($this->strTemp), $this->strFile);
	}

	/**
	 * Convert a Unix timestamp to a hexadecimal value
	 *
	 * @param integer $intTime The Unix timestamp
	 *
	 * @return integer The hexadecimal value
	 */
	protected function unixToHex($intTime=0)
	{
		$arrTime = $intTime ? getdate($intTime) : getdate();

		$hexTime = dechex
		(
			(($arrTime['year'] - 1980) << 25) |
			 ($arrTime['mon'] << 21) |
			 ($arrTime['mday'] << 16) |
			 ($arrTime['hours'] << 11) |
			 ($arrTime['minutes'] << 5) |
			 ($arrTime['seconds'] >> 1)
		);

		return pack("H*", $hexTime[6] . $hexTime[7] . $hexTime[4] . $hexTime[5] . $hexTime[2] . $hexTime[3] . $hexTime[0] . $hexTime[1]);
	}
}

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


<?php

declare(strict_types=1);

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

namespace Contao\InstallationBundle\Database;

class Version447Update extends AbstractVersionUpdate
{
    /**
     * {@inheritdoc}
     */
    public function shouldBeRun(): bool
    {
        $schemaManager = $this->connection->getSchemaManager();

        if (!$schemaManager->tablesExist(['tl_newsletter_recipients'])) {
            return false;
        }

        $columns = $schemaManager->listTableIndexes('tl_newsletter_recipients');

        return !isset($columns['pid_email']);
    }

    /**
     * {@inheritdoc}
     */
    public function run(): void
    {
        $schemaManager = $this->connection->getSchemaManager();

        // Back up the existing subscriptions
        if (!$schemaManager->tablesExist(['tl_newsletter_recipients_backup'])) {
            $this->connection->query('
                CREATE TABLE
                    tl_newsletter_recipients_backup
                LIKE
                    tl_newsletter_recipients
            ');

            $this->connection->query('
                INSERT
                    tl_newsletter_recipients_backup
                SELECT
                    *
                FROM
                    tl_newsletter_recipients
            ');
        }

        // Find multiple subscriptions for the same channel with the same e-mail address
        $duplicates = $this->connection->query('
            SELECT
                pid, email
            FROM
                tl_newsletter_recipients
            GROUP BY
                pid, email
            HAVING
                COUNT(*) > 1
            ORDER BY
                pid
        ');

        while (false !== ($duplicate = $duplicates->fetch(\PDO::FETCH_OBJ))) {
            $count = 0;

            // Find the oldest, active subscription preferring real subscriptions over imported ones
            $subscriptions = $this->connection->prepare("
                SELECT
                    *
                FROM
                    tl_newsletter_recipients
                WHERE
                    pid = :pid AND email = :email
                ORDER BY
                    active = '1' DESC, addedOn != '' DESC, id
            ");

            $subscriptions->execute(['pid' => $duplicate->pid, ':email' => $duplicate->email]);

            while (false !== ($subscription = $subscriptions->fetch(\PDO::FETCH_OBJ))) {
                if (0 === $count++) {
                    continue; // keep the first subscription
                }

                $delete = $this->connection->prepare('
                    DELETE FROM
                        tl_newsletter_recipients
                    WHERE
                        id = :id
                ');

                $delete->execute(['id' => $subscription->id]);
            }

            $this->addMessage(sprintf('
%s
', $duplicate->email));
        }

        if ($this->hasMessage()) {
            $translator = $this->container->get('translator');

            $this->prependMessage(sprintf(
                '
%s

%s

',
                $translator->trans('duplicate_subscriptions'),
                $translator->trans('duplicate_subscriptions_begin')
            ));

            $this->addMessage(sprintf('

%s
', $translator->trans('duplicate_subscriptions_end')));
        }
    }
}


<?php

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

namespace Contao;

use Contao\CoreBundle\Exception\PageNotFoundException;
use Contao\CoreBundle\OptIn\OptIn;
use FOS\HttpCache\ResponseTagger;

/**
 * Class Comments
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class Comments extends Frontend
{

	/**
	 * Add comments to a template
	 *
	 * @param FrontendTemplate $objTemplate
	 * @param \stdClass        $objConfig
	 * @param string           $strSource
	 * @param integer          $intParent
	 * @param mixed            $varNotifies
	 */
	public function addCommentsToTemplate(FrontendTemplate $objTemplate, \stdClass $objConfig, $strSource, $intParent, $varNotifies)
	{
		/** @var PageModel $objPage */
		global $objPage;

		$limit = 0;
		$offset = 0;
		$total = 0;
		$gtotal = 0;
		$arrComments = array();

		$objTemplate->comments = array(); // see #4064

		// Tag the response
		if (System::getContainer()->has('fos_http_cache.http.symfony_response_tagger'))
		{
			/** @var ResponseTagger $responseTagger */
			$responseTagger = System::getContainer()->get('fos_http_cache.http.symfony_response_tagger');
			$responseTagger->addTags(array(sprintf('contao.comments.%s.%s', $strSource, $intParent)));
		}

		// Pagination
		if ($objConfig->perPage > 0)
		{
			// Get the total number of comments
			$intTotal = CommentsModel::countPublishedBySourceAndParent($strSource, $intParent);
			$total = $gtotal = $intTotal;

			// Calculate the key (e.g. tl_form_field becomes page_cff12)
			$key = '';
			$chunks = explode('_', substr($strSource, ((strncmp($strSource, 'tl_', 3) === 0) ? 3 : 0)));

			foreach ($chunks as $chunk)
			{
				$key .= substr($chunk, 0, 1);
			}

			// Get the current page
			$id = 'page_c' . $key . $intParent; // see #4141
			$page = Input::get($id) ?? 1;

			// Do not index or cache the page if the page number is outside the range
			if ($page < 1 || $page > max(ceil($total/$objConfig->perPage), 1))
			{
				throw new PageNotFoundException('Page not found: ' . Environment::get('uri'));
			}

			// Set limit and offset
			$limit = $objConfig->perPage;
			$offset = ($page - 1) * $objConfig->perPage;

			// Initialize the pagination menu
			$objPagination = new Pagination($total, $objConfig->perPage, Config::get('maxPaginationLinks'), $id);
			$objTemplate->pagination = $objPagination->generate("\n  ");
		}

		$objTemplate->allowComments = true;

		// Get all published comments
		if ($limit)
		{
			$objComments = CommentsModel::findPublishedBySourceAndParent($strSource, $intParent, ($objConfig->order == 'descending'), $limit, $offset);
		}
		else
		{
			$objComments = CommentsModel::findPublishedBySourceAndParent($strSource, $intParent, ($objConfig->order == 'descending'));
		}

		// Parse the comments
		if ($objComments !== null && ($total = $objComments->count()) > 0)
		{
			$count = 0;

			if ($objConfig->template == '')
			{
				$objConfig->template = 'com_default';
			}

			$objPartial = new FrontendTemplate($objConfig->template);

			while ($objComments->next())
			{
				$objPartial->setData($objComments->row());

				// Clean the RTE output
				$objPartial->comment = StringUtil::toHtml5($objComments->comment);
				$objPartial->comment = trim(str_replace(array('{{', '}}'), array('{{', '}}'), $objPartial->comment));

				$objPartial->datim = Date::parse($objPage->datimFormat, $objComments->date);
				$objPartial->date = Date::parse($objPage->dateFormat, $objComments->date);
				$objPartial->class = (($count < 1) ? ' first' : '') . (($count >= ($total - 1)) ? ' last' : '') . (($count % 2 == 0) ? ' even' : ' odd');
				$objPartial->by = $GLOBALS['TL_LANG']['MSC']['com_by'];
				$objPartial->id = 'c' . $objComments->id;
				$objPartial->timestamp = $objComments->date;
				$objPartial->datetime = date('Y-m-d\TH:i:sP', $objComments->date);
				$objPartial->addReply = false;

				// Reply
				if ($objComments->addReply && $objComments->reply != '')
				{
					if (($objAuthor = $objComments->getRelated('author')) instanceof UserModel)
					{
						$objPartial->addReply = true;
						$objPartial->rby = $GLOBALS['TL_LANG']['MSC']['com_reply'];
						$objPartial->reply = $this->replaceInsertTags($objComments->reply);
						$objPartial->author = $objAuthor;

						// Clean the RTE output
						$objPartial->reply = StringUtil::toHtml5($objPartial->reply);
					}
				}

				$arrComments[] = $objPartial->parse();
				++$count;
			}
		}

		$objTemplate->comments = $arrComments;
		$objTemplate->hlcText = $GLOBALS['TL_LANG']['MSC']['comments'];
		$objTemplate->addComment = $GLOBALS['TL_LANG']['MSC']['addComment'];
		$objTemplate->name = $GLOBALS['TL_LANG']['MSC']['com_name'];
		$objTemplate->email = $GLOBALS['TL_LANG']['MSC']['com_email'];
		$objTemplate->website = $GLOBALS['TL_LANG']['MSC']['com_website'];
		$objTemplate->commentsTotal = $limit ? $gtotal : $total;

		// Add a form to create new comments
		$this->renderCommentForm($objTemplate, $objConfig, $strSource, $intParent, $varNotifies);
	}

	/**
	 * Add a form to create new comments
	 *
	 * @param FrontendTemplate $objTemplate
	 * @param \stdClass        $objConfig
	 * @param string           $strSource
	 * @param integer          $intParent
	 * @param mixed            $varNotifies
	 */
	protected function renderCommentForm(FrontendTemplate $objTemplate, \stdClass $objConfig, $strSource, $intParent, $varNotifies)
	{
		$this->import(FrontendUser::class, 'User');

		// Access control
		if ($objConfig->requireLogin && !FE_USER_LOGGED_IN)
		{
			$objTemplate->requireLogin = true;
			$objTemplate->login = $GLOBALS['TL_LANG']['MSC']['com_login'];

			return;
		}

		// Confirm or remove a subscription
		if (strncmp(Input::get('token'), 'com-', 4) === 0 || strncmp(Input::get('token'), 'cor-', 4) === 0)
		{
			static::changeSubscriptionStatus($objTemplate);

			return;
		}

		// Form fields
		$arrFields = array
		(
			'name' => array
			(
				'name'      => 'name',
				'label'     => $GLOBALS['TL_LANG']['MSC']['com_name'],
				'value'     => trim($this->User->firstname . ' ' . $this->User->lastname),
				'inputType' => 'text',
				'eval'      => array('mandatory'=>true, 'maxlength'=>64)
			),
			'email' => array
			(
				'name'      => 'email',
				'label'     => $GLOBALS['TL_LANG']['MSC']['com_email'],
				'value'     => $this->User->email,
				'inputType' => 'text',
				'eval'      => array('rgxp'=>'email', 'mandatory'=>true, 'maxlength'=>255, 'decodeEntities'=>true)
			),
			'website' => array
			(
				'name'      => 'website',
				'label'     => $GLOBALS['TL_LANG']['MSC']['com_website'],
				'inputType' => 'text',
				'eval'      => array('rgxp'=>'url', 'maxlength'=>128, 'decodeEntities'=>true)
			)
		);

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

		// Comment field
		$arrFields['comment'] = array
		(
			'name'      => 'comment',
			'label'     => $GLOBALS['TL_LANG']['MSC']['com_comment'],
			'inputType' => 'textarea',
			'eval'      => array('mandatory'=>true, 'rows'=>4, 'cols'=>40, 'preserveTags'=>true)
		);

		// Notify me of new comments
		$arrFields['notify'] = array
		(
			'name'      => 'notify',
			'label'     => '',
			'inputType' => 'checkbox',
			'options'   => array(1=>$GLOBALS['TL_LANG']['MSC']['com_notify'])
		);

		$doNotSubmit = false;
		$arrWidgets = array();
		$strFormId = 'com_'. $strSource .'_'. $intParent;

		// 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'], $arrField['value']));

			// Append the parent ID to prevent duplicate IDs (see #1493)
			$objWidget->id .= '_' . $intParent;

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

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

			$arrWidgets[$arrField['name']] = $objWidget;
		}

		$objTemplate->fields = $arrWidgets;
		$objTemplate->submit = $GLOBALS['TL_LANG']['MSC']['com_submit'];
		$objTemplate->action = ampersand(Environment::get('request'));
		$objTemplate->messages = ''; // Deprecated since Contao 4.0, to be removed in Contao 5.0
		$objTemplate->formId = $strFormId;
		$objTemplate->hasError = $doNotSubmit;

		// Do not index or cache the page with the confirmation message
		if ($_SESSION['TL_COMMENT_ADDED'])
		{
			/** @var PageModel $objPage */
			global $objPage;

			$objPage->noSearch = 1;
			$objPage->cache = 0;

			$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['com_confirm'];
			$_SESSION['TL_COMMENT_ADDED'] = false;
		}

		// Store the comment
		if (!$doNotSubmit && Input::post('FORM_SUBMIT') == $strFormId)
		{
			$strWebsite = $arrWidgets['website']->value;

			// Add http:// to the website
			if (($strWebsite != '') && !preg_match('@^(https?://|ftp://|mailto:|#)@i', $strWebsite))
			{
				$strWebsite = 'http://' . $strWebsite;
			}

			// Do not parse any tags in the comment
			$strComment = StringUtil::specialchars(trim($arrWidgets['comment']->value));
			$strComment = str_replace(array('&', '<', '>'), array('[&]', '[lt]', '[gt]'), $strComment);

			// Remove multiple line feeds
			$strComment = preg_replace('@\n\n+@', "\n\n", $strComment);

			// Parse BBCode
			if ($objConfig->bbcode)
			{
				$strComment = $this->parseBbCode($strComment);
			}

			// Prevent cross-site request forgeries
			$strComment = preg_replace('/(href|src|on[a-z]+)="[^"]*(contao\/main\.php|typolight\/main\.php|javascript|vbscri?pt|script|alert|document|cookie|window)[^"]*"+/i', '$1="#"', $strComment);

			$intMember = 0;

			if (System::getContainer()->get('contao.security.token_checker')->hasFrontendUser())
			{
				$intMember = FrontendUser::getInstance()->id;
			}

			$time = time();

			// Prepare the record
			$arrSet = array
			(
				'tstamp'    => $time,
				'source'    => $strSource,
				'parent'    => $intParent,
				'name'      => $arrWidgets['name']->value,
				'email'     => $arrWidgets['email']->value,
				'website'   => $strWebsite,
				'member'    => $intMember,
				'comment'   => $this->convertLineFeeds($strComment),
				'ip'        => Environment::get('ip'),
				'date'      => $time,
				'published' => ($objConfig->moderate ? '' : 1)
			);

			// Store the comment
			$objComment = new CommentsModel();
			$objComment->setRow($arrSet)->save();

			// Store the subscription
			if ($arrWidgets['notify']->value)
			{
				static::addCommentsSubscription($objComment);
			}

			// HOOK: add custom logic
			if (isset($GLOBALS['TL_HOOKS']['addComment']) && \is_array($GLOBALS['TL_HOOKS']['addComment']))
			{
				foreach ($GLOBALS['TL_HOOKS']['addComment'] as $callback)
				{
					$this->import($callback[0]);
					$this->{$callback[0]}->{$callback[1]}($objComment->id, $arrSet, $this);
				}
			}

			// Prepare the notification mail
			$objEmail = new Email();
			$objEmail->from = $GLOBALS['TL_ADMIN_EMAIL'];
			$objEmail->fromName = $GLOBALS['TL_ADMIN_NAME'];
			$objEmail->subject = sprintf($GLOBALS['TL_LANG']['MSC']['com_subject'], Idna::decode(Environment::get('host')));

			// Convert the comment to plain text
			$strComment = strip_tags($strComment);
			$strComment = StringUtil::decodeEntities($strComment);
			$strComment = str_replace(array('[&]', '[lt]', '[gt]'), array('&', '<', '>'), $strComment);

			// Add the comment details
			$objEmail->text = sprintf($GLOBALS['TL_LANG']['MSC']['com_message'],
									  $arrSet['name'] . ' (' . $arrSet['email'] . ')',
									  $strComment,
									  Idna::decode(Environment::get('base')) . Environment::get('request'),
									  Idna::decode(Environment::get('base')) . 'contao?do=comments&act=edit&id=' . $objComment->id);

			// Add a moderation hint to the e-mail (see #7478)
			if ($objConfig->moderate)
			{
				$objEmail->text .= "\n" . $GLOBALS['TL_LANG']['MSC']['com_moderated'] . "\n";
			}

			// Do not send notifications twice
			if (\is_array($varNotifies))
			{
				$objEmail->sendTo(array_unique($varNotifies));
			}
			elseif ($varNotifies != '')
			{
				$objEmail->sendTo($varNotifies); // see #5443
			}

			// Pending for approval
			if ($objConfig->moderate)
			{
				$_SESSION['TL_COMMENT_ADDED'] = true;
			}
			else
			{
				static::notifyCommentsSubscribers($objComment);
			}

			$this->reload();
		}
	}

	/**
	 * Replace bbcode and return the HTML string
	 *
	 * Supports the following tags:
	 *
	 * * [b][/b] bold
	 * * [i][/i] italic
	 * * [u][/u] underline
	 * * [img][/img]
	 * * [code][/code]
	 * * [color=#ff0000][/color]
	 * * [quote][/quote]
	 * * [quote=tim][/quote]
	 * * [url][/url]
	 * * [url=http://][/url]
	 * * [email][/email]
	 * * [email=name@example.com][/email]
	 *
	 * @param string $strComment
	 *
	 * @return string
	 */
	public function parseBbCode($strComment)
	{
		$arrSearch = array
		(
			'@\[b\](.*)\[/b\]@Uis',
			'@\[i\](.*)\[/i\]@Uis',
			'@\[u\](.*)\[/u\]@Uis',
			'@\s*\[code\](.*)\[/code\]\s*@Uis',
			'@\[color=([^\]" ]+)\](.*)\[/color\]@Uis',
			'@\s*\[quote\](.*)\[/quote\]\s*@Uis',
			'@\s*\[quote=([^\]]+)\](.*)\[/quote\]\s*@Uis',
			'@\[img\]\s*([^\[" ]+\.(jpe?g|png|gif|bmp|tiff?|ico))\s*\[/img\]@i',
			'@\[url\]\s*([^\[" ]+)\s*\[/url\]@i',
			'@\[url=([^\]" ]+)\](.*)\[/url\]@Uis',
			'@\[email\]\s*([^\[" ]+)\s*\[/email\]@i',
			'@\[email=([^\]" ]+)\](.*)\[/email\]@Uis',
			'@href="(([a-z0-9]+\.)*[a-z0-9]+\.([a-z]{2}|asia|biz|com|info|name|net|org|tel)(/|"))@i'
		);

		$arrReplace = array
		(
			'$1',
			'$1',
			'$1',
			"\n\n" . '

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

$1

' . "\n\n",
			'$2',
			"\n\n" . '
$1
' . "\n\n",
			"\n\n" . '

'. sprintf($GLOBALS['TL_LANG']['MSC']['com_quote'], '$1') .'
$2
' . "\n\n",
			'',
			'$1',
			'$2',
			'$1',
			'$2',
			'href="http://$1'
		);

		$strComment = preg_replace($arrSearch, $arrReplace, $strComment);

		// Encode e-mail addresses
		if (strpos($strComment, 'mailto:') !== false)
		{
			$strComment = StringUtil::encodeEmail($strComment);
		}

		return $strComment;
	}

	/**
	 * Convert line feeds to
 tags
	 *
	 * @param string $strComment
	 *
	 * @return string
	 */
	public function convertLineFeeds($strComment)
	{
		$strComment = nl2br_pre($strComment);

		// Use paragraphs to generate new lines
		if (strncmp('
', $strComment, 3) !== 0)
		{
			$strComment = '
'. $strComment .'
';
		}

		$arrReplace = array
		(
			'@
\s?
\s?@' => "
\n
", // Convert two linebreaks into a new paragraph
			'@\s?

@'    => '
',      // Remove BR tags before closing P tags
			'@

 ' '
'     // Do not nest DIVs inside paragraphs
		);

		return preg_replace(array_keys($arrReplace), array_values($arrReplace), $strComment);
	}

	/**
	 * Purge subscriptions that have not been activated within 24 hours
	 */
	public function purgeSubscriptions()
	{
		$objNotify = CommentsNotifyModel::findExpiredSubscriptions();

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

		while ($objNotify->next())
		{
			$objNotify->delete();
		}

		// Add a log entry
		$this->log('Purged the unactivated comment subscriptions', __METHOD__, TL_CRON);
	}

	/**
	 * Add the subscription and send the activation mail (double opt-in)
	 *
	 * @param CommentsModel $objComment
	 */
	public static function addCommentsSubscription(CommentsModel $objComment)
	{
		$objNotify = CommentsNotifyModel::findBySourceParentAndEmail($objComment->source, $objComment->parent, $objComment->email);

		// The subscription exists already
		if ($objNotify !== null)
		{
			return;
		}

		$time = time();

		// Prepare the record
		$arrSet = array
		(
			'tstamp'       => $time,
			'source'       => $objComment->source,
			'parent'       => $objComment->parent,
			'name'         => $objComment->name,
			'email'        => $objComment->email,
			'url'          => Environment::get('request'),
			'addedOn'      => $time,
			'active'       => '',
			'tokenRemove'  => 'cor-'.bin2hex(random_bytes(10))
		);

		// Store the subscription
		$objNotify = new CommentsNotifyModel();
		$objNotify->setRow($arrSet)->save();

		$strUrl = Idna::decode(Environment::get('base')) . Environment::get('request');
		$strConnector = (strpos($strUrl, '?') !== false) ? '&' : '?';

		/** @var OptIn $optIn */
		$optIn = System::getContainer()->get('contao.opt-in');
		$optInToken = $optIn->create('com', $objComment->email, array('tl_comments_notify'=>array($objNotify->id)));

		// Send the token
		$optInToken->send(sprintf($GLOBALS['TL_LANG']['MSC']['com_optInSubject'], Idna::decode(Environment::get('host'))), sprintf($GLOBALS['TL_LANG']['MSC']['com_optInMessage'], $objComment->name, $strUrl, $strUrl . $strConnector . 'token=' . $optInToken->getIdentifier(), $strUrl . $strConnector . 'token=' . $objNotify->tokenRemove));
	}

	/**
	 * Change the subscription status
	 *
	 * @param FrontendTemplate $objTemplate
	 */
	public static function changeSubscriptionStatus(FrontendTemplate $objTemplate)
	{
		if (strncmp(Input::get('token'), 'com-', 4) === 0)
		{
			/** @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_comments_notify' || \count($arrIds = current($arrRelated)) != 1 || (!$objNotify = CommentsNotifyModel::findByPk($arrIds[0])))
			{
				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['invalidToken'];

				return;
			}

			if ($optInToken->isConfirmed())
			{
				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['tokenConfirmed'];

				return;
			}

			if ($optInToken->getEmail() != $objNotify->email)
			{
				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['tokenEmailMismatch'];

				return;
			}

			$objNotify->active = '1';
			$objNotify->save();

			$optInToken->confirm();

			$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['com_optInConfirm'];
		}
		elseif (strncmp(Input::get('token'), 'cor-', 4) === 0)
		{
			$objNotify = CommentsNotifyModel::findOneByTokenRemove(Input::get('token'));

			if ($objNotify === null)
			{
				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['invalidToken'];

				return;
			}

			$objNotify->delete();
			$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['com_optInCancel'];
		}
	}

	/**
	 * Notify the subscribers of new comments
	 *
	 * @param CommentsModel $objComment
	 */
	public static function notifyCommentsSubscribers(CommentsModel $objComment)
	{
		// Notified already
		if ($objComment->notified)
		{
			return;
		}

		$objNotify = CommentsNotifyModel::findActiveBySourceAndParent($objComment->source, $objComment->parent);

		if ($objNotify !== null)
		{
			while ($objNotify->next())
			{
				// Don't notify the commentor about his own comment
				if ($objNotify->email == $objComment->email)
				{
					continue;
				}

				// Prepare the URL
				$strUrl = Idna::decode(Environment::get('base')) . $objNotify->url;

				$objEmail = new Email();
				$objEmail->from = $GLOBALS['TL_ADMIN_EMAIL'];
				$objEmail->fromName = $GLOBALS['TL_ADMIN_NAME'];
				$objEmail->subject = sprintf($GLOBALS['TL_LANG']['MSC']['com_notifySubject'], Idna::decode(Environment::get('host')));
				$objEmail->text = sprintf($GLOBALS['TL_LANG']['MSC']['com_notifyMessage'], $objNotify->name, $strUrl . '#c' . $objComment->id, $strUrl . '?token=' . $objNotify->tokenRemove);
				$objEmail->sendTo($objNotify->email);
			}
		}

		$objComment->notified = '1';
		$objComment->save();
	}
}

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


<?php

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

namespace Contao;

use MatthiasMullie\Minify;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\VarDumper\VarDumper;

/**
 * Parses and outputs template files
 *
 * The class supports loading template files, adding variables to them and then
 * printing them to the screen. It functions as abstract parent class for the
 * two core classes "BackendTemplate" and "FrontendTemplate".
 *
 * Usage:
 *
 *     $template = new BackendTemplate();
 *     $template->name = 'Leo Feyer';
 *     $template->output();
 *
 * @property string       $style
 * @property array|string $cssID
 * @property string       $class
 * @property string       $inColumn
 * @property string       $headline
 * @property array        $hl
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
abstract class Template extends Controller
{
	use TemplateInheritance;

	/**
	 * Output buffer
	 * @var string
	 */
	protected $strBuffer;

	/**
	 * Content type
	 * @var string
	 */
	protected $strContentType;

	/**
	 * Template data
	 * @var array
	 */
	protected $arrData = array();

	/**
	 * Valid JavaScipt types
	 * @var array
	 * @see http://www.w3.org/TR/html5/scripting-1.html#scriptingLanguages
	 */
	protected static $validJavaScriptTypes = array
	(
		'application/ecmascript',
		'application/javascript',
		'application/x-ecmascript',
		'application/x-javascript',
		'text/ecmascript',
		'text/javascript',
		'text/javascript1.0',
		'text/javascript1.1',
		'text/javascript1.2',
		'text/javascript1.3',
		'text/javascript1.4',
		'text/javascript1.5',
		'text/jscript',
		'text/livescript',
		'text/x-ecmascript',
		'text/x-javascript',
	);

	/**
	 * Create a new template object
	 *
	 * @param string $strTemplate    The template name
	 * @param string $strContentType The content type (defaults to "text/html")
	 */
	public function __construct($strTemplate='', $strContentType='text/html')
	{
		parent::__construct();

		$this->strTemplate = $strTemplate;
		$this->strContentType = $strContentType;
	}

	/**
	 * Set an object property
	 *
	 * @param string $strKey   The property name
	 * @param mixed  $varValue The property value
	 */
	public function __set($strKey, $varValue)
	{
		$this->arrData[$strKey] = $varValue;
	}

	/**
	 * Return an object property
	 *
	 * @param string $strKey The property name
	 *
	 * @return mixed The property value
	 */
	public function __get($strKey)
	{
		if (isset($this->arrData[$strKey]))
		{
			if (\is_object($this->arrData[$strKey]) && \is_callable($this->arrData[$strKey]))
			{
				return $this->arrData[$strKey]();
			}

			return $this->arrData[$strKey];
		}

		return parent::__get($strKey);
	}

	/**
	 * Execute a callable and return the result
	 *
	 * @param string $strKey    The name of the key
	 * @param array  $arrParams The parameters array
	 *
	 * @return mixed The callable return value
	 *
	 * @throws \InvalidArgumentException If the callable does not exist
	 */
	public function __call($strKey, $arrParams)
	{
		if (!isset($this->arrData[$strKey]) || !\is_callable($this->arrData[$strKey]))
		{
			throw new \InvalidArgumentException("$strKey is not set or not a callable");
		}

		return \call_user_func_array($this->arrData[$strKey], $arrParams);
	}

	/**
	 * Check whether a property is set
	 *
	 * @param string $strKey The property name
	 *
	 * @return boolean True if the property is set
	 */
	public function __isset($strKey)
	{
		return isset($this->arrData[$strKey]);
	}

	/**
	 * Set the template data from an array
	 *
	 * @param array $arrData The data array
	 */
	public function setData($arrData)
	{
		$this->arrData = $arrData;
	}

	/**
	 * Return the template data as array
	 *
	 * @return array The data array
	 */
	public function getData()
	{
		return $this->arrData;
	}

	/**
	 * Set the template name
	 *
	 * @param string $strTemplate The template name
	 */
	public function setName($strTemplate)
	{
		$this->strTemplate = $strTemplate;
	}

	/**
	 * Return the template name
	 *
	 * @return string The template name
	 */
	public function getName()
	{
		return $this->strTemplate;
	}

	/**
	 * Set the output format
	 *
	 * @param string $strFormat The output format
	 */
	public function setFormat($strFormat)
	{
		$this->strFormat = $strFormat;
	}

	/**
	 * Return the output format
	 *
	 * @return string The output format
	 */
	public function getFormat()
	{
		return $this->strFormat;
	}

	/**
	 * Print all template variables to the screen using print_r
	 *
	 * @deprecated Deprecated since Contao 4.3, to be removed in Contao 5.
	 *             Use Template::dumpTemplateVars() instead.
	 */
	public function showTemplateVars()
	{
		@trigger_error('Using Template::showTemplateVars() has been deprecated and will no longer work in Contao 5.0. Use Template::dumpTemplateVars() instead.', E_USER_DEPRECATED);

		$this->dumpTemplateVars();
	}

	/**
	 * Print all template variables to the screen using the Symfony VarDumper component
	 */
	public function dumpTemplateVars()
	{
		VarDumper::dump($this->arrData);
	}

	/**
	 * Parse the template file and return it as string
	 *
	 * @return string The template markup
	 */
	public function parse()
	{
		if ($this->strTemplate == '')
		{
			return '';
		}

		// HOOK: add custom parse filters
		if (isset($GLOBALS['TL_HOOKS']['parseTemplate']) && \is_array($GLOBALS['TL_HOOKS']['parseTemplate']))
		{
			foreach ($GLOBALS['TL_HOOKS']['parseTemplate'] as $callback)
			{
				$this->import($callback[0]);
				$this->{$callback[0]}->{$callback[1]}($this);
			}
		}

		return $this->inherit();
	}

	/**
	 * Parse the template file and print it to the screen
	 *
	 * @deprecated Deprecated since Contao 4.0, to be removed in Contao 5.0.
	 *             Use Template::getResponse() instead.
	 */
	public function output()
	{
		@trigger_error('Using Template::output() has been deprecated and will no longer work in Contao 5.0. Use Template::getResponse() instead.', E_USER_DEPRECATED);

		$this->compile();

		header('Content-Type: ' . $this->strContentType . '; charset=' . Config::get('characterSet'));

		echo $this->strBuffer;

		// Flush the output buffers (see #6962)
		$this->flushAllData();
	}

	/**
	 * Return a response object
	 *
	 * @return Response The response object
	 */
	public function getResponse()
	{
		$this->compile();

		$response = new Response($this->strBuffer);
		$response->headers->set('Content-Type', $this->strContentType . '; charset=' . Config::get('characterSet'));

		return $response;
	}

	/**
	 * Return a route relative to the base URL
	 *
	 * @param string $strName   The route name
	 * @param array  $arrParams The route parameters
	 *
	 * @return string The route
	 */
	public function route($strName, $arrParams=array())
	{
		$strUrl = System::getContainer()->get('router')->generate($strName, $arrParams);
		$strUrl = substr($strUrl, \strlen(Environment::get('path')) + 1);

		return ampersand($strUrl);
	}

	/**
	 * Return the preview route
	 *
	 * @param string $strName   The route name
	 * @param array  $arrParams The route parameters
	 *
	 * @return string The route
	 */
	public function previewRoute($strName, $arrParams=array())
	{
		$objRouter = System::getContainer()->get('router');
		$objContext = $objRouter->getContext();

		$objPreviewContext = clone $objContext;
		$objPreviewContext->setBaseUrl('/preview.php');

		$objRouter->setContext($objPreviewContext);

		$strUrl = $objRouter->generate($strName, $arrParams);
		$strUrl = substr($strUrl, \strlen(Environment::get('path')) + 1);

		$objRouter->setContext($objContext);

		return ampersand($strUrl);
	}

	/**
	 * Returns a translated message
	 *
	 * @param string $strId
	 * @param array  $arrParams
	 * @param string $strDomain
	 *
	 * @return string
	 */
	public function trans($strId, array $arrParams=array(), $strDomain='contao_default')
	{
		return System::getContainer()->get('translator')->trans($strId, $arrParams, $strDomain);
	}

	/**
	 * Returns an asset path
	 *
	 * @param string      $path
	 * @param string|null $packageName
	 *
	 * @return string
	 */
	public function asset($path, $packageName = null)
	{
		$url = System::getContainer()->get('assets.packages')->getUrl($path, $packageName);

		// Contao paths are relative to the <base> tag, so remove leading slashes
		return ltrim($url, '/');
	}

	/**
	 * Compile the template
	 *
	 * @internal Do not call this method in your code. It will be made private in Contao 5.0.
	 */
	protected function compile()
	{
		if (!$this->strBuffer)
		{
			$this->strBuffer = $this->parse();
		}
	}

	/**
	 * Return the debug bar string
	 *
	 * @deprecated Deprecated since Contao 4.0, to be removed in Contao 5.0.
	 */
	protected function getDebugBar()
	{
		@trigger_error('Using Template::getDebugBar() has been deprecated and will no longer work in Contao 5.0.', E_USER_DEPRECATED);
	}

	/**
	 * Minify the HTML markup preserving pre, script, style and textarea tags
	 *
	 * @param string $strHtml The HTML markup
	 *
	 * @return string The minified HTML markup
	 */
	public function minifyHtml($strHtml)
	{
		if (Config::get('debugMode'))
		{
			return $strHtml;
		}

		// Split the markup based on the tags that shall be preserved
		$arrChunks = preg_split('@(</?pre[^>]*>)|(</?script[^>]*>)|(</?style[^>]*>)|( ?</?textarea[^>]*>)@i', $strHtml, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);

		$strHtml = '';
		$blnPreserveNext = false;
		$blnOptimizeNext = false;
		$strType = null;

		// Check for valid JavaScript types (see #7927)
		$isJavaScript = function ($strChunk)
		{
			$typeMatch = array();

			if (preg_match('/\stype\s*=\s*(?:(?J)(["\'])\s*(?<type>.*?)\s*\1|(?<type>[^\s>]+))/i', $strChunk, $typeMatch) && !\in_array(strtolower($typeMatch['type']), static::$validJavaScriptTypes))
			{
				return false;
			}

			if (preg_match('/\slanguage\s*=\s*(?:(?J)(["\'])\s*(?<type>.*?)\s*\1|(?<type>[^\s>]+))/i', $strChunk, $typeMatch) && !\in_array('text/' . strtolower($typeMatch['type']), static::$validJavaScriptTypes))
			{
				return false;
			}

			return true;
		};

		// Recombine the markup
		foreach ($arrChunks as $strChunk)
		{
			if (strncasecmp($strChunk, '<pre', 4) === 0 || strncasecmp(ltrim($strChunk), '<textarea', 9) === 0)
			{
				$blnPreserveNext = true;
			}
			elseif (strncasecmp($strChunk, '<script', 7) === 0)
			{
				if ($isJavaScript($strChunk))
				{
					$blnOptimizeNext = true;
					$strType = 'js';
				}
				else
				{
					$blnPreserveNext = true;
				}
			}
			elseif (strncasecmp($strChunk, '<style', 6) === 0)
			{
				$blnOptimizeNext = true;
				$strType = 'css';
			}
			elseif ($blnPreserveNext)
			{
				$blnPreserveNext = false;
			}
			elseif ($blnOptimizeNext)
			{
				$blnOptimizeNext = false;

				// Minify inline scripts
				if ($strType == 'js')
				{
					$objMinify = new Minify\JS();
					$objMinify->add($strChunk);
					$strChunk = $objMinify->minify();
				}
				elseif ($strType == 'css')
				{
					$objMinify = new Minify\CSS();
					$objMinify->add($strChunk);
					$strChunk = $objMinify->minify();
				}
			}
			else
			{
				// Remove line indentations and trailing spaces
				$strChunk = str_replace("\r", '', $strChunk);
				$strChunk = preg_replace(array('/^[\t ]+/m', '/[\t ]+$/m', '/\n\n+/'), array('', '', "\n"), $strChunk);
			}

			$strHtml .= $strChunk;
		}

		return trim($strHtml);
	}

	/**
	 * Generate the markup for a style sheet tag
	 *
	 * @param string $href  The script path
	 * @param string $media The media type string
	 * @param mixed  $mtime The file mtime
	 *
	 * @return string The markup string
	 */
	public static function generateStyleTag($href, $media=null, $mtime=false)
	{
		// Add the filemtime if not given and not an external file
		if ($mtime === null && !preg_match('@^https?://@', $href))
		{
			$container = System::getContainer();
			$rootDir = $container->getParameter('kernel.project_dir');

			if (file_exists($rootDir . '/' . $href))
			{
				$mtime = filemtime($rootDir . '/' . $href);
			}
			else
			{
				$webDir = StringUtil::stripRootDir($container->getParameter('contao.web_dir'));

				// Handle public bundle resources in web/
				if (file_exists($rootDir . '/' . $webDir . '/' . $href))
				{
					$mtime = filemtime($rootDir . '/' . $webDir . '/' . $href);
				}
			}
		}

		if ($mtime)
		{
			$href .= '?v=' . substr(md5($mtime), 0, 8);
		}

		return '<link rel="stylesheet" href="' . $href . '"' . (($media && $media != 'all') ? ' media="' . $media . '"' : '') . '>';
	}

	/**
	 * Generate the markup for inline CSS code
	 *
	 * @param string $script The CSS code
	 *
	 * @return string The markup string
	 */
	public static function generateInlineStyle($script)
	{
		return '<style>' . $script . '</style>';
	}

	/**
	 * Generate the markup for a JavaScript tag
	 *
	 * @param string      $src         The script path
	 * @param boolean     $async       True to add the async attribute
	 * @param mixed       $mtime       The file mtime
	 * @param string|null $hash        An optional integrity hash
	 * @param string|null $crossorigin An optional crossorigin attribute
	 *
	 * @return string The markup string
	 */
	public static function generateScriptTag($src, $async=false, $mtime=false, $hash=null, $crossorigin=null)
	{
		// Add the filemtime if not given and not an external file
		if ($mtime === null && !preg_match('@^https?://@', $src))
		{
			$container = System::getContainer();
			$rootDir = $container->getParameter('kernel.project_dir');

			if (file_exists($rootDir . '/' . $src))
			{
				$mtime = filemtime($rootDir . '/' . $src);
			}
			else
			{
				$webDir = StringUtil::stripRootDir($container->getParameter('contao.web_dir'));

				// Handle public bundle resources in web/
				if (file_exists($rootDir . '/' . $webDir . '/' . $src))
				{
					$mtime = filemtime($rootDir . '/' . $webDir . '/' . $src);
				}
			}
		}

		if ($mtime)
		{
			$src .= '?v=' . substr(md5($mtime), 0, 8);
		}

		return '<script src="' . $src . '"' . ($async ? ' async' : '') . ($hash ? ' integrity="' . $hash . '"' : '') . ($crossorigin ? ' crossorigin="' . $crossorigin . '"' : '') . '></script>';
	}

	/**
	 * Generate the markup for an inline JavaScript
	 *
	 * @param string $script The JavaScript code
	 *
	 * @return string The markup string
	 */
	public static function generateInlineScript($script)
	{
		return '<script>' . $script . '</script>';
	}

	/**
	 * Generate the markup for an RSS feed tag
	 *
	 * @param string $href   The script path
	 * @param string $format The feed format
	 * @param string $title  The feed title
	 *
	 * @return string The markup string
	 */
	public static function generateFeedTag($href, $format, $title)
	{
		return '<link type="application/' . $format . '+xml" rel="alternate" href="' . $href . '" title="' . StringUtil::specialchars($title) . '">';
	}

	/**
	 * Flush the output buffers
	 *
	 * @deprecated Deprecated since Contao 4.0, to be removed in Contao 5.0.
	 */
	public function flushAllData()
	{
		@trigger_error('Using Template::flushAllData() has been deprecated and will no longer work in Contao 5.0.', E_USER_DEPRECATED);

		if (\function_exists('fastcgi_finish_request'))
		{
			fastcgi_finish_request();
		}
		elseif (PHP_SAPI !== 'cli')
		{
			$status = ob_get_status(true);
			$level = \count($status);

			while ($level-- > 0 && (!empty($status[$level]['del']) || (isset($status[$level]['flags']) && ($status[$level]['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) && ($status[$level]['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE))))
			{
				ob_end_flush();
			}

			flush();
		}
	}
}

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


<?php

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

namespace Contao;

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

	/**
	 * Parse the template
	 *
	 * @return string
	 */
	public function generate()
	{
		$objElement = ContentModel::findByPk($this->cteAlias);

		if ($objElement === null)
		{
			return '';
		}

		$strClass = static::findClass($objElement->type);

		if (!class_exists($strClass))
		{
			return '';
		}

		$objElement->origId = $objElement->id;
		$objElement->id = $this->id;
		$objElement->typePrefix = 'ce_';

		/** @var ContentElement $objElement */
		$objElement = new $strClass($objElement);

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

		// Override the CSS ID (see #305)
		if (!empty($this->cssID[0]))
		{
			$cssID[0] = $this->cssID[0];
		}

		// Merge the CSS classes (see #6011)
		if (!empty($this->cssID[1]))
		{
			$cssID[1] = trim($cssID[1] . ' ' . $this->cssID[1]);
		}

		$objElement->cssID = $cssID;

		return $objElement->generate();
	}

	/**
	 * Generate the content element
	 */
	protected function compile() {}
}

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


<?php

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

namespace Contao;

/**
 * Extracts DCA information and cache it
 *
 * The class parses the DCA files and stores various extracts like relations
 * in the cache directory. This meta data can then be loaded and used in the
 * application (e.g. the Model classes).
 *
 * Usage:
 *
 *     $user = DcaExtractor::getInstance('tl_user');
 *
 *     if ($user->hasRelations())
 *     {
 *         print_r($user->getRelations());
 *     }
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
class DcaExtractor extends Controller
{

	/**
	 * Instances
	 * @var DcaExtractor[]
	 */
	protected static $arrInstances = array();

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

	/**
	 * Meta data
	 * @var array
	 */
	protected $arrMeta = array();

	/**
	 * Fields
	 * @var array
	 */
	protected $arrFields = array();

	/**
	 * Order fields
	 * @var array
	 */
	protected $arrOrderFields = array();

	/**
	 * Unique fields
	 * @var array
	 */
	protected $arrUniqueFields = array();

	/**
	 * Keys
	 * @var array
	 */
	protected $arrKeys = array();

	/**
	 * Relations
	 * @var array
	 */
	protected $arrRelations = array();

	/**
	 * SQL buffer
	 * @var array
	 */
	protected static $arrSql = array();

	/**
	 * Database table
	 * @var boolean
	 */
	protected $blnIsDbTable = false;

	/**
	 * Load or create the extract
	 *
	 * @param string $strTable The table name
	 *
	 * @throws \Exception If $strTable is empty
	 */
	protected function __construct($strTable)
	{
		if ($strTable == '')
		{
			throw new \Exception('The table name must not be empty');
		}

		parent::__construct();

		$this->strTable = $strTable;

		$strFile = System::getContainer()->getParameter('kernel.cache_dir') . '/contao/sql/' . $strTable . '.php';

		// Try to load from cache
		if (file_exists($strFile))
		{
			include $strFile;
		}
		else
		{
			$this->createExtract();
		}
	}

	/**
	 * Prevent cloning of the object (Singleton)
	 */
	final public function __clone() {}

	/**
	 * Get one object instance per table
	 *
	 * @param string $strTable The table name
	 *
	 * @return DcaExtractor The object instance
	 */
	public static function getInstance($strTable)
	{
		if (!isset(static::$arrInstances[$strTable]))
		{
			static::$arrInstances[$strTable] = new static($strTable);
		}

		return static::$arrInstances[$strTable];
	}

	/**
	 * Return the meta data as array
	 *
	 * @return array The meta data
	 */
	public function getMeta()
	{
		return $this->arrMeta;
	}

	/**
	 * Return true if there is meta data
	 *
	 * @return boolean True if there is meta data
	 */
	public function hasMeta()
	{
		return !empty($this->arrMeta);
	}

	/**
	 * Return the fields as array
	 *
	 * @return array The fields array
	 */
	public function getFields()
	{
		return $this->arrFields;
	}

	/**
	 * Return true if there are fields
	 *
	 * @return boolean True if there are fields
	 */
	public function hasFields()
	{
		return !empty($this->arrFields);
	}

	/**
	 * Return the order fields as array
	 *
	 * @return array The order fields array
	 */
	public function getOrderFields()
	{
		return $this->arrOrderFields;
	}

	/**
	 * Return true if there are order fields
	 *
	 * @return boolean True if there are order fields
	 */
	public function hasOrderFields()
	{
		return !empty($this->arrOrderFields);
	}

	/**
	 * Return an array of unique columns
	 *
	 * @return array
	 */
	public function getUniqueFields()
	{
		return $this->arrUniqueFields;
	}

	/**
	 * Return true if there are unique fields
	 *
	 * @return boolean True if there are unique fields
	 */
	public function hasUniqueFields()
	{
		return !empty($this->arrUniqueFields);
	}

	/**
	 * Return the keys as array
	 *
	 * @return array The keys array
	 */
	public function getKeys()
	{
		return $this->arrKeys;
	}

	/**
	 * Return true if there are keys
	 *
	 * @return boolean True if there are keys
	 */
	public function hasKeys()
	{
		return !empty($this->arrKeys);
	}

	/**
	 * Return the relations as array
	 *
	 * @return array The relations array
	 */
	public function getRelations()
	{
		return $this->arrRelations;
	}

	/**
	 * Return true if there are relations
	 *
	 * @return boolean True if there are relations
	 */
	public function hasRelations()
	{
		return !empty($this->arrRelations);
	}

	/**
	 * Return true if the extract relates to a database table
	 *
	 * @return boolean True if the extract relates to a database table
	 */
	public function isDbTable()
	{
		return $this->blnIsDbTable;
	}

	/**
	 * Return an array that can be used by the database installer
	 *
	 * @return array The data array
	 */
	public function getDbInstallerArray()
	{
		$return = array();

		// Fields
		foreach ($this->arrFields as $k=>$v)
		{
			if (\is_array($v))
			{
				if (!isset($v['name']))
				{
					$v['name'] = $k;
				}

				$return['SCHEMA_FIELDS'][$k] = $v;
			}
			else
			{
				$return['TABLE_FIELDS'][$k] = '`' . $k . '` ' . $v;
			}
		}

		$quote = function ($item) { return '`' . $item . '`'; };

		// Keys
		foreach ($this->arrKeys as $k=>$v)
		{
			// Handle multi-column indexes (see #5556)
			if (strpos($k, ',') !== false)
			{
				$f = array_map($quote, StringUtil::trimsplit(',', $k));
				$k = str_replace(',', '_', $k);
			}
			else
			{
				$f = array($quote($k));
			}

			if ($v == 'primary')
			{
				$k = 'PRIMARY';
				$v = 'PRIMARY KEY  (' . implode(', ', $f) . ')';
			}
			elseif ($v == 'index')
			{
				$v = 'KEY `' . $k . '` (' . implode(', ', $f) . ')';
			}
			else
			{
				$v = strtoupper($v) . ' KEY `' . $k . '` (' . implode(', ', $f) . ')';
			}

			$return['TABLE_CREATE_DEFINITIONS'][$k] = $v;
		}

		$return['TABLE_OPTIONS'] = '';

		// Options
		foreach ($this->arrMeta as $k=>$v)
		{
			if ($k == 'engine')
			{
				$return['TABLE_OPTIONS'] .= ' ENGINE=' . $v;
			}
			elseif ($k == 'charset')
			{
				$return['TABLE_OPTIONS'] .= ' DEFAULT CHARSET=' . $v;
			}
			elseif ($k == 'collate')
			{
				$return['TABLE_OPTIONS'] .= ' COLLATE ' . $v;
			}
		}

		return $return;
	}

	/**
	 * Create the extract from the DCA or the database.sql files
	 */
	protected function createExtract()
	{
		// Load the default language file (see #7202)
		if (empty($GLOBALS['TL_LANG']['MSC']))
		{
			System::loadLanguageFile('default');
		}

		// Load the data container
		if (!isset($GLOBALS['loadDataContainer'][$this->strTable]))
		{
			$this->loadDataContainer($this->strTable);
		}

		// Return if the table is not defined
		if (!isset($GLOBALS['TL_DCA'][$this->strTable]))
		{
			return;
		}

		// Return if the DC type is "File"
		if ($GLOBALS['TL_DCA'][$this->strTable]['config']['dataContainer'] == 'File')
		{
			return;
		}

		// Return if the DC type is "Folder" and the DC is not database assisted
		if ($GLOBALS['TL_DCA'][$this->strTable]['config']['dataContainer'] == 'Folder' && empty($GLOBALS['TL_DCA'][$this->strTable]['config']['databaseAssisted']))
		{
			return;
		}

		$blnFromFile = false;
		$arrRelations = array();

		// Check whether there are fields (see #4826)
		if (isset($GLOBALS['TL_DCA'][$this->strTable]['fields']))
		{
			foreach ($GLOBALS['TL_DCA'][$this->strTable]['fields'] as $field=>$config)
			{
				// Check whether all fields have an SQL definition
				if (!isset($config['sql']) && isset($config['inputType']))
				{
					$blnFromFile = true;
				}

				// Check whether there is a relation (see #6524)
				if (isset($config['relation']))
				{
					$table = substr($config['foreignKey'], 0, strrpos($config['foreignKey'], '.'));
					$arrRelations[$field] = array_merge(array('table'=>$table, 'field'=>'id'), $config['relation']);

					// Store the field delimiter if the related IDs are stored in CSV format (see #257)
					if (isset($config['eval']['csv']))
					{
						$arrRelations[$field]['delimiter'] = $config['eval']['csv'];
					}

					// Table name and field name are mandatory
					if (empty($arrRelations[$field]['table']) || empty($arrRelations[$field]['field']))
					{
						throw new \Exception('Incomplete relation defined for ' . $this->strTable . '.' . $field);
					}
				}
			}
		}

		$sql = $GLOBALS['TL_DCA'][$this->strTable]['config']['sql'] ?? array();
		$fields = $GLOBALS['TL_DCA'][$this->strTable]['fields'] ?? array();

		// Deprecated since Contao 4.0, to be removed in Contao 5.0
		if ($blnFromFile)
		{
			@trigger_error('Using database.sql files has been deprecated and will no longer work in Contao 5.0. Use a DCA file instead.', E_USER_DEPRECATED);

			if (!isset(static::$arrSql[$this->strTable]))
			{
				$arrSql = array();

				try
				{
					$files = System::getContainer()->get('contao.resource_locator')->locate('config/database.sql', null, false);
				}
				catch (\InvalidArgumentException $e)
				{
					$files = array();
				}

				foreach ($files as $file)
				{
					$arrSql = array_merge_recursive($arrSql, SqlFileParser::parse($file));
				}

				static::$arrSql = $arrSql;
			}

			$arrTable = static::$arrSql[$this->strTable];

			if (\is_array($arrTable['TABLE_OPTIONS']))
			{
				$arrTable['TABLE_OPTIONS'] = $arrTable['TABLE_OPTIONS'][0]; // see #324
			}

			list($engine, , $charset) = explode(' ', trim($arrTable['TABLE_OPTIONS']));

			if ($engine != '')
			{
				$sql['engine'] = str_replace('ENGINE=', '', $engine);
			}
			if ($charset != '')
			{
				$sql['charset'] = str_replace('CHARSET=', '', $charset);
			}

			// Fields
			if (isset($arrTable['TABLE_FIELDS']))
			{
				foreach ($arrTable['TABLE_FIELDS'] as $k=>$v)
				{
					$fields[$k]['sql'] = str_replace('`' . $k . '` ', '', $v);
				}
			}

			// Keys
			if (isset($arrTable['TABLE_CREATE_DEFINITIONS']))
			{
				foreach ($arrTable['TABLE_CREATE_DEFINITIONS'] as $strKey)
				{
					if (preg_match('/^([A-Z]+ )?KEY .+\(([^)]+)\)$/', $strKey, $arrMatches) && preg_match_all('/`([^`]+)`/', $arrMatches[2], $arrFields))
					{
						$type = trim($arrMatches[1]);
						$field = implode(',', $arrFields[1]);
						$sql['keys'][$field] = ($type != '') ? strtolower($type) : 'index';
					}
				}
			}
		}

		// Not a database table or no field information
		if (empty($sql) || empty($fields))
		{
			return;
		}

		$params = System::getContainer()->get('database_connection')->getParams();

		// Add the default engine and charset if none is given
		if (empty($sql['engine']))
		{
			$sql['engine'] = $params['defaultTableOptions']['engine'] ?? 'InnoDB';
		}
		if (empty($sql['charset']))
		{
			$sql['charset'] = $params['defaultTableOptions']['charset'] ?? 'utf8mb4';
		}
		if (empty($sql['collate']))
		{
			$sql['collate'] = $params['defaultTableOptions']['collate'] ?? 'utf8mb4_unicode_ci';
		}

		// Meta
		$this->arrMeta = array
		(
			'engine' => $sql['engine'],
			'charset' => $sql['charset'],
			'collate' => $sql['collate']
		);

		// Fields
		if (!empty($fields))
		{
			$this->arrFields = array();
			$this->arrOrderFields = array();

			foreach ($fields as $field=>$config)
			{
				if (isset($config['sql']))
				{
					$this->arrFields[$field] = $config['sql'];
				}

				// Only add order fields of binary fields (see #7785)
				if (isset($config['inputType']) && $config['inputType'] == 'fileTree' && isset($config['eval']['orderField']))
				{
					$this->arrOrderFields[] = $config['eval']['orderField'];
				}

				if (isset($config['eval']['unique']) && $config['eval']['unique'])
				{
					$this->arrUniqueFields[] = $field;
				}
			}
		}

		// Keys
		if (!empty($sql['keys']) && \is_array($sql['keys']))
		{
			$this->arrKeys = array();

			foreach ($sql['keys'] as $field=>$type)
			{
				$this->arrKeys[$field] = $type;

				if ($type == 'unique')
				{
					$this->arrUniqueFields[] = $field;
				}
			}
		}

		// Relations
		if (!empty($arrRelations))
		{
			$this->arrRelations = array();

			foreach ($arrRelations as $field=>$config)
			{
				$this->arrRelations[$field] = array();

				foreach ($config as $k=>$v)
				{
					$this->arrRelations[$field][$k] = $v;
				}
			}
		}

		$this->arrUniqueFields = array_unique($this->arrUniqueFields);
		$this->blnIsDbTable = true;
	}
}

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