diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..07889f5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +Tests export-ignore +patches export-ignore +.github export-ignore + +.gitattributes export-ignore +.gitignore export-ignore + +rector export-ignore +.php-cs-fixer.dist.php export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore + +shell.nix export-ignore diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3496821 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,133 @@ +name: CI + +on: + - pull_request + +jobs: + check-composer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: cachix/install-nix-action@v17 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Validate composer.json + run: nix-shell --pure --run project-validate-composer + + php-linting: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: + - 7.4 + - 8.0 + - 8.1 + - 8.2 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + + - name: PHP lint + run: "find *.php Classes Configuration Tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l" + + xml-linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: cachix/install-nix-action@v17 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Validate XML + run: nix-shell --pure --run project-validate-xml + + coding-guideline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: cachix/install-nix-action@v17 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Check Coding Guideline + run: nix-shell --pure --run project-coding-guideline + + code-quality: + runs-on: ubuntu-20.04 + strategy: + matrix: + include: + - php-version: '7.4' + - php-version: '8.0' + - php-version: '8.1' + - php-version: '8.2' + steps: + - uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Code Quality (by PHPStan) + run: ./vendor/bin/phpstan analyse + + tests: + runs-on: ubuntu-20.04 + strategy: + matrix: + include: + - php-version: '7.4' + - php-version: '8.0' + - php-version: '8.1' + - php-version: '8.2' + steps: + - uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + + - name: Setup MySQL + uses: mirromutth/mysql-action@v1.1 + with: + mysql version: '8' + mysql database: 'typo3' + mysql root password: 'root' + + - name: Wait for MySQL + run: | + while ! mysqladmin ping --host=127.0.0.1 --password=root --silent; do + sleep 1 + done + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: PHPUnit Tests + env: + typo3DatabaseDriver: "pdo_mysql" + typo3DatabaseName: "typo3" + typo3DatabaseHost: "127.0.0.1" + typo3DatabaseUsername: "root" + typo3DatabasePassword: "root" + run: ./vendor/bin/phpunit --testdox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b32ebe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.Build/ +/vendor/ +/composer.lock diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..80e0228 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,62 @@ +ignoreVCSIgnored(true) + ->in(realpath(__DIR__)); + +return (new \PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@DoctrineAnnotation' => true, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'braces' => ['allow_single_line_closure' => true], + 'cast_spaces' => ['space' => 'none'], + 'compact_nullable_typehint' => true, + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'dir_constant' => true, + 'function_to_constant' => ['functions' => ['get_called_class', 'get_class', 'get_class_this', 'php_sapi_name', 'phpversion', 'pi']], + 'function_typehint_space' => true, + 'lowercase_cast' => true, + 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], + 'modernize_strpos' => true, + 'modernize_types_casting' => true, + 'native_function_casing' => true, + 'new_with_braces' => true, + 'no_alias_functions' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_null_property_initialization' => true, + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_superfluous_elseif' => true, + 'no_trailing_comma_in_singleline_array' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_whitespace_in_blank_line' => true, + 'ordered_imports' => true, + 'php_unit_construct' => ['assertions' => ['assertEquals', 'assertSame', 'assertNotEquals', 'assertNotSame']], + 'php_unit_mock_short_will_return' => true, + 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], + 'phpdoc_no_access' => true, + 'phpdoc_no_empty_return' => true, + 'phpdoc_no_package' => true, + 'phpdoc_scalar' => true, + 'phpdoc_trim' => true, + 'phpdoc_types' => true, + 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], + 'return_type_declaration' => ['space_before' => 'none'], + 'single_quote' => true, + 'single_line_comment_style' => ['comment_types' => ['hash']], + 'single_trait_insert_per_statement' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'whitespace_after_comma_in_array' => true, + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + ]) + ->setFinder($finder); diff --git a/Classes/Cookie.php b/Classes/Cookie.php new file mode 100644 index 0000000..209de45 --- /dev/null +++ b/Classes/Cookie.php @@ -0,0 +1,81 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\ABTest; + +use TYPO3\CMS\Core\SingletonInterface; + +class Cookie implements SingletonInterface +{ + /** + * @var int + */ + private $requestedPageUid = 0; + + /** + * @var int + */ + private $actualPageUid = 0; + + /** + * @var int + */ + private $lifetime = 604800; + + public function setRequestedPage(int $uid): void + { + $this->requestedPageUid = $uid; + } + + public function setActualPage(int $uid): void + { + $this->actualPageUid = $uid; + } + + public function setLifetime(int $seconds): void + { + if ($seconds > 0) { + $this->lifetime = $seconds; + } + } + + public function getRequestedPage(): int + { + return $this->requestedPageUid; + } + + public function getActualPage(): int + { + return $this->actualPageUid; + } + + public function getLifetime(): int + { + return $this->lifetime; + } + + public function needsToBeSet(): bool + { + return $this->actualPageUid > 0; + } +} diff --git a/Classes/DeviceDetector/DeviceDetector.php b/Classes/DeviceDetector/DeviceDetector.php deleted file mode 100644 index d76b473..0000000 --- a/Classes/DeviceDetector/DeviceDetector.php +++ /dev/null @@ -1,263 +0,0 @@ -setUserAgent($userAgent); - } - - } - - /** - * Sets the useragent to be parsed - * - * @param string $userAgent - */ - public function setUserAgent($userAgent) - { - if ($this->userAgent != $userAgent) { - $this->reset(); - } - $this->userAgent = $userAgent; - } - - protected function reset() - { - $this->bot = null; - $this->parsed = false; - } - - - - /** - * Sets whether to discard additional bot information - * If information is discarded it's only possible check whether UA was detected as bot or not. - * (Discarding information speeds up the detection a bit) - * - * @param bool $discard - */ - public function discardBotInformation($discard = true) - { - $this->discardBotInformation = $discard; - } - - /** - * Sets whether to skip bot detection. - * It is needed if we want bots to be processed as a simple clients. So we can detect if it is mobile client, - * or desktop, or enything else. By default all this information is not retrieved for the bots. - * - * @param bool $skip - */ - public function skipBotDetection($skip = true) - { - $this->skipBotDetection = $skip; - } - - /** - * Returns if the parsed UA was identified as a Bot - * - * @see bots.yml for a list of detected bots - * - * @return bool - */ - public function isBot() - { - return !empty($this->bot); - } - - - /** - * Returns the user agent that is set to be parsed - * - * @return string - */ - public function getUserAgent() - { - return $this->userAgent; - } - - /** - * Returns the bot extracted from the parsed UA - * - * @return array - */ - public function getBot() - { - return $this->bot; - } - - /** - * Returns true, if userAgent was already parsed with parse() - * - * @return bool - */ - public function isParsed() - { - return $this->parsed; - } - - /** - * Triggers the parsing of the current user agent - * @throws \Exception - */ - public function parse() - { - if ($this->isParsed()) { - return; - } - - $this->parsed = true; - - // skip parsing for empty useragents or those not containing any letter - if (empty($this->userAgent) || !preg_match('/([a-z])/i', $this->userAgent)) { - return; - } - - $this->parseBot(); - if ($this->isBot()) { - return; - } - - } - - /** - * Parses the UA for bot information using the Bot parser - * @throws \Exception - * @return void - */ - protected function parseBot() - { - if ($this->skipBotDetection) { - $this->bot = false; - return; - } - - $botParser = new Bot(); - $botParser->setUserAgent($this->getUserAgent()); - $botParser->setYamlParser($this->getYamlParser()); - if ($this->discardBotInformation) { - $botParser->discardDetails(); - } - $this->bot = $botParser->parse(); - - } - - - - protected function matchUserAgent($regex) - { - $regex = '/(?:^|[^A-Z_-])(?:' . str_replace('/', '\/', $regex) . ')/i'; - - if (preg_match($regex, $this->userAgent, $matches)) { - return $matches; - } - - return false; - } - - - - /** - * Sets the Yaml Parser class - * - * @param YamlParser - * @throws \Exception - */ - public function setYamlParser($yamlParser) - { - if ($yamlParser instanceof YamlParser) { - $this->yamlParser = $yamlParser; - return; - } - - throw new \Exception('Yaml Parser not supported'); - } - - /** - * Returns Yaml Parser object - * - * @return YamlParser - */ - public function getYamlParser() - { - if (!empty($this->yamlParser)) { - return $this->yamlParser; - } - - return new Spyc(); - } -} diff --git a/Classes/DeviceDetector/Parser/Bot.php b/Classes/DeviceDetector/Parser/Bot.php deleted file mode 100644 index a2da5b6..0000000 --- a/Classes/DeviceDetector/Parser/Bot.php +++ /dev/null @@ -1,72 +0,0 @@ -discardDetails = true; - } - - /** - * Parses the current UA and checks whether it contains bot information - * - * @see bots.yml for list of detected bots - * - * Step 1: Build a big regex containing all regexes and match UA against it - * -> If no matches found: return - * -> Otherwise: - * Step 2: Walk through the list of regexes in bots.yml and try to match every one - * -> Return the matched data - * - * If $discardDetails is set to TRUE, the Step 2 will be skipped - * $bot will be set to TRUE instead - * - * NOTE: Doing the big match before matching every single regex speeds up the detection - */ - public function parse() - { - $result = null; - - if ($this->preMatchOverall()) { - foreach ($this->getRegexes() as $regex) { - $matches = $this->matchUserAgent($regex['regex']); - - if ($matches) { - if ($this->discardDetails) { - $result = true; - break; - } - - unset($regex['regex']); - $result = $regex; - break; - } - } - } - - return $result; - } -} diff --git a/Classes/DeviceDetector/Parser/ParserAbstract.php b/Classes/DeviceDetector/Parser/ParserAbstract.php deleted file mode 100644 index 8fa98b5..0000000 --- a/Classes/DeviceDetector/Parser/ParserAbstract.php +++ /dev/null @@ -1,269 +0,0 @@ -setUserAgent($ua); - } - - /** - * Set how DeviceDetector should return versions - * @param int|null $type Any of the VERSION_TRUNCATION_* constants - */ - public static function setVersionTruncation($type) - { - if (in_array($type, array(self::VERSION_TRUNCATION_BUILD, - self::VERSION_TRUNCATION_NONE, - self::VERSION_TRUNCATION_MAJOR, - self::VERSION_TRUNCATION_MINOR, - self::VERSION_TRUNCATION_PATCH))) { - self::$maxMinorParts = $type; - } - } - - /** - * Sets the user agent to parse - * - * @param string $ua user agent - */ - public function setUserAgent($ua) - { - $this->userAgent = $ua; - } - - /** - * Returns the internal name of the parser - * - * @return string - */ - public function getName() - { - return $this->parserName; - } - - /** - * Returns the result of the parsed yml file defined in $fixtureFile - * - * @return array - */ - protected function getRegexes() - { - if (empty($this->regexList)) { - $this->regexList = $this->getYamlParser()->parse( - file_get_contents(GeneralUtility::getFileAbsFileName('EXT:abtest2/Configuration/YAML/'.$this->fixtureFile)) - ); - } - return $this->regexList; - } - - /** - * @return string - */ - protected function getRegexesDirectory() - { - return dirname(__DIR__); - } - - /** - * Matches the useragent against the given regex - * - * @param $regex - * @return array|bool - */ - protected function matchUserAgent($regex) - { - // only match if useragent begins with given regex or there is no letter before it - $regex = '/(?:^|[^A-Z0-9\-_]|[^A-Z0-9\-]_|sprd-)(?:' . str_replace('/', '\/', $regex) . ')/i'; - - if (preg_match($regex, $this->userAgent, $matches)) { - return $matches; - } - - return false; - } - - /** - * @param string $item - * @param array $matches - * @return string type - */ - protected function buildByMatch($item, $matches) - { - for ($nb=1;$nb<=3;$nb++) { - if (strpos($item, '$' . $nb) === false) { - continue; - } - - $replace = isset($matches[$nb]) ? $matches[$nb] : ''; - $item = trim(str_replace('$' . $nb, $replace, $item)); - } - return $item; - } - - /** - * Builds the version with the given $versionString and $matches - * - * Example: - * $versionString = 'v$2' - * $matches = array('version_1_0_1', '1_0_1') - * return value would be v1.0.1 - * - * @param $versionString - * @param $matches - * @return mixed|string - */ - protected function buildVersion($versionString, $matches) - { - $versionString = $this->buildByMatch($versionString, $matches); - $versionString = str_replace('_', '.', $versionString); - if (null !== self::$maxMinorParts && substr_count($versionString, '.') > self::$maxMinorParts) { - $versionParts = explode('.', $versionString); - $versionParts = array_slice($versionParts, 0, 1+self::$maxMinorParts); - $versionString = implode('.', $versionParts); - } - return trim($versionString, ' .'); - } - - /** - * Tests the useragent against a combination of all regexes - * - * All regexes returned by getRegexes() will be reversed and concated with '|' - * Afterwards the big regex will be tested against the user agent - * - * Method can be used to speed up detections by making a big check before doing checks for every single regex - * - * @return bool - */ - protected function preMatchOverall() - { - $regexes = $this->getRegexes(); - - static $overAllMatch; - - if (empty($overAllMatch)) { - // reverse all regexes, so we have the generic one first, which already matches most patterns - $overAllMatch = array_reduce(array_reverse($regexes), function ($val1, $val2) { - if (!empty($val1)) { - return $val1.'|'.$val2['regex']; - } else { - return $val2['regex']; - } - }); - } - - return $this->matchUserAgent($overAllMatch); - } - - - - /** - * Sets the YamlParser class - * - * @param Parser - */ - public function setYamlParser($yamlParser) - { - $this->yamlParser = $yamlParser; - } - - /** - * Returns Parser object - * - * @return Parser - */ - public function getYamlParser() - { - return $this->yamlParser; - } -} diff --git a/Classes/DeviceDetector/Yaml/Inline.php b/Classes/DeviceDetector/Yaml/Inline.php deleted file mode 100644 index 988ad3b..0000000 --- a/Classes/DeviceDetector/Yaml/Inline.php +++ /dev/null @@ -1,567 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace WapplerSystems\ABTest2\DeviceDetector\Yaml; - -use Symfony\Component\Yaml\Exception\DumpException; - -/** - * Inline implements a YAML parser/dumper for the YAML inline syntax. - * - * @author Fabien Potencier - */ -class Inline -{ - const REGEX_QUOTED_STRING = '(?:"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\']*(?:\'\'[^\']*)*)\')'; - - private static $exceptionOnInvalidType = false; - private static $objectSupport = false; - private static $objectForMap = false; - - /** - * Converts a YAML string to a PHP array. - * - * @param string $value A YAML string - * @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise - * @param bool $objectSupport true if object support is enabled, false otherwise - * @param bool $objectForMap true if maps should return a stdClass instead of array() - * @param array $references Mapping of variable names to values - * - * @return array A PHP array representing the YAML string - * - * @throws ParseException - */ - public static function parse($value, $exceptionOnInvalidType = false, $objectSupport = false, $objectForMap = false, $references = array()) - { - self::$exceptionOnInvalidType = $exceptionOnInvalidType; - self::$objectSupport = $objectSupport; - self::$objectForMap = $objectForMap; - - $value = trim($value); - - if ('' === $value) { - return ''; - } - - if (2 /* MB_OVERLOAD_STRING */ & (int) ini_get('mbstring.func_overload')) { - $mbEncoding = mb_internal_encoding(); - mb_internal_encoding('ASCII'); - } - - $i = 0; - switch ($value[0]) { - case '[': - $result = self::parseSequence($value, $i, $references); - ++$i; - break; - case '{': - $result = self::parseMapping($value, $i, $references); - ++$i; - break; - default: - $result = self::parseScalar($value, null, array('"', "'"), $i, true, $references); - } - - // some comments are allowed at the end - if (preg_replace('/\s+#.*$/A', '', substr($value, $i))) { - throw new ParseException(sprintf('Unexpected characters near "%s".', substr($value, $i))); - } - - if (isset($mbEncoding)) { - mb_internal_encoding($mbEncoding); - } - - return $result; - } - - /** - * Dumps a given PHP variable to a YAML string. - * - * @param mixed $value The PHP variable to convert - * @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise - * @param bool $objectSupport true if object support is enabled, false otherwise - * - * @return string The YAML string representing the PHP array - * - * @throws DumpException When trying to dump PHP resource - */ - public static function dump($value, $exceptionOnInvalidType = false, $objectSupport = false) - { - switch (true) { - case is_resource($value): - if ($exceptionOnInvalidType) { - throw new DumpException(sprintf('Unable to dump PHP resources in a YAML file ("%s").', get_resource_type($value))); - } - - return 'null'; - case is_object($value): - if ($objectSupport) { - return '!php/object:'.serialize($value); - } - - if ($exceptionOnInvalidType) { - throw new DumpException('Object support when dumping a YAML file has been disabled.'); - } - - return 'null'; - case is_array($value): - return self::dumpArray($value, $exceptionOnInvalidType, $objectSupport); - case null === $value: - return 'null'; - case true === $value: - return 'true'; - case false === $value: - return 'false'; - case ctype_digit($value): - return is_string($value) ? "'$value'" : (int) $value; - case is_numeric($value): - $locale = setlocale(LC_NUMERIC, 0); - if (false !== $locale) { - setlocale(LC_NUMERIC, 'C'); - } - if (is_float($value)) { - $repr = (string) $value; - if (is_infinite($value)) { - $repr = str_ireplace('INF', '.Inf', $repr); - } elseif (floor($value) == $value && $repr == $value) { - // Preserve float data type since storing a whole number will result in integer value. - $repr = '!!float '.$repr; - } - } else { - $repr = is_string($value) ? "'$value'" : (string) $value; - } - if (false !== $locale) { - setlocale(LC_NUMERIC, $locale); - } - - return $repr; - case '' == $value: - return "''"; - case Escaper::requiresDoubleQuoting($value): - return Escaper::escapeWithDoubleQuotes($value); - case Escaper::requiresSingleQuoting($value): - case preg_match(self::getHexRegex(), $value): - case preg_match(self::getTimestampRegex(), $value): - return Escaper::escapeWithSingleQuotes($value); - default: - return $value; - } - } - - /** - * Dumps a PHP array to a YAML string. - * - * @param array $value The PHP array to dump - * @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise - * @param bool $objectSupport true if object support is enabled, false otherwise - * - * @return string The YAML string representing the PHP array - */ - private static function dumpArray($value, $exceptionOnInvalidType, $objectSupport) - { - // array - $keys = array_keys($value); - $keysCount = count($keys); - if ((1 === $keysCount && '0' == $keys[0]) - || ($keysCount > 1 && array_reduce($keys, function ($v, $w) { return (int) $v + $w; }, 0) === $keysCount * ($keysCount - 1) / 2) - ) { - $output = array(); - foreach ($value as $val) { - $output[] = self::dump($val, $exceptionOnInvalidType, $objectSupport); - } - - return sprintf('[%s]', implode(', ', $output)); - } - - // mapping - $output = array(); - foreach ($value as $key => $val) { - $output[] = sprintf('%s: %s', self::dump($key, $exceptionOnInvalidType, $objectSupport), self::dump($val, $exceptionOnInvalidType, $objectSupport)); - } - - return sprintf('{ %s }', implode(', ', $output)); - } - - /** - * Parses a scalar to a YAML string. - * - * @param string $scalar - * @param string $delimiters - * @param array $stringDelimiters - * @param int &$i - * @param bool $evaluate - * @param array $references - * - * @return string A YAML string - * - * @throws ParseException When malformed inline YAML string is parsed - * - * @internal - */ - public static function parseScalar($scalar, $delimiters = null, $stringDelimiters = array('"', "'"), &$i = 0, $evaluate = true, $references = array()) - { - if (in_array($scalar[$i], $stringDelimiters)) { - // quoted scalar - $output = self::parseQuotedScalar($scalar, $i); - - if (null !== $delimiters) { - $tmp = ltrim(substr($scalar, $i), ' '); - if (!in_array($tmp[0], $delimiters)) { - throw new ParseException(sprintf('Unexpected characters (%s).', substr($scalar, $i))); - } - } - } else { - // "normal" string - if (!$delimiters) { - $output = substr($scalar, $i); - $i += strlen($output); - - // remove comments - if (preg_match('/[ \t]+#/', $output, $match, PREG_OFFSET_CAPTURE)) { - $output = substr($output, 0, $match[0][1]); - } - } elseif (preg_match('/^(.+?)('.implode('|', $delimiters).')/', substr($scalar, $i), $match)) { - $output = $match[1]; - $i += strlen($output); - } else { - throw new ParseException(sprintf('Malformed inline YAML string (%s).', $scalar)); - } - - // a non-quoted string cannot start with @ or ` (reserved) nor with a scalar indicator (| or >) - if ($output && ('@' === $output[0] || '`' === $output[0] || '|' === $output[0] || '>' === $output[0])) { - throw new ParseException(sprintf('The reserved indicator "%s" cannot start a plain scalar; you need to quote the scalar.', $output[0])); - } - - if ($evaluate) { - $output = self::evaluateScalar($output, $references); - } - } - - return $output; - } - - /** - * Parses a quoted scalar to YAML. - * - * @param string $scalar - * @param int &$i - * - * @return string A YAML string - * - * @throws ParseException When malformed inline YAML string is parsed - */ - private static function parseQuotedScalar($scalar, &$i) - { - if (!preg_match('/'.self::REGEX_QUOTED_STRING.'/Au', substr($scalar, $i), $match)) { - throw new ParseException(sprintf('Malformed inline YAML string (%s).', substr($scalar, $i))); - } - - $output = substr($match[0], 1, strlen($match[0]) - 2); - - $unescaper = new Unescaper(); - if ('"' == $scalar[$i]) { - $output = $unescaper->unescapeDoubleQuotedString($output); - } else { - $output = $unescaper->unescapeSingleQuotedString($output); - } - - $i += strlen($match[0]); - - return $output; - } - - /** - * Parses a sequence to a YAML string. - * - * @param string $sequence - * @param int &$i - * @param array $references - * - * @return string A YAML string - * - * @throws ParseException When malformed inline YAML string is parsed - */ - private static function parseSequence($sequence, &$i = 0, $references = array()) - { - $output = array(); - $len = strlen($sequence); - ++$i; - - // [foo, bar, ...] - while ($i < $len) { - switch ($sequence[$i]) { - case '[': - // nested sequence - $output[] = self::parseSequence($sequence, $i, $references); - break; - case '{': - // nested mapping - $output[] = self::parseMapping($sequence, $i, $references); - break; - case ']': - return $output; - case ',': - case ' ': - break; - default: - $isQuoted = in_array($sequence[$i], array('"', "'")); - $value = self::parseScalar($sequence, array(',', ']'), array('"', "'"), $i, true, $references); - - // the value can be an array if a reference has been resolved to an array var - if (!is_array($value) && !$isQuoted && false !== strpos($value, ': ')) { - // embedded mapping? - try { - $pos = 0; - $value = self::parseMapping('{'.$value.'}', $pos, $references); - } catch (\InvalidArgumentException $e) { - // no, it's not - } - } - - $output[] = $value; - - --$i; - } - - ++$i; - } - - throw new ParseException(sprintf('Malformed inline YAML string %s', $sequence)); - } - - /** - * Parses a mapping to a YAML string. - * - * @param string $mapping - * @param int &$i - * @param array $references - * - * @return string A YAML string - * - * @throws ParseException When malformed inline YAML string is parsed - */ - private static function parseMapping($mapping, &$i = 0, $references = array()) - { - $output = array(); - $len = strlen($mapping); - ++$i; - - // {foo: bar, bar:foo, ...} - while ($i < $len) { - switch ($mapping[$i]) { - case ' ': - case ',': - ++$i; - continue 2; - case '}': - if (self::$objectForMap) { - return (object) $output; - } - - return $output; - } - - // key - $key = self::parseScalar($mapping, array(':', ' '), array('"', "'"), $i, false); - - // value - $done = false; - - while ($i < $len) { - switch ($mapping[$i]) { - case '[': - // nested sequence - $value = self::parseSequence($mapping, $i, $references); - // Spec: Keys MUST be unique; first one wins. - // Parser cannot abort this mapping earlier, since lines - // are processed sequentially. - if (!isset($output[$key])) { - $output[$key] = $value; - } - $done = true; - break; - case '{': - // nested mapping - $value = self::parseMapping($mapping, $i, $references); - // Spec: Keys MUST be unique; first one wins. - // Parser cannot abort this mapping earlier, since lines - // are processed sequentially. - if (!isset($output[$key])) { - $output[$key] = $value; - } - $done = true; - break; - case ':': - case ' ': - break; - default: - $value = self::parseScalar($mapping, array(',', '}'), array('"', "'"), $i, true, $references); - // Spec: Keys MUST be unique; first one wins. - // Parser cannot abort this mapping earlier, since lines - // are processed sequentially. - if (!isset($output[$key])) { - $output[$key] = $value; - } - $done = true; - --$i; - } - - ++$i; - - if ($done) { - continue 2; - } - } - } - - throw new ParseException(sprintf('Malformed inline YAML string %s', $mapping)); - } - - /** - * Evaluates scalars and replaces magic values. - * - * @param string $scalar - * @param array $references - * - * @return string A YAML string - * - * @throws ParseException when object parsing support was disabled and the parser detected a PHP object or when a reference could not be resolved - */ - private static function evaluateScalar($scalar, $references = array()) - { - $scalar = trim($scalar); - $scalarLower = strtolower($scalar); - - if (0 === strpos($scalar, '*')) { - if (false !== $pos = strpos($scalar, '#')) { - $value = substr($scalar, 1, $pos - 2); - } else { - $value = substr($scalar, 1); - } - - // an unquoted * - if (false === $value || '' === $value) { - throw new ParseException('A reference must contain at least one character.'); - } - - if (!array_key_exists($value, $references)) { - throw new ParseException(sprintf('Reference "%s" does not exist.', $value)); - } - - return $references[$value]; - } - - switch (true) { - case 'null' === $scalarLower: - case '' === $scalar: - case '~' === $scalar: - return; - case 'true' === $scalarLower: - return true; - case 'false' === $scalarLower: - return false; - // Optimise for returning strings. - case $scalar[0] === '+' || $scalar[0] === '-' || $scalar[0] === '.' || $scalar[0] === '!' || is_numeric($scalar[0]): - switch (true) { - case 0 === strpos($scalar, '!str'): - return (string) substr($scalar, 5); - case 0 === strpos($scalar, '! '): - return (int) self::parseScalar(substr($scalar, 2)); - case 0 === strpos($scalar, '!php/object:'): - if (self::$objectSupport) { - return unserialize(substr($scalar, 12)); - } - - if (self::$exceptionOnInvalidType) { - throw new ParseException('Object support when parsing a YAML file has been disabled.'); - } - - return; - case 0 === strpos($scalar, '!!php/object:'): - if (self::$objectSupport) { - return unserialize(substr($scalar, 13)); - } - - if (self::$exceptionOnInvalidType) { - throw new ParseException('Object support when parsing a YAML file has been disabled.'); - } - - return; - case 0 === strpos($scalar, '!!float '): - return (float) substr($scalar, 8); - case ctype_digit($scalar): - $raw = $scalar; - $cast = (int) $scalar; - - return '0' == $scalar[0] ? octdec($scalar) : (((string) $raw == (string) $cast) ? $cast : $raw); - case '-' === $scalar[0] && ctype_digit(substr($scalar, 1)): - $raw = $scalar; - $cast = (int) $scalar; - - return '0' == $scalar[1] ? octdec($scalar) : (((string) $raw === (string) $cast) ? $cast : $raw); - case is_numeric($scalar): - case preg_match(self::getHexRegex(), $scalar): - return '0x' === $scalar[0].$scalar[1] ? hexdec($scalar) : (float) $scalar; - case '.inf' === $scalarLower: - case '.nan' === $scalarLower: - return -log(0); - case '-.inf' === $scalarLower: - return log(0); - case preg_match('/^(-|\+)?[0-9,]+(\.[0-9]+)?$/', $scalar): - return (float) str_replace(',', '', $scalar); - case preg_match(self::getTimestampRegex(), $scalar): - $timeZone = date_default_timezone_get(); - date_default_timezone_set('UTC'); - $time = strtotime($scalar); - date_default_timezone_set($timeZone); - - return $time; - } - default: - return (string) $scalar; - } - } - - /** - * Gets a regex that matches a YAML date. - * - * @return string The regular expression - * - * @see http://www.yaml.org/spec/1.2/spec.html#id2761573 - */ - private static function getTimestampRegex() - { - return <<[0-9][0-9][0-9][0-9]) - -(?P[0-9][0-9]?) - -(?P[0-9][0-9]?) - (?:(?:[Tt]|[ \t]+) - (?P[0-9][0-9]?) - :(?P[0-9][0-9]) - :(?P[0-9][0-9]) - (?:\.(?P[0-9]*))? - (?:[ \t]*(?PZ|(?P[-+])(?P[0-9][0-9]?) - (?::(?P[0-9][0-9]))?))?)? - $~x -EOF; - } - - /** - * Gets a regex that matches a YAML number in hexadecimal notation. - * - * @return string - */ - private static function getHexRegex() - { - return '~^0x[0-9a-f]++$~i'; - } -} diff --git a/Classes/DeviceDetector/Yaml/ParseException.php b/Classes/DeviceDetector/Yaml/ParseException.php deleted file mode 100644 index 8a230c3..0000000 --- a/Classes/DeviceDetector/Yaml/ParseException.php +++ /dev/null @@ -1,141 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace WapplerSystems\ABTest2\DeviceDetector\Yaml; - -/** - * Exception class thrown when an error occurs during parsing. - * - * @author Fabien Potencier - */ -class ParseException extends \RuntimeException -{ - private $parsedFile; - private $parsedLine; - private $snippet; - private $rawMessage; - - /** - * Constructor. - * - * @param string $message The error message - * @param int $parsedLine The line where the error occurred - * @param int $snippet The snippet of code near the problem - * @param string $parsedFile The file name where the error occurred - * @param \Exception $previous The previous exception - */ - public function __construct($message, $parsedLine = -1, $snippet = null, $parsedFile = null, \Exception $previous = null) - { - $this->parsedFile = $parsedFile; - $this->parsedLine = $parsedLine; - $this->snippet = $snippet; - $this->rawMessage = $message; - - $this->updateRepr(); - - parent::__construct($this->message, 0, $previous); - } - - /** - * Gets the snippet of code near the error. - * - * @return string The snippet of code - */ - public function getSnippet() - { - return $this->snippet; - } - - /** - * Sets the snippet of code near the error. - * - * @param string $snippet The code snippet - */ - public function setSnippet($snippet) - { - $this->snippet = $snippet; - - $this->updateRepr(); - } - - /** - * Gets the filename where the error occurred. - * - * This method returns null if a string is parsed. - * - * @return string The filename - */ - public function getParsedFile() - { - return $this->parsedFile; - } - - /** - * Sets the filename where the error occurred. - * - * @param string $parsedFile The filename - */ - public function setParsedFile($parsedFile) - { - $this->parsedFile = $parsedFile; - - $this->updateRepr(); - } - - /** - * Gets the line where the error occurred. - * - * @return int The file line - */ - public function getParsedLine() - { - return $this->parsedLine; - } - - /** - * Sets the line where the error occurred. - * - * @param int $parsedLine The file line - */ - public function setParsedLine($parsedLine) - { - $this->parsedLine = $parsedLine; - - $this->updateRepr(); - } - - private function updateRepr() - { - $this->message = $this->rawMessage; - - $dot = false; - if ('.' === substr($this->message, -1)) { - $this->message = substr($this->message, 0, -1); - $dot = true; - } - - if (null !== $this->parsedFile) { - $this->message .= sprintf(' in %s', json_encode($this->parsedFile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); - } - - if ($this->parsedLine >= 0) { - $this->message .= sprintf(' at line %d', $this->parsedLine); - } - - if ($this->snippet) { - $this->message .= sprintf(' (near "%s")', $this->snippet); - } - - if ($dot) { - $this->message .= '.'; - } - } -} diff --git a/Classes/DeviceDetector/Yaml/Parser.php b/Classes/DeviceDetector/Yaml/Parser.php deleted file mode 100644 index 304d47e..0000000 --- a/Classes/DeviceDetector/Yaml/Parser.php +++ /dev/null @@ -1,777 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace WapplerSystems\ABTest2\DeviceDetector\Yaml; - - -/** - * Parser parses YAML strings to convert them to PHP arrays. - * - * @author Fabien Potencier - */ -class Parser -{ - const BLOCK_SCALAR_HEADER_PATTERN = '(?P\||>)(?P\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P +#.*)?'; - - private $offset = 0; - private $lines = array(); - private $currentLineNb = -1; - private $currentLine = ''; - private $refs = array(); - - /** - * Constructor. - * - * @param int $offset The offset of YAML document (used for line numbers in error messages) - */ - public function __construct($offset = 0) - { - $this->offset = $offset; - } - - /** - * Parses a YAML string to a PHP value. - * - * @param string $value A YAML string - * @param bool $exceptionOnInvalidType true if an exception must be thrown on invalid types (a PHP resource or object), false otherwise - * @param bool $objectSupport true if object support is enabled, false otherwise - * @param bool $objectForMap true if maps should return a stdClass instead of array() - * - * @return mixed A PHP value - * - * @throws ParseException If the YAML is not valid - */ - public function parse($value, $exceptionOnInvalidType = false, $objectSupport = false, $objectForMap = false) - { - if (!preg_match('//u', $value)) { - throw new ParseException('The YAML value does not appear to be valid UTF-8.'); - } - $this->currentLineNb = -1; - $this->currentLine = ''; - $value = $this->cleanup($value); - $this->lines = explode("\n", $value); - - if (2 /* MB_OVERLOAD_STRING */ & (int) ini_get('mbstring.func_overload')) { - $mbEncoding = mb_internal_encoding(); - mb_internal_encoding('UTF-8'); - } - - $data = array(); - $context = null; - $allowOverwrite = false; - while ($this->moveToNextLine()) { - if ($this->isCurrentLineEmpty()) { - continue; - } - - // tab? - if ("\t" === $this->currentLine[0]) { - throw new ParseException('A YAML file cannot contain tabs as indentation.', $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - - $isRef = $mergeNode = false; - if (preg_match('#^\-((?P\s+)(?P.+?))?\s*$#u', $this->currentLine, $values)) { - if ($context && 'mapping' == $context) { - throw new ParseException('You cannot define a sequence item when in a mapping'); - } - $context = 'sequence'; - - if (isset($values['value']) && preg_match('#^&(?P[^ ]+) *(?P.*)#u', $values['value'], $matches)) { - $isRef = $matches['ref']; - $values['value'] = $matches['value']; - } - - // array - if (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) { - $c = $this->getRealCurrentLineNb() + 1; - $parser = new self($c); - $parser->refs = &$this->refs; - $data[] = $parser->parse($this->getNextEmbedBlock(null, true), $exceptionOnInvalidType, $objectSupport, $objectForMap); - } else { - if (isset($values['leadspaces']) - && preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\{\[].*?) *\:(\s+(?P.+?))?\s*$#u', $values['value'], $matches) - ) { - // this is a compact notation element, add to next block and parse - $c = $this->getRealCurrentLineNb(); - $parser = new self($c); - $parser->refs = &$this->refs; - - $block = $values['value']; - if ($this->isNextLineIndented()) { - $block .= "\n".$this->getNextEmbedBlock($this->getCurrentLineIndentation() + strlen($values['leadspaces']) + 1); - } - - $data[] = $parser->parse($block, $exceptionOnInvalidType, $objectSupport, $objectForMap); - } else { - $data[] = $this->parseValue($values['value'], $exceptionOnInvalidType, $objectSupport, $objectForMap, $context); - } - } - if ($isRef) { - $this->refs[$isRef] = end($data); - } - } elseif (preg_match('#^(?P'.Inline::REGEX_QUOTED_STRING.'|[^ \'"\[\{].*?) *\:(\s+(?P.+?))?\s*$#u', $this->currentLine, $values) && (false === strpos($values['key'], ' #') || in_array($values['key'][0], array('"', "'")))) { - if ($context && 'sequence' == $context) { - throw new ParseException('You cannot define a mapping item when in a sequence'); - } - $context = 'mapping'; - - // force correct settings - Inline::parse(null, $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs); - try { - $key = Inline::parseScalar($values['key']); - } catch (ParseException $e) { - $e->setParsedLine($this->getRealCurrentLineNb() + 1); - $e->setSnippet($this->currentLine); - - throw $e; - } - - // Convert float keys to strings, to avoid being converted to integers by PHP - if (is_float($key)) { - $key = (string) $key; - } - - if ('<<' === $key) { - $mergeNode = true; - $allowOverwrite = true; - if (isset($values['value']) && 0 === strpos($values['value'], '*')) { - $refName = substr($values['value'], 1); - if (!array_key_exists($refName, $this->refs)) { - throw new ParseException(sprintf('Reference "%s" does not exist.', $refName), $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - - $refValue = $this->refs[$refName]; - - if (!is_array($refValue)) { - throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - - foreach ($refValue as $key => $value) { - if (!isset($data[$key])) { - $data[$key] = $value; - } - } - } else { - if (isset($values['value']) && $values['value'] !== '') { - $value = $values['value']; - } else { - $value = $this->getNextEmbedBlock(); - } - $c = $this->getRealCurrentLineNb() + 1; - $parser = new self($c); - $parser->refs = &$this->refs; - $parsed = $parser->parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap); - - if (!is_array($parsed)) { - throw new ParseException('YAML merge keys used with a scalar value instead of an array.', $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - - if (isset($parsed[0])) { - // If the value associated with the merge key is a sequence, then this sequence is expected to contain mapping nodes - // and each of these nodes is merged in turn according to its order in the sequence. Keys in mapping nodes earlier - // in the sequence override keys specified in later mapping nodes. - foreach ($parsed as $parsedItem) { - if (!is_array($parsedItem)) { - throw new ParseException('Merge items must be arrays.', $this->getRealCurrentLineNb() + 1, $parsedItem); - } - - foreach ($parsedItem as $key => $value) { - if (!isset($data[$key])) { - $data[$key] = $value; - } - } - } - } else { - // If the value associated with the key is a single mapping node, each of its key/value pairs is inserted into the - // current mapping, unless the key already exists in it. - foreach ($parsed as $key => $value) { - if (!isset($data[$key])) { - $data[$key] = $value; - } - } - } - } - } elseif (isset($values['value']) && preg_match('#^&(?P[^ ]+) *(?P.*)#u', $values['value'], $matches)) { - $isRef = $matches['ref']; - $values['value'] = $matches['value']; - } - - if ($mergeNode) { - // Merge keys - } elseif (!isset($values['value']) || '' == trim($values['value'], ' ') || 0 === strpos(ltrim($values['value'], ' '), '#')) { - // hash - // if next line is less indented or equal, then it means that the current value is null - if (!$this->isNextLineIndented() && !$this->isNextLineUnIndentedCollection()) { - // Spec: Keys MUST be unique; first one wins. - // But overwriting is allowed when a merge node is used in current block. - if ($allowOverwrite || !isset($data[$key])) { - $data[$key] = null; - } - } else { - $c = $this->getRealCurrentLineNb() + 1; - $parser = new self($c); - $parser->refs = &$this->refs; - $value = $parser->parse($this->getNextEmbedBlock(), $exceptionOnInvalidType, $objectSupport, $objectForMap); - // Spec: Keys MUST be unique; first one wins. - // But overwriting is allowed when a merge node is used in current block. - if ($allowOverwrite || !isset($data[$key])) { - $data[$key] = $value; - } - } - } else { - $value = $this->parseValue($values['value'], $exceptionOnInvalidType, $objectSupport, $objectForMap, $context); - // Spec: Keys MUST be unique; first one wins. - // But overwriting is allowed when a merge node is used in current block. - if ($allowOverwrite || !isset($data[$key])) { - $data[$key] = $value; - } - } - if ($isRef) { - $this->refs[$isRef] = $data[$key]; - } - } else { - // multiple documents are not supported - if ('---' === $this->currentLine) { - throw new ParseException('Multiple documents are not supported.'); - } - - // 1-liner optionally followed by newline(s) - if (is_string($value) && $this->lines[0] === trim($value)) { - try { - $value = Inline::parse($this->lines[0], $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs); - } catch (ParseException $e) { - $e->setParsedLine($this->getRealCurrentLineNb() + 1); - $e->setSnippet($this->currentLine); - - throw $e; - } - - if (is_array($value)) { - $first = reset($value); - if (is_string($first) && 0 === strpos($first, '*')) { - $data = array(); - foreach ($value as $alias) { - $data[] = $this->refs[substr($alias, 1)]; - } - $value = $data; - } - } - - if (isset($mbEncoding)) { - mb_internal_encoding($mbEncoding); - } - - return $value; - } - - switch (preg_last_error()) { - case PREG_INTERNAL_ERROR: - $error = 'Internal PCRE error.'; - break; - case PREG_BACKTRACK_LIMIT_ERROR: - $error = 'pcre.backtrack_limit reached.'; - break; - case PREG_RECURSION_LIMIT_ERROR: - $error = 'pcre.recursion_limit reached.'; - break; - case PREG_BAD_UTF8_ERROR: - $error = 'Malformed UTF-8 data.'; - break; - case PREG_BAD_UTF8_OFFSET_ERROR: - $error = 'Offset doesn\'t correspond to the begin of a valid UTF-8 code point.'; - break; - default: - $error = 'Unable to parse.'; - } - - throw new ParseException($error, $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - } - - if (isset($mbEncoding)) { - mb_internal_encoding($mbEncoding); - } - - if ($objectForMap && !is_object($data) && 'mapping' === $context) { - $object = new \stdClass(); - - foreach ($data as $key => $value) { - $object->$key = $value; - } - - $data = $object; - } - - return empty($data) ? null : $data; - } - - /** - * Returns the current line number (takes the offset into account). - * - * @return int The current line number - */ - private function getRealCurrentLineNb() - { - return $this->currentLineNb + $this->offset; - } - - /** - * Returns the current line indentation. - * - * @return int The current line indentation - */ - private function getCurrentLineIndentation() - { - return strlen($this->currentLine) - strlen(ltrim($this->currentLine, ' ')); - } - - /** - * Returns the next embed block of YAML. - * - * @param int $indentation The indent level at which the block is to be read, or null for default - * @param bool $inSequence True if the enclosing data structure is a sequence - * - * @return string A YAML string - * - * @throws ParseException When indentation problem are detected - */ - private function getNextEmbedBlock($indentation = null, $inSequence = false) - { - $oldLineIndentation = $this->getCurrentLineIndentation(); - $blockScalarIndentations = array(); - - if ($this->isBlockScalarHeader()) { - $blockScalarIndentations[] = $this->getCurrentLineIndentation(); - } - - if (!$this->moveToNextLine()) { - return; - } - - if (null === $indentation) { - $newIndent = $this->getCurrentLineIndentation(); - - $unindentedEmbedBlock = $this->isStringUnIndentedCollectionItem(); - - if (!$this->isCurrentLineEmpty() && 0 === $newIndent && !$unindentedEmbedBlock) { - throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - } else { - $newIndent = $indentation; - } - - $data = array(); - if ($this->getCurrentLineIndentation() >= $newIndent) { - $data[] = substr($this->currentLine, $newIndent); - } else { - $this->moveToPreviousLine(); - - return; - } - - if ($inSequence && $oldLineIndentation === $newIndent && isset($data[0][0]) && '-' === $data[0][0]) { - // the previous line contained a dash but no item content, this line is a sequence item with the same indentation - // and therefore no nested list or mapping - $this->moveToPreviousLine(); - - return; - } - - $isItUnindentedCollection = $this->isStringUnIndentedCollectionItem(); - - if (empty($blockScalarIndentations) && $this->isBlockScalarHeader()) { - $blockScalarIndentations[] = $this->getCurrentLineIndentation(); - } - - $previousLineIndentation = $this->getCurrentLineIndentation(); - - while ($this->moveToNextLine()) { - $indent = $this->getCurrentLineIndentation(); - - // terminate all block scalars that are more indented than the current line - if (!empty($blockScalarIndentations) && $indent < $previousLineIndentation && trim($this->currentLine) !== '') { - foreach ($blockScalarIndentations as $key => $blockScalarIndentation) { - if ($blockScalarIndentation >= $this->getCurrentLineIndentation()) { - unset($blockScalarIndentations[$key]); - } - } - } - - if (empty($blockScalarIndentations) && !$this->isCurrentLineComment() && $this->isBlockScalarHeader()) { - $blockScalarIndentations[] = $this->getCurrentLineIndentation(); - } - - $previousLineIndentation = $indent; - - if ($isItUnindentedCollection && !$this->isStringUnIndentedCollectionItem() && $newIndent === $indent) { - $this->moveToPreviousLine(); - break; - } - - if ($this->isCurrentLineBlank()) { - $data[] = substr($this->currentLine, $newIndent); - continue; - } - - // we ignore "comment" lines only when we are not inside a scalar block - if (empty($blockScalarIndentations) && $this->isCurrentLineComment()) { - continue; - } - - if ($indent >= $newIndent) { - $data[] = substr($this->currentLine, $newIndent); - } elseif (0 == $indent) { - $this->moveToPreviousLine(); - - break; - } else { - throw new ParseException('Indentation problem.', $this->getRealCurrentLineNb() + 1, $this->currentLine); - } - } - - return implode("\n", $data); - } - - /** - * Moves the parser to the next line. - * - * @return bool - */ - private function moveToNextLine() - { - if ($this->currentLineNb >= count($this->lines) - 1) { - return false; - } - - $this->currentLine = $this->lines[++$this->currentLineNb]; - - return true; - } - - /** - * Moves the parser to the previous line. - */ - private function moveToPreviousLine() - { - $this->currentLine = $this->lines[--$this->currentLineNb]; - } - - /** - * Parses a YAML value. - * - * @param string $value A YAML value - * @param bool $exceptionOnInvalidType True if an exception must be thrown on invalid types false otherwise - * @param bool $objectSupport True if object support is enabled, false otherwise - * @param bool $objectForMap true if maps should return a stdClass instead of array() - * @param string $context The parser context (either sequence or mapping) - * - * @return mixed A PHP value - * - * @throws ParseException When reference does not exist - */ - private function parseValue($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $context) - { - if (0 === strpos($value, '*')) { - if (false !== $pos = strpos($value, '#')) { - $value = substr($value, 1, $pos - 2); - } else { - $value = substr($value, 1); - } - - if (!array_key_exists($value, $this->refs)) { - throw new ParseException(sprintf('Reference "%s" does not exist.', $value), $this->currentLine); - } - - return $this->refs[$value]; - } - - if (preg_match('/^'.self::BLOCK_SCALAR_HEADER_PATTERN.'$/', $value, $matches)) { - $modifiers = isset($matches['modifiers']) ? $matches['modifiers'] : ''; - - return $this->parseBlockScalar($matches['separator'], preg_replace('#\d+#', '', $modifiers), (int) abs($modifiers)); - } - - try { - $parsedValue = Inline::parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $this->refs); - - if ('mapping' === $context && '"' !== $value[0] && "'" !== $value[0] && '[' !== $value[0] && '{' !== $value[0] && '!' !== $value[0] && false !== strpos($parsedValue, ': ')) { - throw new ParseException('A colon cannot be used in an unquoted mapping value.'); - } - - return $parsedValue; - } catch (ParseException $e) { - $e->setParsedLine($this->getRealCurrentLineNb() + 1); - $e->setSnippet($this->currentLine); - - throw $e; - } - } - - /** - * Parses a block scalar. - * - * @param string $style The style indicator that was used to begin this block scalar (| or >) - * @param string $chomping The chomping indicator that was used to begin this block scalar (+ or -) - * @param int $indentation The indentation indicator that was used to begin this block scalar - * - * @return string The text value - */ - private function parseBlockScalar($style, $chomping = '', $indentation = 0) - { - $notEOF = $this->moveToNextLine(); - if (!$notEOF) { - return ''; - } - - $isCurrentLineBlank = $this->isCurrentLineBlank(); - $blockLines = array(); - - // leading blank lines are consumed before determining indentation - while ($notEOF && $isCurrentLineBlank) { - // newline only if not EOF - if ($notEOF = $this->moveToNextLine()) { - $blockLines[] = ''; - $isCurrentLineBlank = $this->isCurrentLineBlank(); - } - } - - // determine indentation if not specified - if (0 === $indentation) { - if (preg_match('/^ +/', $this->currentLine, $matches)) { - $indentation = strlen($matches[0]); - } - } - - if ($indentation > 0) { - $pattern = sprintf('/^ {%d}(.*)$/', $indentation); - - while ( - $notEOF && ( - $isCurrentLineBlank || - preg_match($pattern, $this->currentLine, $matches) - ) - ) { - if ($isCurrentLineBlank && strlen($this->currentLine) > $indentation) { - $blockLines[] = substr($this->currentLine, $indentation); - } elseif ($isCurrentLineBlank) { - $blockLines[] = ''; - } else { - $blockLines[] = $matches[1]; - } - - // newline only if not EOF - if ($notEOF = $this->moveToNextLine()) { - $isCurrentLineBlank = $this->isCurrentLineBlank(); - } - } - } elseif ($notEOF) { - $blockLines[] = ''; - } - - if ($notEOF) { - $blockLines[] = ''; - $this->moveToPreviousLine(); - } - - // folded style - if ('>' === $style) { - $text = ''; - $previousLineIndented = false; - $previousLineBlank = false; - - for ($i = 0; $i < count($blockLines); ++$i) { - if ('' === $blockLines[$i]) { - $text .= "\n"; - $previousLineIndented = false; - $previousLineBlank = true; - } elseif (' ' === $blockLines[$i][0]) { - $text .= "\n".$blockLines[$i]; - $previousLineIndented = true; - $previousLineBlank = false; - } elseif ($previousLineIndented) { - $text .= "\n".$blockLines[$i]; - $previousLineIndented = false; - $previousLineBlank = false; - } elseif ($previousLineBlank || 0 === $i) { - $text .= $blockLines[$i]; - $previousLineIndented = false; - $previousLineBlank = false; - } else { - $text .= ' '.$blockLines[$i]; - $previousLineIndented = false; - $previousLineBlank = false; - } - } - } else { - $text = implode("\n", $blockLines); - } - - // deal with trailing newlines - if ('' === $chomping) { - $text = preg_replace('/\n+$/', "\n", $text); - } elseif ('-' === $chomping) { - $text = preg_replace('/\n+$/', '', $text); - } - - return $text; - } - - /** - * Returns true if the next line is indented. - * - * @return bool Returns true if the next line is indented, false otherwise - */ - private function isNextLineIndented() - { - $currentIndentation = $this->getCurrentLineIndentation(); - $EOF = !$this->moveToNextLine(); - - while (!$EOF && $this->isCurrentLineEmpty()) { - $EOF = !$this->moveToNextLine(); - } - - if ($EOF) { - return false; - } - - $ret = false; - if ($this->getCurrentLineIndentation() > $currentIndentation) { - $ret = true; - } - - $this->moveToPreviousLine(); - - return $ret; - } - - /** - * Returns true if the current line is blank or if it is a comment line. - * - * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise - */ - private function isCurrentLineEmpty() - { - return $this->isCurrentLineBlank() || $this->isCurrentLineComment(); - } - - /** - * Returns true if the current line is blank. - * - * @return bool Returns true if the current line is blank, false otherwise - */ - private function isCurrentLineBlank() - { - return '' == trim($this->currentLine, ' '); - } - - /** - * Returns true if the current line is a comment line. - * - * @return bool Returns true if the current line is a comment line, false otherwise - */ - private function isCurrentLineComment() - { - //checking explicitly the first char of the trim is faster than loops or strpos - $ltrimmedLine = ltrim($this->currentLine, ' '); - - return '' !== $ltrimmedLine && $ltrimmedLine[0] === '#'; - } - - /** - * Cleanups a YAML string to be parsed. - * - * @param string $value The input YAML string - * - * @return string A cleaned up YAML string - */ - private function cleanup($value) - { - $value = str_replace(array("\r\n", "\r"), "\n", $value); - - // strip YAML header - $count = 0; - $value = preg_replace('#^\%YAML[: ][\d\.]+.*\n#u', '', $value, -1, $count); - $this->offset += $count; - - // remove leading comments - $trimmedValue = preg_replace('#^(\#.*?\n)+#s', '', $value, -1, $count); - if ($count == 1) { - // items have been removed, update the offset - $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); - $value = $trimmedValue; - } - - // remove start of the document marker (---) - $trimmedValue = preg_replace('#^\-\-\-.*?\n#s', '', $value, -1, $count); - if ($count == 1) { - // items have been removed, update the offset - $this->offset += substr_count($value, "\n") - substr_count($trimmedValue, "\n"); - $value = $trimmedValue; - - // remove end of the document marker (...) - $value = preg_replace('#\.\.\.\s*$#', '', $value); - } - - return $value; - } - - /** - * Returns true if the next line starts unindented collection. - * - * @return bool Returns true if the next line starts unindented collection, false otherwise - */ - private function isNextLineUnIndentedCollection() - { - $currentIndentation = $this->getCurrentLineIndentation(); - $notEOF = $this->moveToNextLine(); - - while ($notEOF && $this->isCurrentLineEmpty()) { - $notEOF = $this->moveToNextLine(); - } - - if (false === $notEOF) { - return false; - } - - $ret = false; - if ( - $this->getCurrentLineIndentation() == $currentIndentation - && - $this->isStringUnIndentedCollectionItem() - ) { - $ret = true; - } - - $this->moveToPreviousLine(); - - return $ret; - } - - /** - * Returns true if the string is un-indented collection item. - * - * @return bool Returns true if the string is un-indented collection item, false otherwise - */ - private function isStringUnIndentedCollectionItem() - { - return 0 === strpos($this->currentLine, '- '); - } - - /** - * Tests whether or not the current line is the header of a block scalar. - * - * @return bool - */ - private function isBlockScalarHeader() - { - return (bool) preg_match('~'.self::BLOCK_SCALAR_HEADER_PATTERN.'$~', $this->currentLine); - } -} diff --git a/Classes/DeviceDetector/Yaml/Unescaper.php b/Classes/DeviceDetector/Yaml/Unescaper.php deleted file mode 100644 index 62623e1..0000000 --- a/Classes/DeviceDetector/Yaml/Unescaper.php +++ /dev/null @@ -1,141 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace WapplerSystems\ABTest2\DeviceDetector\Yaml; - - -/** - * Unescaper encapsulates unescaping rules for single and double-quoted - * YAML strings. - * - * @author Matthew Lewinski - * - * @internal - */ -class Unescaper -{ - /** - * Regex fragment that matches an escaped character in a double quoted string. - */ - const REGEX_ESCAPED_CHARACTER = '\\\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)'; - - /** - * Unescapes a single quoted string. - * - * @param string $value A single quoted string. - * - * @return string The unescaped string. - */ - public function unescapeSingleQuotedString($value) - { - return str_replace('\'\'', '\'', $value); - } - - /** - * Unescapes a double quoted string. - * - * @param string $value A double quoted string. - * - * @return string The unescaped string. - */ - public function unescapeDoubleQuotedString($value) - { - $callback = function ($match) { - return $this->unescapeCharacter($match[0]); - }; - - // evaluate the string - return preg_replace_callback('/'.self::REGEX_ESCAPED_CHARACTER.'/u', $callback, $value); - } - - /** - * Unescapes a character that was found in a double-quoted string. - * - * @param string $value An escaped character - * - * @return string The unescaped character - */ - private function unescapeCharacter($value) - { - switch ($value[1]) { - case '0': - return "\x0"; - case 'a': - return "\x7"; - case 'b': - return "\x8"; - case 't': - return "\t"; - case "\t": - return "\t"; - case 'n': - return "\n"; - case 'v': - return "\xB"; - case 'f': - return "\xC"; - case 'r': - return "\r"; - case 'e': - return "\x1B"; - case ' ': - return ' '; - case '"': - return '"'; - case '/': - return '/'; - case '\\': - return '\\'; - case 'N': - // U+0085 NEXT LINE - return "\xC2\x85"; - case '_': - // U+00A0 NO-BREAK SPACE - return "\xC2\xA0"; - case 'L': - // U+2028 LINE SEPARATOR - return "\xE2\x80\xA8"; - case 'P': - // U+2029 PARAGRAPH SEPARATOR - return "\xE2\x80\xA9"; - case 'x': - return self::utf8chr(hexdec(substr($value, 2, 2))); - case 'u': - return self::utf8chr(hexdec(substr($value, 2, 4))); - case 'U': - return self::utf8chr(hexdec(substr($value, 2, 8))); - default: - throw new ParseException(sprintf('Found unknown escape character "%s".', $value)); - } - } - - /** - * Get the UTF-8 character for the given code point. - * - * @param int $c The unicode code point - * - * @return string The corresponding UTF-8 character - */ - private static function utf8chr($c) - { - if (0x80 > $c %= 0x200000) { - return chr($c); - } - if (0x800 > $c) { - return chr(0xC0 | $c >> 6).chr(0x80 | $c & 0x3F); - } - if (0x10000 > $c) { - return chr(0xE0 | $c >> 12).chr(0x80 | $c >> 6 & 0x3F).chr(0x80 | $c & 0x3F); - } - - return chr(0xF0 | $c >> 18).chr(0x80 | $c >> 12 & 0x3F).chr(0x80 | $c >> 6 & 0x3F).chr(0x80 | $c & 0x3F); - } -} diff --git a/Classes/Helper.php b/Classes/Helper.php deleted file mode 100644 index 55261b8..0000000 --- a/Classes/Helper.php +++ /dev/null @@ -1,148 +0,0 @@ - - */ -class Helper -{ - - /** - * - * @param array $params - * @param $tsFeController TypoScriptFrontendController - * @return void - * @throws \InvalidArgumentException - */ - public function determineContentId(array $params, &$tsFeController) - { - $deviceDetector = new DeviceDetector(); - $deviceDetector->setUserAgent($_SERVER['HTTP_USER_AGENT']); - try { - $deviceDetector->setYamlParser(new Parser()); - $deviceDetector->parse(); - if ($deviceDetector->isBot()) return; - } catch (\Exception $e) { - } - - $currentPageId = $targetPageId = $tsFeController->id; - - // Get the rootpage_id - $pageRepository = GeneralUtility::makeInstance(PageRepository::class); - $rootpage_id = array_pop($pageRepository->getRootLine($GLOBALS['TSFE']->id)); - - // If the ID is NULL, then we set this value to the rootpage_id. NULL is the "Home"page, ID is a specific sub-page, e.g. www.domain.de (NULL) - www.domain.de/page.html (ID) - if (!$currentPageId) { - if ($rootpage_id) { - $currentPageId = $rootpage_id; - } else { - // Leave the function because we can not determine the ID. - return; - } - } - - $currentPagePropertiesArray = $pageRepository->getPage($currentPageId); - - $pageBPageId = $currentPagePropertiesArray['tx_abtest2_b_id']; - /* TODO: check if page b exists */ - $cookieLifeTime = $currentPagePropertiesArray['tx_abtest2_cookie_time']; - - if ($pageBPageId) { - - $pageBPropertiesArray = $pageRepository->getPage($pageBPageId); - $cookieValue = $_COOKIE['abtest2']; - - if ($cookieValue === 'b') { - $targetPageId = $pageBPageId; - $currentPagePropertiesArray = $pageBPropertiesArray; - } else if ($cookieValue === 'a') { - - } else { - $cookieValue = 'a'; - /* select least used page */ - - if ((int)$currentPagePropertiesArray['tx_abtest2_counter'] > (int)$pageBPropertiesArray['tx_abtest2_counter']) { - /* take b */ - $targetPageId = $pageBPageId; - $currentPagePropertiesArray = $pageBPropertiesArray; - $cookieValue = 'b'; - } - - /* rise counter */ - $GLOBALS['TYPO3_DB']->exec_UPDATEquery('pages', 'uid='. (int)$targetPageId, array('tx_abtest2_counter' => $currentPagePropertiesArray['tx_abtest2_counter'] + 1)); - - setcookie('abtest2', $cookieValue, time() + $cookieLifeTime); - } - - // If current page ID is different from the random page ID we set the correct page ID. - if ($currentPageId !== $targetPageId) { - $tsFeController->contentPid = $targetPageId; - $tsFeController->page['content_from_pid'] = $targetPageId; - } - - $_GET['abtest'] = $cookieValue; - - $this->makeCacheHash($tsFeController); - - - if ($currentPagePropertiesArray) { - $additionalHeaderData = $currentPagePropertiesArray['tx_abtest2_header']; - $additionalFooterData = $currentPagePropertiesArray['tx_abtest2_footer']; - if ($additionalHeaderData) { - $tsFeController->additionalHeaderData['abtest2'] = $additionalHeaderData; - } - if ($additionalFooterData) { - $tsFeController->additionalFooterData['abtest2'] = $additionalFooterData; - } - } - - } - - - - } - - - /** - * - * @param $tsFeController TypoScriptFrontendController - * @return void - * @throws \InvalidArgumentException - */ - private function makeCacheHash(&$tsFeController) - { - $GET = GeneralUtility::_GET(); - - /* Fix for root pages */ - if (!isset($GET['id'])) { - $GET['id'] = $tsFeController->id; - } - - /** @var CacheHashCalculator $cacheHash */ - $cacheHash = GeneralUtility::makeInstance(CacheHashCalculator::class); - - $tsFeController->cHash_array = $cacheHash->getRelevantParameters(GeneralUtility::implodeArrayForUrl('', $GET)); - $tsFeController->cHash = $cacheHash->calculateCacheHash($tsFeController->cHash_array); - - } -} - - diff --git a/Classes/Middleware/SetCookie.php b/Classes/Middleware/SetCookie.php new file mode 100644 index 0000000..0ff4765 --- /dev/null +++ b/Classes/Middleware/SetCookie.php @@ -0,0 +1,65 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\ABTest\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Component\HttpFoundation\Cookie as SymfonyCookie; +use WerkraumMedia\ABTest\Cookie; + +class SetCookie implements MiddlewareInterface +{ + /** + * @var Cookie + */ + private $cookie; + + public function __construct( + Cookie $cookie + ) { + $this->cookie = $cookie; + } + + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + $response = $handler->handle($request); + + if ($this->cookie->needsToBeSet()) { + $cookie = new SymfonyCookie( + 'ab-' . $this->cookie->getRequestedPage(), + (string)$this->cookie->getActualPage(), + time() + $this->cookie->getLifetime() + ); + + // Do we need to adjust response header for caching to not cache the cookie within proxies? + $response = $response->withAddedHeader('Set-Cookie', $cookie->__toString()); + } + + return $response; + } +} diff --git a/Classes/PageRepository.php b/Classes/PageRepository.php new file mode 100644 index 0000000..a38f0d8 --- /dev/null +++ b/Classes/PageRepository.php @@ -0,0 +1,64 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\ABTest; + +use TYPO3\CMS\Core\Database\ConnectionPool; + +class PageRepository +{ + private ConnectionPool $connectionPool; + + public function __construct( + ConnectionPool $connectionPool + ) { + $this->connectionPool = $connectionPool; + } + + public function getPage(int $uid): array + { + $result = $this->connectionPool->getConnectionForTable('pages') + ->select( + ['*'], + 'pages', + ['uid' => $uid] + ) + ->fetchAssociative() + ; + + if (is_array($result) === false) { + $result = []; + } + + return $result; + } + + public function updateCounter(int $uid, int $counter): void + { + $this->connectionPool->getConnectionForTable('pages')->update( + 'pages', + ['tx_abtest_counter' => $counter], + ['uid' => $uid] + ); + } +} diff --git a/Classes/Switcher.php b/Classes/Switcher.php new file mode 100644 index 0000000..1d9d6ea --- /dev/null +++ b/Classes/Switcher.php @@ -0,0 +1,149 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\ABTest; + +use DeviceDetector\DeviceDetector; +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; + +/** + * Will decide whether to switch to another variant. + */ +class Switcher +{ + private PageRepository $pageRepository; + + private Cookie $cookie; + + public function __construct( + PageRepository $pageRepository, + Cookie $cookie + ) { + $this->pageRepository = $pageRepository; + $this->cookie = $cookie; + } + + public function determineContentId( + array $params, + TypoScriptFrontendController $frontendController + ): void { + if ($this->isRequestByBot()) { + return; + } + + $currentPageId = $frontendController->id; + if (is_numeric($currentPageId) === false) { + $currentPageId = $this->getRootPageId(); + } else { + $currentPageId = (int)$currentPageId; + } + + if ($currentPageId === 0) { + return; + } + + $currentPagePropertiesArray = $this->pageRepository->getPage($currentPageId); + if ((int)$currentPagePropertiesArray['tx_abtest_variant'] === 0) { + return; + } + + $requestedViaCookie = (int)($this->getRequest()->getCookieParams()['ab-' . $currentPageId] ?? '0'); + $targetPage = $this->getTargetPage($currentPagePropertiesArray, $requestedViaCookie); + + if ($frontendController->id !== (int)$targetPage['uid']) { + $frontendController->id = (int)$targetPage['uid']; + $frontendController->contentPid = (int)$targetPage['uid']; + $frontendController->page = $targetPage; + } + + if ( + $requestedViaCookie === 0 + || (int)$targetPage['uid'] !== $requestedViaCookie + ) { + $this->pageRepository->updateCounter((int)$targetPage['uid'], ++$targetPage['tx_abtest_counter']); + } + + $this->cookie->setRequestedPage($currentPageId); + $this->cookie->setActualPage($targetPage['uid']); + $this->cookie->setLifetime($targetPage['tx_abtest_cookie_time']); + } + + private function isRequestByBot(): bool + { + $deviceDetector = new DeviceDetector(); + $deviceDetector->setUserAgent($_SERVER['HTTP_USER_AGENT']); + try { + $deviceDetector->parse(); + return $deviceDetector->isBot(); + } catch (\Exception $e) { + } + + return false; + } + + /** + * Returns 0 if no site could be fetched. + */ + private function getRootPageId(): int + { + $site = $this->getRequest()->getAttribute('site'); + if (!$site instanceof Site) { + return 0; + } + + return $site->getRootPageId(); + } + + private function getRequest(): ServerRequest + { + return $GLOBALS['TYPO3_REQUEST']; + } + + private function getTargetPage(array $page, int $cookiePageUid): array + { + if ($cookiePageUid > 0 && $cookiePageUid === (int)$page['uid']) { + return $page; + } + + $variantPage = $this->pageRepository->getPage((int)$page['tx_abtest_variant']); + + if ( + $variantPage !== [] + && ( + ($cookiePageUid > 0 && $cookiePageUid === (int)$variantPage['uid']) + || ((int)$variantPage['tx_abtest_counter'] < (int)$page['tx_abtest_counter']) + ) + ) { + return $variantPage; + } + + return $page; + } + + public static function register(): void + { + $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PostProc'][self::class] = self::class . '->determineContentId'; + } +} diff --git a/Classes/TCA/VariantFilter.php b/Classes/TCA/VariantFilter.php new file mode 100644 index 0000000..577bbd5 --- /dev/null +++ b/Classes/TCA/VariantFilter.php @@ -0,0 +1,51 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\ABTest\TCA; + +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Domain\Repository\PageRepository; + +class VariantFilter +{ + private PageRepository $pageRepository; + + public function __construct( + PageRepository $pageRepository + ) { + $this->pageRepository = $pageRepository; + } + + public function doFilter(array $parameters, DataHandler $dataHandler): array + { + return array_filter($parameters['values'], [$this, 'filterPage']); + } + + private function filterPage(string $pageIdentifier): bool + { + $uid = (int)str_replace('pages_', '', $pageIdentifier); + $page = $this->pageRepository->getPage($uid); + $doktype = (int)($page['doktype'] ?? 0); + return $doktype === PageRepository::DOKTYPE_DEFAULT; + } +} diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..218cd8d --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,15 @@ + [ + 'abtest-cookie' => [ + 'target' => \WerkraumMedia\ABTest\Middleware\SetCookie::class, + 'after' => [ + 'typo3/cms-frontend/prepare-tsfe-rendering', + ], + 'before' => [ + 'typo3/cms-frontend/output-compression', + ], + ], + ], +]; diff --git a/Configuration/Services.php b/Configuration/Services.php new file mode 100644 index 0000000..e0d6052 --- /dev/null +++ b/Configuration/Services.php @@ -0,0 +1,23 @@ +services() + ->defaults() + ->autowire() + ->autoconfigure() + ; + + $services->load('WerkraumMedia\\ABTest\\', '../Classes/'); + + $services->set(Switcher::class)->public(); + $services->set(VariantFilter::class)->public(); +}; diff --git a/Configuration/TCA/Overrides/pages.php b/Configuration/TCA/Overrides/pages.php index 7b6f6f5..56b1b40 100644 --- a/Configuration/TCA/Overrides/pages.php +++ b/Configuration/TCA/Overrides/pages.php @@ -1,61 +1,67 @@ [ - 'exclude' => 1, - 'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_b_id', - 'config' => [ - 'type' => 'group', - 'internal_type' => 'db', - 'allowed' => 'pages', - 'maxitems' => 1, - 'size' => 1 - ] - ], - 'tx_abtest2_cookie_time' => [ - 'exclude' => 1, - 'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_cookie_time', - 'config' => [ - 'type' => 'select', - 'renderType' => 'selectSingle', - 'items' => [ - ['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_month', 2419200], - ['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_week', 604800], - ['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_day', 86400], - ['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_12_day', 43200], - ['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_hour', 3600], - ['LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:cookie_1_minute', 60] - ] - ] - ], - 'tx_abtest2_header' => [ - 'exclude' => 1, - 'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_header', - 'config' => [ - 'type' => 'text' - ] - ], - 'tx_abtest2_footer' => [ - 'exclude' => 1, - 'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_footer', - 'config' => [ - 'type' => 'text' - ] - ], - 'tx_abtest2_counter' => [ - 'exclude' => 1, - 'label' => 'LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:pages.tx_abtest2_counter', - 'config' => [ - 'type' => 'input' - ] - ] -]); +(static function ( + string $extensionName = 'abtest', + string $tableName = 'pages' +) { + $languagePath = 'LLL:EXT:' . $extensionName . '/Resources/Private/Language/locallang_db.xlf:' . $tableName . '.'; -\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes('pages', - '--palette--;LLL:EXT:abtest2/Resources/Private/Language/locallang_db.xlf:palette_title;tx_abtest2', '', - 'after:subtitle'); - -$GLOBALS['TCA']['pages']['palettes']['tx_abtest2'] = array( - 'showitem' => 'tx_abtest2_b_id,--linebreak--,tx_abtest2_header,tx_abtest2_footer,tx_abtest2_cookie_time,tx_abtest2_counter' -); + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns($tableName, [ + 'tx_abtest_variant' => [ + 'exclude' => 1, + 'label' => $languagePath . 'tx_abtest_variant', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'maxitems' => 1, + 'minitems' => 0, + 'size' => 1, + 'suggestOptions' => [ + 'default' => [ + 'addWhere' => 'AND pages.doktype = ' . \TYPO3\CMS\Core\Domain\Repository\PageRepository::DOKTYPE_DEFAULT, + ], + ], + 'filter' => [ + [ + 'userFunc' => \WerkraumMedia\ABTest\TCA\VariantFilter::class . '->doFilter', + ], + ], + ], + ], + 'tx_abtest_cookie_time' => [ + 'exclude' => 1, + 'label' => $languagePath . 'tx_abtest_cookie_time', + 'config' => [ + 'type' => 'input', + 'eval' => 'int', + 'size' => 10, + 'valuePicker' => [ + 'items' => [ + [$languagePath . 'tx_abtest_cookie_time.cookie_1_month', 2419200], + [$languagePath . 'tx_abtest_cookie_time.cookie_1_week', 604800], + [$languagePath . 'tx_abtest_cookie_time.cookie_1_day', 86400], + [$languagePath . 'tx_abtest_cookie_time.cookie_12_days', 43200], + [$languagePath . 'tx_abtest_cookie_time.cookie_1_hour', 3600], + [$languagePath . 'tx_abtest_cookie_time.cookie_1_minute', 60], + ], + ], + ], + ], + 'tx_abtest_counter' => [ + 'exclude' => 1, + 'label' => $languagePath . 'tx_abtest_counter', + 'config' => [ + 'type' => 'input', + 'eval' => 'int', + 'size' => 10, + ], + ], + ]); + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes( + $tableName, + '--div--;' . $languagePath . 'div_title,tx_abtest_variant,tx_abtest_cookie_time,tx_abtest_counter', + '', + 'after:content_from_pid' + ); +})(); diff --git a/Configuration/YAML/bots.yml b/Configuration/YAML/bots.yml deleted file mode 100644 index ff5da52..0000000 --- a/Configuration/YAML/bots.yml +++ /dev/null @@ -1,1572 +0,0 @@ -############### -# Device Detector - The Universal Device Detection library for parsing User Agents -# -# @link http://piwik.org -# @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later -############### - -- regex: '360Spider(-Image|-Video)?' - name: '360Spider' - category: 'Search bot' - url: 'http://www.so.com/help/help_3_2.html' - producer: - name: 'Online Media Group, Inc.' - url: '' - -- regex: 'Aboundex' - name: 'Aboundexbot' - category: 'Search bot' - url: 'http://www.aboundex.com/crawler/' - producer: - name: 'Aboundex.com' - url: 'http://www.aboundex.com' - -- regex: 'AcoonBot' - name: 'Acoon' - category: 'Search bot' - url: 'http://www.acoon.de/robot.asp' - producer: - name: 'Acoon GmbH' - url: 'http://www.acoon.de' - -- regex: 'AddThis\.com' - name: 'AddThis.com' - category: 'Social Media Agent' - url: '' - producer: - name: 'Clearspring Technologies, Inc.' - url: 'http://www.clearspring.com' - -- regex: 'AhrefsBot' - name: 'aHrefs Bot' - category: 'Crawler' - url: 'http://ahrefs.com/robot' - producer: - name: 'Ahrefs Pte Ltd' - url: 'http://ahrefs.com/robot' - -- regex: 'ia_archiver|alexabot|verifybot' - name: 'Alexa Crawler' - category: 'Search bot' - url: 'https://alexa.zendesk.com/hc/en-us/sections/200100794-Crawlers' - producer: - name: 'Alexa Internet' - url: 'http://www.alexa.com' - -- regex: 'AmorankSpider' - name: 'Amorank Spider' - category: 'Crawler' - url: 'http://amorank.com/webcrawler.html' - producer: - name: 'Amorank' - url: 'http://www.amorank.com' - -- regex: 'ApacheBench' - name: 'ApacheBench' - category: 'Benchmark' - url: 'https://httpd.apache.org/docs/2.4/programs/ab.html' - producer: - name: 'The Apache Software Foundation' - url: 'http://www.apache.org/foundation/' - -- regex: 'Applebot' - name: 'Applebot' - category: 'Crawler' - url: 'http://www.apple.com/go/applebot' - producer: - name: 'Apple Inc' - url: 'http://www.apple.com' - -- regex: 'Castro 2, Episode Duration Lookup' - name: 'Castro 2' - category: 'Service Agent' - url: 'http://supertop.co/castro/' - producer: - name: 'Supertop' - url: 'http://supertop.co' - -- regex: 'Curious George' - name: 'Analytics SEO Crawler' - category: 'Crawler' - url: 'http://www.analyticsseo.com/crawler' - producer: - name: 'Analytics SEO' - url: 'http://www.analyticsseo.com' - -- regex: 'archive\.org_bot|special_archiver' - name: 'archive.org bot' - category: 'Crawler' - url: 'http://www.archive.org/details/archive.org_bot' - producer: - name: 'The Internet Archive' - url: 'http://www.archive.org' - -- regex: 'Ask Jeeves/Teoma' - name: 'Ask Jeeves' - category: 'Search bot' - url: '' - producer: - name: 'Ask Jeeves Inc.' - url: 'http://www.ask.com' - -- regex: 'Backlink-Check\.de' - name: 'Backlink-Check.de' - category: 'Crawler' - url: 'http://www.backlink-check.de/bot.html' - producer: - name: 'Mediagreen Medienservice' - url: 'http://www.backlink-check.de' - -- regex: 'BacklinkCrawler' - name: 'BacklinkCrawler' - category: 'Crawler' - url: 'http://www.backlinktest.com/crawler.html' - producer: - name: '2.0Promotion GbR' - url: 'http://www.backlinktest.com' - -- regex: 'baiduspider(-image)?|baidu Transcoder|baidu.*spider' - name: 'Baidu Spider' - category: 'Search bot' - url: 'http://www.baidu.com/search/spider.htm' - producer: - name: 'Baidu' - url: 'http://www.baidu.com' - -- regex: 'BazQux' - name: 'BazQux Reader' - url: 'https://bazqux.com/fetcher' - category: 'Feed Fetcher' - producer: - name: '' - url: '' - -- regex: 'MSNBot|msrbot|bingbot|BingPreview|msnbot-(UDiscovery|NewsBlogs)|adidxbot' - name: 'BingBot' - category: 'Search bot' - url: 'http://search.msn.com/msnbot.htmn' - producer: - name: 'Microsoft Corporation' - url: 'http://www.microsoft.com' - -- regex: 'Blekkobot' - name: 'Blekkobot' - category: 'Search bot' - url: 'http://blekko.com/about/blekkobot' - producer: - name: 'Blekko' - url: 'http://blekko.com' - -- regex: 'BLEXBot(Test)?' - name: 'BLEXBot Crawler' - category: 'Crawler' - url: 'http://webmeup-crawler.com' - producer: - name: 'WebMeUp' - url: 'http://webmeup.com' - -- regex: 'Bloglovin' - name: 'Bloglovin' - url: 'http://www.bloglovin.com' - category: 'Feed Fetcher' - producer: - name: '' - url: '' - -- regex: 'Blogtrottr' - name: 'Blogtrottr' - url: '' - category: 'Feed Fetcher' - producer: - name: 'Blogtrottr Ltd' - url: 'https://blogtrottr.com/' - -- regex: 'BountiiBot' - name: 'Bountii Bot' - category: 'Search bot' - url: 'http://bountii.com/contact.php' - producer: - name: 'Bountii Inc.' - url: 'http://bountii.com' - -- regex: 'Browsershots' - name: 'Browsershots' - category: 'Service Agent' - url: 'http://browsershots.org/faq' - producer: - name: 'Browsershots.org' - url: 'http://browsershots.org' - -- regex: 'BUbiNG' - name: 'BUbiNG' - category: 'Crawler' - url: 'http://law.di.unimi.it/BUbiNG.html' - producer: - name: 'The Laboratory for Web Algorithmics (LAW)' - url: 'http://law.di.unimi.it/software.php#buging' - -- regex: '(?`_ by - downloading the zip version. Upload - the file afterwards in the Extension Manager. - - #. **Use composer**: Use `composer require svewap/abtest2`. - -#. The Extension Manager offers some basic configuration which is - explained :ref:`here `. - -Latest version from git ------------------------ -You can get the latest version from git by using the git command: - -.. code-block:: bash - - git clone https://github.com/svewap/abtest2.git diff --git a/Documentation/Images/demo.gif b/Documentation/Images/demo.gif deleted file mode 100644 index 38477cf..0000000 Binary files a/Documentation/Images/demo.gif and /dev/null differ diff --git a/Documentation/Images/page_settings.png b/Documentation/Images/page_settings.png deleted file mode 100644 index 04cd438..0000000 Binary files a/Documentation/Images/page_settings.png and /dev/null differ diff --git a/Documentation/Includes.txt b/Documentation/Includes.txt deleted file mode 100644 index 3f9e47a..0000000 --- a/Documentation/Includes.txt +++ /dev/null @@ -1,29 +0,0 @@ -.. ================================================== -.. FOR YOUR INFORMATION -.. -------------------------------------------------- -.. -*- coding: utf-8 -*- with BOM. - -.. This is 'Includes.txt'. It is included at the very top of each and - every ReST source file in this documentation project (= manual). - - -.. ================================================== -.. DEFINE SOME TEXT ROLES -.. -------------------------------------------------- - -.. role:: typoscript(code) - -.. role:: ts(typoscript) - :class: typoscript - -.. role:: php(code) - -.. highlight:: php - -.. |img-demo| image:: /Images/demo.gif -.. :border: 0 -.. :align: left - -.. |img-page-settings| image:: /Images/page_settings.png -.. :border: 0 -.. :align: left diff --git a/Documentation/Index.rst b/Documentation/Index.rst deleted file mode 100644 index 9c62ede..0000000 --- a/Documentation/Index.rst +++ /dev/null @@ -1,54 +0,0 @@ -.. ================================================== -.. FOR YOUR INFORMATION -.. -------------------------------------------------- -.. -*- coding: utf-8 -*- with BOM. - -.. include:: Includes.txt - - -.. _start: - -============================================================= -TYPO3 extension for A/B tests -============================================================= - -.. only:: html - - :Classification: - abtest2 - - :Version: - |release| - - :Language: - en - - :Keywords: - abtest - - :Copyright: - 2017 - - :Author: - Sven Wappler - - :License: - This document is published under the Open Content License - available from http://www.opencontent.org/opl.shtml - - :Rendered: - |today| - - The content of this document is related to TYPO3, - a GNU/GPL CMS/Framework available from `www.typo3.org `_. - - - **Table of Contents** - -.. toctree:: - :maxdepth: 3 - :titlesonly: - :glob: - - Introduction/Index - AdministratorManual/Index diff --git a/Documentation/Introduction/About/Index.rst b/Documentation/Introduction/About/Index.rst deleted file mode 100644 index 4b35ffc..0000000 --- a/Documentation/Introduction/About/Index.rst +++ /dev/null @@ -1,23 +0,0 @@ -.. ================================================== -.. FOR YOUR INFORMATION -.. -------------------------------------------------- -.. -*- coding: utf-8 -*- with BOM. - -.. include:: ../../Includes.txt - -.. _about: - -What does it do? -================ - -This extension supports TYPO3 administrators in performing A/B tests. This is useful when a site owner want to measure whether a new version improves or reduces user interaction compared to the current version. - - -**Features of the extension** - -- Caching of each page version -- A real 50/50% chance. That means: No selection by random, because of the unreliable random method. So the versions are always taken alternately. -- Complete different content with same page id. So only one URL for two versions. The displayed version is determined by the cookie value. - - -|img-demo| \ No newline at end of file diff --git a/Documentation/Introduction/Index.rst b/Documentation/Introduction/Index.rst deleted file mode 100644 index 22a39ef..0000000 --- a/Documentation/Introduction/Index.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. ================================================== -.. FOR YOUR INFORMATION -.. -------------------------------------------------- -.. -*- coding: utf-8 -*- with BOM. - -.. _introduction: - -Introduction -============ - -.. only:: html - - This chapter gives you a basic introduction about the TYPO3 CMS extension "*abtest2*". - -.. toctree:: - :maxdepth: 5 - :titlesonly: - - About/Index - Support/Index - Thanks/Index diff --git a/Documentation/Introduction/Support/Index.rst b/Documentation/Introduction/Support/Index.rst deleted file mode 100644 index e6c81af..0000000 --- a/Documentation/Introduction/Support/Index.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. ================================================== -.. FOR YOUR INFORMATION -.. -------------------------------------------------- -.. -*- coding: utf-8 -*- with BOM. - -.. include:: ../../Includes.txt - -.. _support: - -Need Support? -============= -There are various ways to get support for this extension! - -Stackoverflow -------------- -Please use https://stackoverflow.com to get best support. Tags you should use are `typo3` and `abtest2`. - -Sponsoring ----------- -If you need a feature which is not yet implemented, feel free to contact me anytime! - -Private/Personal support ------------------------- -If you need private or personal support, ask one of the developers for it. - -**Be aware that this support might not be free!** diff --git a/Documentation/Introduction/Thanks/Index.rst b/Documentation/Introduction/Thanks/Index.rst deleted file mode 100644 index e4a601c..0000000 --- a/Documentation/Introduction/Thanks/Index.rst +++ /dev/null @@ -1,32 +0,0 @@ -.. ================================================== -.. FOR YOUR INFORMATION -.. -------------------------------------------------- -.. -*- coding: utf-8 -*- with BOM. - -.. include:: ../../Includes.txt - -.. _thanks: - -Say thanks! -=========== -This extension and manual has been created in hours, mostly by a single person. -It is actively maintained to fit all supported TYPO3 versions, user interface concepts and best practice approaches. - -If this extension helps you in anyway to achieve your requirements, please think about giving something back. -Or you want to sponsor a feature to extend something that's already there? Then find some ideas to make me happy below - I'm looking forward to get in contact with you! - -Nice mails -^^^^^^^^^^ -Some nice words fit every time - just drop me some kind words by mail to make me happy :-) - -Talk about it -^^^^^^^^^^^^^ -If you like what I've built, then share it wherever it fits. Maybe this will help to spread the word and find others that can profit from it, too. - -Let's have a tee -^^^^^^^^^^^^^^^^^^^ -If you're in the region of Aachen, just let me know. I'm always looking forward to meeting new and known faces and to exchange on several topics. - -Money -^^^^^ -If you have too much of it and want to share parts of your money with me, just let me know and we'll find a way to organize that :-) diff --git a/Documentation/Settings.cfg b/Documentation/Settings.cfg deleted file mode 100644 index 940c6a0..0000000 --- a/Documentation/Settings.cfg +++ /dev/null @@ -1,48 +0,0 @@ -[html_theme_options] -project_contact = -use_opensearch = -project_home = -project_issues = -github_revision_msg = -github_branch = -github_repository = -project_repository = -project_discussions = -github_sphinx_locale = -github_commit_hash = - -[intersphinx_mapping] -t3tsref = http://docs.typo3.org/typo3cms/TyposcriptReference/ -t3editors = http://docs.typo3.org/typo3cms/EditorsTutorial/ -t3start = http://docs.typo3.org/typo3cms/GettingStartedTutorial/ - -[latex_elements] -papersize = a4paper -preamble = \usepackage{typo3} -pointsize = 10pt - -[general] -project = abtest2 -release = 1.0.0 -version = 1.0 -copyright = 2017 - -[notify] -about_new_build = no -# have one or more receivers notified -# about_new_build = email-1 [, email2, ...] - - -# About Settings.cfg - -# normal: -# https://github.com/marble/typo3-docs-typo3-org-resources/blob/master/TemplatesForCopying/ExampleFiles/Settings-minimal.cfg - -# extensive: -# https://github.com/marble/typo3-docs-typo3-org-resources/blob/master/TemplatesForCopying/ExampleFiles/Settings-extensive.cfg - -# Example files: -# https://github.com/marble/typo3-docs-typo3-org-resources/tree/master/TemplatesForCopying/ExampleFiles - -# More: -# http://mbless.de/blog/2015/10/24/a-new-task-for-an-old-server.html#ini-files diff --git a/Documentation/Settings.yml b/Documentation/Settings.yml deleted file mode 100644 index 7304ad2..0000000 --- a/Documentation/Settings.yml +++ /dev/null @@ -1,30 +0,0 @@ -# This is the project specific Settings.yml file. -# Place Sphinx specific build information here. -# Settings given here will replace the settings of 'conf.py'. - ---- -conf.py: - copyright: 2017 - project: abtest2 - version: 1.0 - release: 1.0.0 - latex_documents: - - - Index - html_theme_options: - github_repository: svewap/abtest2 - github_branch: master - latex_elements: - papersize: a4paper - pointsize: 10pt - preamble: \usepackage{typo3} - intersphinx_mapping: - t3tsref: - - http://docs.typo3.org/typo3cms/TyposcriptReference/ - - null - t3start: - - http://docs.typo3.org/typo3cms/GettingStartedTutorial/ - - null - t3editors: - - http://docs.typo3.org/typo3cms/EditorsTutorial/ - - null -... diff --git a/README.md b/README.md index 11a9c62..7489cf0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# abtest2 TYPO3 Extension +# abtest TYPO3 Extension Extension for A/B-Tests @@ -19,60 +19,5 @@ Additional header information may be specified both for the original version as #### Demo -![Demo](https://raw.githubusercontent.com/svewap/abtest2/master/Documentation/Images/demo.gif) - -#### Example for Google Tag Manager: - -You have two options to define the parameter: By page settings or by TypoScript: - -##### Additional Header Information at page settings - -On original page (version A): - -```javascript - -``` - -On version B: - -```javascript - -``` - - -##### TypoScript - -```typo3_typoscript -[globalVar = GP:abtest = a] - page.headerData.129 = TEXT - page.headerData.129.value ( - - ) -[global] -[globalVar = GP:abtest = b] - page.headerData.129 = TEXT - page.headerData.129.value ( - - ) -[global] - -page.headerData.130 = TEXT -page.headerData.130.value ( - -.... - -) -``` +![Demo](https://raw.githubusercontent.com/werkraum-media/abtest/master/Documentation/Images/demo.gif) diff --git a/Resources/Private/Language/de.locallang_db.xlf b/Resources/Private/Language/de.locallang_db.xlf index d10644d..fe87edf 100644 --- a/Resources/Private/Language/de.locallang_db.xlf +++ b/Resources/Private/Language/de.locallang_db.xlf @@ -1,57 +1,48 @@ - - - -
- - - Site B - Seite B - - - Cookie Lifetime - Cookie Lebenszeit - - - Additional Header Information - Zusätzliche Header Informationen - - - Additional footer information - Zusätzliche Footer Informationen - - - Counter - Zähler - - - AB Test Settings - AB Test Einstellungen - - - 1 month - 1 Monat - - - 1 week - 1 Woche - - - 1 day - 1 Tag - - - 1/2 day - 1/2 Tag - - - 1 hour - 1 Stunde - - - 1 minute - 1 Minute - - - - \ No newline at end of file + + + +
+ + + A/B Testing + A/B Testing + + + Variant + Variante + + + Cookie Lifetime + Cookie Lebenszeit + + + 1 month + 1 Monat + + + 1 week + 1 Woche + + + 1 day + 1 Tag + + + 1/2 day + 1/2 Tag + + + 1 hour + 1 Stunde + + + 1 minute + 1 Minute + + + Counter + Zähler + + + + diff --git a/Resources/Private/Language/locallang_db.xlf b/Resources/Private/Language/locallang_db.xlf index 4f941b9..d5ed083 100644 --- a/Resources/Private/Language/locallang_db.xlf +++ b/Resources/Private/Language/locallang_db.xlf @@ -1,45 +1,38 @@ - - - -
- - - Site B - - - Cookie Lifetime - - - Additional header information - - - Additional footer information - - - Counter - - - AB Test Settings - - - 1 month - - - 1 week - - - 1 day - - - 1/2 day - - - 1 hour - - - 1 minute - - - - \ No newline at end of file + + + +
+ + + A/B Testing + + + Variant + + + Cookie Lifetime + + + 1 month + + + 1 week + + + 1 day + + + 1/2 day + + + 1 hour + + + 1 minute + + + Counter + + + + diff --git a/Tests/Fixtures/BasicDatabase.csv b/Tests/Fixtures/BasicDatabase.csv new file mode 100644 index 0000000..eaad2eb --- /dev/null +++ b/Tests/Fixtures/BasicDatabase.csv @@ -0,0 +1,10 @@ +"pages",,,,,,,,,,,,,, +,"uid","pid","slug","title",tx_abtest_variant,hidden,tx_abtest_cookie_time,,,,,,, +,1,0,"/","Page 1 Title (No Variant)",0,0,604800,,,,,,, +,2,1,"/page-2","Page 2 Title (Variant A)",3,0,604800,,,,,,, +,3,1,"/page-3","Page 3 Title (Variant B)",0,0,604800,,,,,,, +,4,1,"/page-4","Page 4 Title (Variant A)",5,0,2419200,,,,,,, +,5,1,"/page-5","Page 5 Title (Variant B)",0,1,604800,,,,,,, +"sys_template",,,,,,,,,,,,,, +,"uid","pid","root","clear","constants","config",,,,,,,, +,1,1,1,3,"databasePlatform = mysql","",,,,,,,, diff --git a/Tests/Fixtures/FrontendRendering.typoscript b/Tests/Fixtures/FrontendRendering.typoscript new file mode 100644 index 0000000..d9ff642 --- /dev/null +++ b/Tests/Fixtures/FrontendRendering.typoscript @@ -0,0 +1,16 @@ +config { + debug = 1 +} + +page = PAGE +page { + config { + debug = 1 + } + + 5 = TEXT + 5.field = title + + 10 = TEXT + 10.value = Example content from TypoScript +} diff --git a/Tests/Fixtures/Sites/default/config.yaml b/Tests/Fixtures/Sites/default/config.yaml new file mode 100644 index 0000000..a127453 --- /dev/null +++ b/Tests/Fixtures/Sites/default/config.yaml @@ -0,0 +1,32 @@ +base: / +languages: + - + title: English + enabled: true + base: / + typo3Language: default + locale: en_GB.UTF-8 + iso-639-1: en + websiteTitle: '' + navigationTitle: English + hreflang: en-GB + direction: '' + flag: gb + languageId: 0 + fallbackType: strict + fallbacks: '0' + - + title: Deutsch + enabled: true + base: /de + typo3Language: de + locale: de_DE.UTF-8 + iso-639-1: de + navigationTitle: Deutsch + hreflang: de-DE + direction: '' + flag: de + websiteTitle: '' + languageId: 1 +rootPageId: 1 +websiteTitle: 'Example Website' diff --git a/Tests/Functional/FrontendRenderingTest.php b/Tests/Functional/FrontendRenderingTest.php new file mode 100644 index 0000000..617d33b --- /dev/null +++ b/Tests/Functional/FrontendRenderingTest.php @@ -0,0 +1,309 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\ABTest\Tests\Functional; + +use Symfony\Component\HttpFoundation\Cookie; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class FrontendRenderingTest extends FunctionalTestCase +{ + protected $testExtensionsToLoad = [ + 'typo3conf/ext/abtest', + ]; + + protected $pathsToLinkInTestInstance = [ + 'typo3conf/ext/abtest/Tests/Fixtures/Sites' => 'typo3conf/sites', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpBackendUserFromFixture(1); + + $this->importCSVDataSet(__DIR__ . '/../Fixtures/BasicDatabase.csv'); + } + + /** + * @test + */ + public function opensDefaultPageIfNothingIsConfigured(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(1); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertSame('', $result->getHeaderLine('Set-Cookie')); + self::assertStringContainsString('Page 1 Title (No Variant)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + $this->assertCounterOfPage(1, 0); + } + + /** + * @test + */ + public function opensVariantAForFirstVisitor(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(2); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + $this->assertCookie($result, 'ab-2', '2'); + $this->assertCounterOfPage(2, 1); + } + + /** + * @test + */ + public function opensVariantBForSecondVisitor(): void + { + $this->opensVariantAForFirstVisitor(); + + $request = new InternalRequest(); + $request = $request->withPageId(2); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString()); + $this->assertCookie($result, 'ab-2', '3'); + $this->assertPageIsNotCached($result); + $this->assertCounterOfPage(2, 1); + $this->assertCounterOfPage(3, 1); + } + + /** + * @test + */ + public function opensVariantStoredInCookie(): void + { + $this->opensVariantAForFirstVisitor(); + + $request = new InternalRequest(); + $request = $request->withPageId(2); + $request = $request->withAddedHeader('Cookie', 'ab-2=2'); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsCached($result); + $this->assertCookie($result, 'ab-2', '2'); + // 1 from first visit, but not 2 as 2nd visit is via cookie. + $this->assertCounterOfPage(2, 1, 'Opening from cookie should not increase counter.'); + $this->assertCounterOfPage(3, 0, 'Opening from cookie should not increase counter.'); + } + + /** + * @test + */ + public function opensDefaultPageIfBotWasDetected(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(2); + $request = $request->withAddedHeader('User-Agent', 'Storebot-Google'); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + $this->assertCookieWasNotSet($result); + $this->assertCounterOfPage(2, 0); + } + + /** + * @test + */ + public function opensRequestedPageIfVariantPageDoesNotExist(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(4); + $request = $request->withAddedHeader('Cookie', 'ab-4=5'); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 4 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + $this->assertCookie($result, 'ab-4', '4'); + $this->assertCounterOfPage(4, 1); + $this->assertCounterOfPage(5, 0); + } + + /** + * @test + */ + public function opensRequestedPageIfCookieDoesNotMatchRequestedPage(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(2); + $request = $request->withAddedHeader('Cookie', 'ab-2=5'); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + $this->assertCookie($result, 'ab-2', '2'); + $this->assertCounterOfPage(2, 1); + $this->assertCounterOfPage(5, 0); + } + + /** + * @test + */ + public function opensVariantBForSecondVisitorIfVariantFromCookieDoesNotMatchVariantB(): void + { + $this->opensVariantAForFirstVisitor(); + + $request = new InternalRequest(); + $request = $request->withPageId(2); + $request = $request->withAddedHeader('Cookie', 'ab-2=5'); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + $this->assertCookie($result, 'ab-2', '3'); + $this->assertCounterOfPage(2, 1); + $this->assertCounterOfPage(3, 1); + } + + /** + * @test + */ + public function cookieHasDefaultLifetime(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(2); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + $cookie = Cookie::fromString($result->getHeaderLine('Set-Cookie')); + self::assertSame(604800, $cookie->getMaxAge()); + } + + /** + * @test + */ + public function cookieHasConfiguredLifetime(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(4); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + $cookie = Cookie::fromString($result->getHeaderLine('Set-Cookie')); + self::assertSame(2419200, $cookie->getMaxAge()); + } + + /** + * Ensure TYPO3 caching works as expected. + * The first call should create a proper cache entry. + * We should still be able to retrieve the other variant by adding the cookie. + * The 2nd variant should also be delivered from cache on 2nd request. + * + * @test + */ + public function returnsCachedPage(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(2); + $result = $this->executeFrontendRequest($request); + self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + + $request = new InternalRequest(); + $request = $request->withPageId(2); + $request = $request->withAddedHeader('Cookie', 'ab-2=2'); + $result = $this->executeFrontendRequest($request); + self::assertStringContainsString('Page 2 Title (Variant A)', $result->getBody()->__toString()); + $this->assertPageIsCached($result); + + $request = new InternalRequest(); + $request = $request->withPageId(2); + $result = $this->executeFrontendRequest($request); + self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString()); + $this->assertPageIsNotCached($result); + + $request = new InternalRequest(); + $request = $request->withPageId(2); + $request = $request->withAddedHeader('Cookie', 'ab-2=3'); + $result = $this->executeFrontendRequest($request); + self::assertStringContainsString('Page 3 Title (Variant B)', $result->getBody()->__toString()); + $this->assertPageIsCached($result); + } + + private function assertCounterOfPage( + int $pageUid, + int $expectedCounter, + string $message = '' + ): void { + $actualCounter = $this->getConnectionPool() + ->getConnectionForTable('pages') + ->select(['tx_abtest_counter'], 'pages', ['uid' => $pageUid]) + ->fetchFirstColumn()[0] ?? 0 + ; + + self::assertSame( + $expectedCounter, + $actualCounter, + 'Counter for page ' . $pageUid . ' was not as expected. ' . $message + ); + } + + private function assertCookie( + InternalResponse $result, + string $name, + string $value + ): void { + $cookie = Cookie::fromString($result->getHeaderLine('Set-Cookie')); + self::assertSame($name, $cookie->getName()); + self::assertSame($value, $cookie->getValue()); + self::assertSame('/', $cookie->getPath()); + self::assertSame('lax', $cookie->getSameSite()); + self::assertNull($cookie->getDomain()); + } + + private function assertCookieWasNotSet(InternalResponse $result): void + { + self::assertSame( + '', + $result->getHeaderLine('Set-Cookie'), + 'Cookie was set but was not expected to be set.' + ); + } + + private function assertPageIsNotCached(InternalResponse $result): void + { + self::assertSame('', $result->getHeaderLine('X-TYPO3-Debug-Cache')); + } + + private function assertPageIsCached(InternalResponse $result): void + { + self::assertStringStartsWith('Cached page generated', $result->getHeaderLine('X-TYPO3-Debug-Cache')); + } +} diff --git a/composer.json b/composer.json index fd1d400..3f1f11d 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,77 @@ { - "name": "svewap/abtest2", + "name": "werkraummedia/abtest", "type": "typo3-cms-extension", - "description": "", - "authors": [], + "license": "GPL-2.0-or-later", + "description": "Provides A/B Testing for TYPO3.", + "homepage": "https://github.com/werkraum-media/abtest", + "support": { + "docs": "https://docs.typo3.org/p/werkraummedia/abtest/master/en-us/", + "email": "coding@daniel-siepmann.de", + "issues": "https://github.com/werkraum-media/abtest/issues", + "source": "https://github.com/werkraum-media/abtest" + }, + "authors": [ + { + "name": "Sven Wappler", + "email": "typo3YYYY@wappler.systems", + "homepage": "https://wappler.systems/", + "role": "Developer" + }, + { + "name": "Daniel Siepmann", + "email": "coding@daniel-siepmann.de", + "homepage": "https://daniel-siepmann.de/", + "role": "Developer" + } + ], "require": { - "typo3/cms-core": "^8.7.1" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0", + "typo3/cms-core": "^11.5", + "typo3/cms-frontend": "^11.5", + "matomo/device-detector": "^6.1", + "symfony/http-foundation": "^5.4", + "psr/http-message": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "typo3/testing-framework": "^6.16", + "phpunit/phpunit": "^9.6", + "friendsofphp/php-cs-fixer": "^3.14", + "cweagans/composer-patches": "^1.7" }, "autoload": { "psr-4": { - "WapplerSystems\\ABTest2\\": "Classes" + "WerkraumMedia\\ABTest\\": "Classes/" + } + }, + "autoload-dev": { + "psr-4": { + "WerkraumMedia\\ABTest\\Tests\\": "Tests/" + } + }, + "extra": { + "typo3/cms": { + "cms-package-dir": "{$vendor-dir}/typo3/cms", + "extension-key": "abtest", + "web-dir": ".Build/web" + }, + "patches": { + "typo3/testing-framework": { + "Allow to test requests with cookies": "patches/testing-framework-cookies.patch" + } + } + }, + "scripts": { + "post-autoload-dump": [ + "@php -r 'is_dir($extFolder=__DIR__.\"/.Build/web/typo3conf/ext/\") || mkdir($extFolder, 0777, true);'", + "@php -r 'file_exists($extFolder=__DIR__.\"/.Build/web/typo3conf/ext/abtest\") || symlink(__DIR__,$extFolder);'" + ] + }, + "config": { + "allow-plugins": { + "typo3/class-alias-loader": true, + "typo3/cms-composer-installers": true, + "cweagans/composer-patches": true } } } diff --git a/ext_emconf.php b/ext_emconf.php deleted file mode 100644 index 974e855..0000000 --- a/ext_emconf.php +++ /dev/null @@ -1,37 +0,0 @@ - 'AB Test Pages', - 'description' => 'With this extension, administrators can deliver different content for the same URL (AB test), depending on cookies or least showed page to realize a most accurate possible test.', - 'category' => 'misc', - 'author' => 'Sven Wappler', - 'author_email' => 'typo3YYYY@wappler.systems', - 'author_company' => 'WapplerSystems', - 'state' => 'stable', - 'uploadfolder' => false, - 'createDirs' => '', - 'clearCacheOnLoad' => true, - 'version' => '1.0.0', - 'constraints' => - [ - 'depends' => - [ - 'typo3' => '7.6.0-9.5.99', - ], - 'conflicts' => - [], - 'suggests' => - [], - ], -]; - diff --git a/ext_localconf.php b/ext_localconf.php index b0bab82..9077cde 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -2,5 +2,4 @@ defined('TYPO3_MODE') or die(); - -$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/class.tslib_fe.php']['determineId-PostProc']['abtest2'] = 'WapplerSystems\\ABTest2\\Helper->determineContentId'; \ No newline at end of file +\WerkraumMedia\ABTest\Switcher::register(); diff --git a/ext_tables.php b/ext_tables.php deleted file mode 100644 index 9be9d9a..0000000 --- a/ext_tables.php +++ /dev/null @@ -1,3 +0,0 @@ -request->hasHeader('Cookie')) { ++ foreach ($this->request->getHeader('Cookie') as $cookie) { ++ [$cookieName, $cookieValue] = explode('=', $cookie, 2); ++ $_COOKIE[$cookieName] = rtrim($cookieValue, ';'); ++ } ++ } + + // Setting up the server environment + $_SERVER = []; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e21df3f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + level: max + paths: + - Classes + - Configuration + - Tests + checkMissingIterableValueType: false + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..144e19f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + Tests/Functional/ + + + + + + Classes + + + + + + + diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..73f1769 --- /dev/null +++ b/shell.nix @@ -0,0 +1,83 @@ +{ pkgs ? import { } }: + +let + + php = pkgs.php81.buildEnv { + extensions = { enabled, all }: enabled ++ (with all; [ + xdebug + ]); + + extraConfig = '' + xdebug.mode = debug + + memory_limit = 4G + ''; + }; + composer = pkgs.php81Packages.composer.override { + inherit php; + }; + + projectInstall = pkgs.writeShellApplication { + name = "project-install"; + runtimeInputs = [ + php + composer + ]; + text = '' + composer install --no-interaction --prefer-dist --no-progress --working-dir="$PROJECT_ROOT" + ''; + }; + projectValidateComposer = pkgs.writeShellApplication { + name = "project-validate-composer"; + runtimeInputs = [ + php + composer + ]; + text = '' + composer validate + ''; + }; + projectValidateXml = pkgs.writeShellApplication { + name = "project-validate-xml"; + runtimeInputs = [ + pkgs.libxml2 + pkgs.wget + projectInstall + ]; + text = '' + project-install + xmllint --schema vendor/phpunit/phpunit/phpunit.xsd --noout phpunit.xml.dist + wget --no-check-certificate https://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd --output-document=xliff-core-1.2-strict.xsd + # shellcheck disable=SC2046 + xmllint --schema xliff-core-1.2-strict.xsd --noout $(find Resources -name '*.xlf') + ''; + }; + projectCodingGuideline = pkgs.writeShellApplication { + name = "project-coding-guideline"; + runtimeInputs = [ + php + projectInstall + ]; + text = '' + project-install + ./vendor/bin/php-cs-fixer fix --dry-run --diff + ''; + }; + +in pkgs.mkShell { + name = "TYPO3 Extension abtest"; + buildInputs = [ + php + composer + + projectValidateComposer + projectValidateXml + projectCodingGuideline + ]; + + shellHook = '' + export PROJECT_ROOT="$(pwd)" + + export typo3DatabaseDriver=pdo_sqlite + ''; +}