diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index fd6c8745af7d855ef2daaabee9cc6c0a16d6694b..f179bb2b1d54f7cdadaa8607149ea1cf59cdf8d6 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -1849,6 +1849,8 @@ const COLUMN_STORE_USER = '='; const COLUMN_FORM_JSON = 'formJson'; +const COLUMN_JWT = 'jwt'; + // Author: Enis Nuredini const COLUMN_ENCRYPT = 'encrypt'; const COLUMN_DECRYPT = 'decrypt'; diff --git a/extension/Classes/Core/Helper/JWT.php b/extension/Classes/Core/Helper/JWT.php deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/extension/Classes/Core/Parser/KVPairListParser.php b/extension/Classes/Core/Parser/KVPairListParser.php new file mode 100644 index 0000000000000000000000000000000000000000..279fd69b9dedd543178a09c3536a6170fe99edb0 --- /dev/null +++ b/extension/Classes/Core/Parser/KVPairListParser.php @@ -0,0 +1,52 @@ +<?php + +namespace IMATHUZH\Qfq\Core\Parser; + + +class KVPairListParser extends SimpleParser +{ + private string $listsep; + private string $kvsep; + + + public function __construct(string $listsep, string $kvsep, array $options = []) + { + parent::__construct("$listsep$kvsep", $options); + $this->listsep = $listsep; + $this->kvsep = $kvsep; + } + + public function iterate(string $data): \Generator + { + $tokens = $this->tokenized($data); + while ($tokens->valid()) { + list($keyToken, $delimiter) = $tokens->current(); + $key = strval($keyToken); + $tokens->next(); + if ($delimiter == $this->kvsep) { + if ($tokens->valid()) { + list($valueToken, $delimiter) = $tokens->current(); + $tokens->next(); + $empty = $valueToken->empty(); + $value = $this->process($valueToken); + } else { + $delimiter = null; + $empty = true; + } + yield $key => $empty ? ( + $this->options['key-is-value'] ? $key : $this->options['empty'] + ) : $value; + } elseif ($key) { + yield $key => $this->options['key-is-value'] ? $key : $this->options['empty']; + } + if ($delimiter && $delimiter != $this->listsep) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->listsep + ); + } + }; + // Trailing commas are ok for objects + } +} \ No newline at end of file diff --git a/extension/Classes/Core/Parser/MixedTypeParser.php b/extension/Classes/Core/Parser/MixedTypeParser.php new file mode 100644 index 0000000000000000000000000000000000000000..2d9ae35aa3eece4e4a91fbcccae2b7fe670e30ec --- /dev/null +++ b/extension/Classes/Core/Parser/MixedTypeParser.php @@ -0,0 +1,180 @@ +<?php + +namespace IMATHUZH\Qfq\Core\Parser; + + + +class MixedTypeParser extends SimpleParser +{ + const SEP = 0; + const KVSEP = 1; + const LIST_START = 2; + const LIST_END = 3; + const DICT_START = 4; + const DICT_END = 5; + + /** @var string delimiters used by this parser */ + private string $delimiters = ',:[]{}'; + + public function __construct(?string $delimiters = null, array $options = []) + { + if ($delimiters) { + $this->delimiters = str_pad($delimiters, 6); + $delimiters = str_replace(' ', '', $delimiters); + } else { + $delimiters = $this->delimiters; + } + parent::__construct($delimiters, $options); + } + + /** + * Parses the provided string into a literal value, a non-associative array, + * or an associative array. The structures can be nested. + * + * @param string $data + * @return mixed + */ + public function parse(string $data) + { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + list($data, $empty, $delimiter) = $this->parseImpl($tokens); + if ($delimiter) $this->raiseUnexpectedDelimiter($delimiter, $this->offset()); + return $data; + } else { + return $this->options['empty']; + } + } + + /** + * Assumes the provided string is a list of items and parses + * it into a non-associative array (possibly empty or with only + * one element). + * + * @param string $data + * @return array + */ + public function parseList(string $data): array + { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + return $this->parseListImpl($tokens, null)[0]; + } else { + return []; + } + } + + /** + * Assumes that the provided string is a dictionary (i.e. a list + * of key-value pairs) and parses it into an associative array. + * + * @param string $data + * @return array + */ + public function parseDictionary(string $data): array + { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + return $this->parseDictionaryImpl($tokens, null)[0]; + } else { + return []; + } + } + + + protected function parseImpl(\Generator $tokens): array + { + $tokenData = $tokens->current(); + $tokens->next(); + list($token, $delimiter) = $tokenData; + $empty = $token->empty(); + switch ($delimiter) { + case $this->delimiters[self::DICT_START]: + $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null); + return $this->parseDictionaryImpl($tokens, $this->delimiters[self::DICT_END]); + case $this->delimiters[self::LIST_START]: + $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null); + return $this->parseListImpl($tokens, $this->delimiters[self::LIST_END]); + case null: + return [$this->process($token), $empty, null]; + default: + return [$this->process($token), $empty, $delimiter]; + } + } + + private function checkNextDelimiter(\Generator $tokens, ?string $current): ?string { + if ($current && $tokens->valid()) { + list($token, $next) = $tokens->current(); + $token->empty() or $this->raiseUnexpectedToken($this->offset(), $current, $next); + $tokens->next(); + return $next; + } else { + return null; + } + } + + protected function parseListImpl(\Generator $tokens, ?string $endDelimiter): array + { + $result = []; + do { + list($value, $empty, $delimiter) = $this->parseImpl($tokens); + if ($delimiter == $endDelimiter) { + // Add an empty element only if there was a comma + if (!$empty || $result) $result[] = $value; + $delimiter = $this->checkNextDelimiter($tokens, $delimiter); + return [$result, false, $delimiter]; + } elseif ($delimiter != $this->delimiters[self::SEP]) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->delimiters[self::SEP] . $endDelimiter + ); + } else { + $result[] = $value; + } + } while ($tokens->valid()); + // The string ended with a comma - append an empty string unless + // the list is expected to end with a delimiter + if ($endDelimiter) $this->raiseUnexpectedEnd($endDelimiter); + $result[] = $this->options['empty']; + return [$result, false, null]; + } + + + protected function parseDictionaryImpl(\Generator $tokens, ?string $endDelimiter): array + { + $result = []; + do { + $value = null; + list($key, $delimiter) = $tokens->current(); + $key = strval($key); + $tokens->next(); + if ($delimiter == $this->delimiters[self::KVSEP]) { + if ($tokens->valid()) { + list($value, $empty, $delimiter) = $this->parseImpl($tokens); + } else { + $delimiter = null; + $empty = true; + } + $result[$key] = $empty ? ( + $this->options['key-is-value'] ? $key : $this->options['empty'] + ) : $value; + } elseif ($key) { + $result[$key] = $this->options['key-is-value'] ? $key : $this->options['empty']; + } + if ($delimiter == $endDelimiter) { + $delimiter = $this->checkNextDelimiter($tokens, $delimiter); + return [$result, false, $delimiter]; + } elseif ($delimiter != $this->delimiters[self::SEP]) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->delimiters[self::SEP] . $endDelimiter + ); + } + } while ($tokens->valid()); + // Trailing commas are ok for objects + return [$result, false, null]; + } + +} \ No newline at end of file diff --git a/extension/Classes/Core/Parser/SimpleParser.php b/extension/Classes/Core/Parser/SimpleParser.php new file mode 100644 index 0000000000000000000000000000000000000000..20200c385b327e94d090463a2fda73d86304f9e9 --- /dev/null +++ b/extension/Classes/Core/Parser/SimpleParser.php @@ -0,0 +1,134 @@ +<?php + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Represents a number prefixed with a sign. This allows to treat such values + * differently from absolute numbers. + * @package qfq + */ +class SignedNumber implements \JsonSerializable { + + /** @var int|float the value of the number */ + public $value; + public function __construct($value) { + $this->value = $value; + } + + public function __toString(): string { + return $this->value > 0 ? "+$this->value" : "$this->value"; + } + + public function jsonSerialize() + { + return $this->value; + } +} + +/** + * A basic parser that splits the provided string at unescaped and unquoted delimiters. + * Token processing recognizes numbers and specials values. + */ +class SimpleParser extends StringTokenizer +{ + const OPTION_KEEP_SIGN = 'keep-sign'; + const OPTION_KEY_IS_VALUE = 'key-is-value'; + const OPTION_EMPTY_VALUE = 'empty'; + const OPTION_PARSE_NUMBERS = 'parse-numbers'; + + public array $specialValues = [ + 'null' => null, + 'false' => false, + 'true' => true, + 'yes' => true, + 'no' => false + ]; + + /** @var array a configuration of the parser */ + public array $options = [ + self::OPTION_KEEP_SIGN => false, // if true, tokens "+number" and "-number" are converted to instances of SignedNumber + self::OPTION_KEY_IS_VALUE => false, // if true, a key with no value is assigned its name + self::OPTION_EMPTY_VALUE => null, // the value used for empty tokens + self::OPTION_PARSE_NUMBERS => true // if true, tokens are replaced with numbers if possible + ]; + + public function __construct(string $delimiters, array $options = []) + { + parent::__construct($delimiters); + $this->options = array_merge($this->options, $options); + } + + /** + * Processes a token into a string, number, or special value. + * @return mixed + */ + protected function process(Token $token) + { + $asString = strval($token); + if ($token->isString) { + return $asString; + } elseif ($asString === '') { + return $this->options[self::OPTION_EMPTY_VALUE]; + } elseif ($this->options[self::OPTION_PARSE_NUMBERS] && is_numeric($asString)) { + if (preg_match("/^[+-]?\d+$/", $asString)) { + $value = intval($asString); + } else { + $value = floatval($asString); + } + return ($this->options[self::OPTION_KEEP_SIGN] && ($asString[0] === '+' || $asString[0] === '-')) + ? new SignedNumber($value) : $value; + } elseif (array_key_exists($asString, $this->specialValues)) { + return $this->specialValues[$asString]; + } else { + return $asString; + } + } + + + protected static function raiseUnexpectedDelimiter($delimiter, $position, $expected=null) + { + $msg = "An unexpected '$delimiter' at position $position"; + if ($expected) { + $msg .= " while expecting " . implode(' or ', str_split($expected)); + } + throw new \RuntimeException($msg); + } + + protected static function raiseUnexpectedEnd($expected) + { + throw new \RuntimeException("An unexpected end while searching for '$expected'"); + } + + protected static function raiseUnexpectedToken($position, $before, $after) + { + $msg = "An unexpected token at $position"; + $extra = []; + if ($before) $extra[] = " before '$before'"; + if ($after) $extra[] = " after '$after'"; + $msg .= implode('and ', $extra); + throw new \RuntimeException($msg); + } + + /** + * Parses the provided string into a list of values separated by delimiters. + * + * @param string $data + * @return mixed + */ + public function parse(string $data) + { + return iterator_to_array($this->iterate($data)); + } + + /** + * Iterates over pieces of a string separated by unescaped delimiters. + * + * @param string $data + * @return \Generator + */ + public function iterate(string $data): \Generator + { + foreach ($this->tokenized($data) as $token) { + yield $this->process($token[0]); + } + }} \ No newline at end of file diff --git a/extension/Classes/Core/Parser/StringTokenizer.php b/extension/Classes/Core/Parser/StringTokenizer.php new file mode 100644 index 0000000000000000000000000000000000000000..63decd8c2e79569d81a3cf7c1a5e3bbac01151d2 --- /dev/null +++ b/extension/Classes/Core/Parser/StringTokenizer.php @@ -0,0 +1,128 @@ +<?php + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Class StringTokenizer + * @project qfq + */ +class StringTokenizer +{ + private string $delimitersPattern; + + protected TokenBuilder $tokenBuilder; + + private int $currentOffset = 0; + + public function offset(): int { + return $this->currentOffset-1; + } + + public function __construct(string $delimiters) + { + $escapedDelimiters = str_replace( + ['[', ']', '/', '.'], + ['\\[', '\\]', '\\/', '\\.'], + $delimiters + ); + $this->delimitersPattern = "/[$escapedDelimiters\\\\\"']/"; + $this->tokenBuilder = new TokenBuilder(); + } + + /** + * Iterates over unescaped delimiters and quotes from a provided string. + * At each iteration returns a triple consisting of + * - the substring bound by the previous and current delimiter + * - the delimiter + * - the offset of the delimiter (mostly for errors and statistics) + * The delimiter and offset are null when the end of the string is reached. + * + * @param string $data the searched string + * @return \Generator triples [substring, delimiter, offset] + */ + protected function unescapedDelimitersAndQuotes(string $data): \Generator + { + $this->tokenBuilder->reset(); + $this->currentOffset = 0; + if (preg_match_all($this->delimitersPattern, $data, $delimiters, PREG_OFFSET_CAPTURE)) { + // If non-empty, $delimiters is an array with one element: + // a list of pairs [delimiter, offset] + $delimiters = $delimiters[0]; + $tokenData = current($delimiters); + while ($tokenData) { + list($delimiter, $offset) = $tokenData; + if ($delimiter == '\\') { + // The next character is escaped. If it is a delimiter, + // then we will ignore it and continue the search. + $tokenData = next($delimiters); + if (!$tokenData) { + // No more delimiters - we have reached the end of the string. + // We return the remaining part of the string outside the loop. + break; + } elseif ($tokenData[1] == $offset + 1) { + // This delimiter or quote is escaped by the backslash + if ($tokenData[0] != '\\') { + $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset-$this->currentOffset)); + $this->currentOffset = $tokenData[1]; + } + $tokenData = next($delimiters); + continue; + } + } + // An unescaped delimiter has been found + $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset-$this->currentOffset)); + $this->currentOffset = $offset + 1; + yield $delimiter; + $tokenData = next($delimiters); + } + } + } + + /** + * Iterates over unescaped and unquoted delimiters from a provided string. + * Unescaped quotes must match and are removed. + * At each iteration returns a triple consisting of + * - the substring bound by the previous and current delimiter + * - the delimiter + * - the offset of the delimiter (mostly for errors and statistics) + * The delimiter and offset are null when the end of the string is reached. + * + * @param string $data the string to search for delimiters + * @return \Generator triples [token, delimiter, offset] + */ + public function tokenized(string $data): \Generator + { + $delimitersAndQuotes = $this->unescapedDelimitersAndQuotes($data); + while ($delimitersAndQuotes->valid()) { + $delimiter = $delimitersAndQuotes->current(); + if ($delimiter === '"' || $delimiter === "'") { + // We will search for the matching quote and put everything + // in between to the token + $quote = $delimiter; + $this->tokenBuilder->markQuote(); + while (true) { + $delimitersAndQuotes->next(); + if (!$delimitersAndQuotes->valid()) { + throw new \RuntimeError("An unexpected end while searching for '$delimiter'"); + } + $delimiter = $delimitersAndQuotes->current(); + if ($delimiter === $quote) { + $this->tokenBuilder->markQuote(); + break; + } + $this->tokenBuilder->pieces[] = $delimiter; + }; + } else { + // An unescaped delimiter: return the current token + // and prepare to build the next one + yield [$this->tokenBuilder->process(), $delimiter]; + } + $delimitersAndQuotes->next(); + } + // No more delimiters: add the rest of the string and process the token + $this->tokenBuilder->pieces[] = substr($data, $this->currentOffset); + $token = $this->tokenBuilder->process(); + $token->empty() or yield [$token, null]; + } +} + diff --git a/extension/Classes/Core/Parser/Token.php b/extension/Classes/Core/Parser/Token.php new file mode 100644 index 0000000000000000000000000000000000000000..aaed7b23fbb363e583eb9e8c6cefbf6da6c78ff6 --- /dev/null +++ b/extension/Classes/Core/Parser/Token.php @@ -0,0 +1,39 @@ +<?php + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * A token returned by StringTokenizer when parsing a string. + * It represents a part of the string except that unescaped quotes and backslashes + * before delimiters and quotes are removed. + * @package qfq + */ +class Token +{ + + /** + * @var string The string wrapped by the token. + */ + public string $value; + + public bool $isString; + + public function __construct(string $value, bool $isString) { + $this->value = $value; + $this->isString = $isString; + } + + /** + * Returns true when the token is empty. In particular, this means + * that there were no quotes in the token. + * @return bool + */ + public function empty() : bool + { + return $this->value === '' && !$this->isString; + } + + public function __toString(): string { + return $this->value; + } +} diff --git a/extension/Classes/Core/Parser/TokenBuilder.php b/extension/Classes/Core/Parser/TokenBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..f52db40c157b3aa641add559baee95b89d668eb6 --- /dev/null +++ b/extension/Classes/Core/Parser/TokenBuilder.php @@ -0,0 +1,76 @@ +<?php + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * A helper class used by StringTokenizer to build a token. + * @package qfq + */ +class TokenBuilder +{ + public int $firstQuoteOffset = -1; + public int $lastQuoteOffset = -1; + + /** + * @var array A list of substrings that form the token. Splits occur at escaped + * delimiters and unescaped quotes so that they do not appear in a final string. + */ + public array $pieces = []; + + public int $length = 0; + + /** + * Returns true when an unescaped quote has been found + * while creating the token. + * @return bool + */ + public function hasQuotes(): bool { + return $this->firstQuoteOffset >= 0; + } + + /** + * Resets the builder to its initial state + * @return void + */ + public function reset() + { + $this->pieces = []; + $this->firstQuoteOffset = -1; + $this->lastQuoteOffset = -1; + $this->length = 0; + } + + /** + * Processes the data to a token and resets the builder + * @return Token + */ + public function process(): Token + { + $value = implode('', $this->pieces); + if ($this->hasQuotes()) { + $value = ltrim(substr($value, 0, $this->firstQuoteOffset)) . + substr($value, $this->firstQuoteOffset, $this->lastQuoteOffset-$this->firstQuoteOffset) . + rtrim(substr($value, $this->lastQuoteOffset)); + } else { + $value = trim($value); + } + $token = new Token($value, $this->hasQuotes()); + $this->reset(); + return $token; + } + + public function append(string $data) + { + $this->pieces[] = $data; + $this->length += strlen($data); + } + + public function markQuote() + { + if ($this->firstQuoteOffset < 0) { + $this->firstQuoteOffset = $this->length; + } else { + $this->lastQuoteOffset = $this->length; + } + } +} diff --git a/extension/Classes/Core/Report/Report.php b/extension/Classes/Core/Report/Report.php index c5dad53c847bcc7ace1a5be6d0b1507dcf12cfe1..52c4df00174f6e2107113c48c839929bf61d1098 100644 --- a/extension/Classes/Core/Report/Report.php +++ b/extension/Classes/Core/Report/Report.php @@ -34,10 +34,15 @@ use IMATHUZH\Qfq\Core\Helper\OnString; use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\Helper\Sanitize; use IMATHUZH\Qfq\Core\Helper\Support; +use IMATHUZH\Qfq\Core\Parser\MixedTypeParser; +use IMATHUZH\Qfq\Core\Parser\SignedNumber; +use IMATHUZH\Qfq\Core\Parser\SimpleParser; use IMATHUZH\Qfq\Core\Store\Sip; use IMATHUZH\Qfq\Core\Store\Store; use IMATHUZH\Qfq\Core\Typo3\T3Handler; +use Firebase\JWT\JWT; + const DEFAULT_QUESTION = 'question'; const DEFAULT_ICON = 'icon'; const DEFAULT_BOOTSTRAP_BUTTON = 'bootstrapButton'; @@ -1341,6 +1346,54 @@ class Report { $content .= Support::encryptDoubleCurlyBraces(FormAsFile::renderColumnFormJson($columnValue, $dbQfq)); break; + // Author: Krzysztof Putyra + case COLUMN_JWT: + /* Converts a string + * claim1:value, claim2:value, ... | key | alg + * into a json web token. Parameters: + * - alg the name of the signing algorithm (default: HS256) + * - key the secret key used by the signing algorithm + * Standard claims with an extended interpretation of values: + * - iss the issuer of the token (default: qfq) + * - iat the timestamp the token has been issued (default: current) + * - exp the expiration timestamp or the number of seconds till invalid (if prefixed with '+') + * - nbf the timestamp from when or (if prefixed with '+') the number of seconds after which the token is valid + */ + + // Split the column into |-separated sections + $parser = new SimpleParser('|', [ + SimpleParser::OPTION_EMPTY_VALUE => '' + ]); + $splitContent = $parser->parse($columnValue); + // Check that key is provided + if (count($splitContent) < 2) { + throw new \UserReportException("JWT requires a secret key, but it is missing"); + } + + // Parse the payload + $currentTime = time(); + $parser = new MixedTypeParser(null, [ + SimpleParser::OPTION_KEEP_SIGN => true + ]); + $claims = array_merge( + ['iss' => 'qfq', 'iat' => $currentTime], + $parser->parseDictionary($splitContent[0]) + ); + foreach (['exp', 'nbf', 'iat'] as $claim) { + $value = $claims[$claim] ?? 0; + if ($value instanceof SignedNumber) { + $claims[$claim] = $value->value + $currentTime; + } + } + + // Create the token + $content .= JWT::encode( + $claims, + $splitContent[1], + $splitContent[2] ?? 'HS256' + ); + break; + // Author: Enis Nuredini case COLUMN_ENCRYPT: $encryptionMethodColumn = $this->store->getVar(SYSTEM_ENCRYPTION_METHOD, STORE_SYSTEM, SANITIZE_ALLOW_ALL); diff --git a/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php b/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2ee2914ece99a6df9d4a0f618125f49415e4afb2 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\KVPairListParser; +use PHPUnit\Framework\TestCase; + +class KVPairListParserTest extends TestCase +{ + public function testEmptyString() + { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + [], + $parser->parse('') + ); + } + + public function testSinglePair() + { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals(['a' => 42], $parser->parse('a:42')); + } + + public function testSingleKey() + { + $parser = new KVPairListParser('|', ':', ['key-is-value' => true]); + $this->assertEquals(['a' => 'a'], $parser->parse('a:')); + + $parser = new KVPairListParser('|', ':', ['key-is-value' => false, 'empty' => 18]); + $this->assertEquals(['a' => 18], $parser->parse('a:')); + $this->assertEquals(['a' => 18], $parser->parse('a')); + } + + public function testSimpleList() + { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab'=>'x','cd'=>'y','ef'=>'z'], + $parser->parse('ab:x|cd:y|ef:z') + ); + } + + public function testEscapedSeparators() + { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab' => 'x','cd:y|ef' => 'z:a'], + $parser->parse('ab:x|cd\\:y\\|ef:z\\:a') + ); + } + + public function testQuotedSeparators() + { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab' => 'x','cd:y| ef' => 'z:a'], + $parser->parse('ab:x|"cd:y| ef":z":a"') + ); + } + + public function testIterate() + { + $parser = new KVPairListParser('|', ':'); + $iterator = $parser->iterate('a:1|b:2|c:3'); + $expected = ['a' => 1, 'b' => 2, 'c' => 3]; + foreach($iterator as $key => $value) { + $expectedKey = key($expected); + $expectedValue = current($expected); + $this->assertSame($expectedKey, $key); + $this->assertSame($expectedValue, $value); + next($expected); + } + $this->assertFalse(current($expected)); + + } +} + diff --git a/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php b/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7e921f53be7948bc09998c7c36d139beec1e9126 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php @@ -0,0 +1,210 @@ +<?php +/** + * Created by PhpStorm. + * User: megger + * Date: 12/17/18 + * Time: 9:14 AM + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\MixedTypeParser; +use PHPUnit\Framework\TestCase; + +/** + * Class MixedTypeParserTest + * @package qfq + */ +class MixedTypeParserTest extends TestCase { + + protected function assertGeneratorOutput($generator, $output) { + foreach ($output as $value) { + $data = $generator->current(); + $data[0] = strval($data[0]); + $this->assertSame($value, $data, "Expecting " . json_encode($value)); + $generator->next(); + } + $this->assertNull($generator->current()); + } + + + public function testLiteralValues() + { + $parser = new MixedTypeParser(null, ['empty' => 18]); + $data = [ + 'abc' => 'abc', ' x\\:y ' => 'x:y', + '' => 18, '""' => '', + ' ' => 18, '" "' => ' ', + '123' => 123, '"123"' => '123', + '12.5' => 12.5, '"12.5"' => '12.5', + 'false' => false, '"false"' => 'false', + 'true' => true, '"true"' => 'true', + 'yes' => true, '"yes"' => 'yes', + 'no' => false, '"no"' => 'no', + 'null' => null, '"null"' => 'null' + ]; + foreach ($data as $input => $expected) + $this->assertSame($expected, $parser->parse($input)); + } + + public function testParseFlatList() + { + $parser = new MixedTypeParser(); + // Empty list + $this->assertEquals([], $parser->parseList('')); + $this->assertEquals([], $parser->parseList(' ')); + // Compact list + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parseList("a,b,c,d") + ); + // Spaces are stripped + $this->assertEquals( + ['a', 'b', 'c'], + $parser->parseList("a, b, c ") + ); + // Internal spaces are preserved + $this->assertEquals( + ['a', 'b c', 'd'], + $parser->parseList("a, b c , d") + ); + // Escaped commas are ignored + $this->assertEquals( + ['a,b', 'c'], + $parser->parseList("a\\,b,c") + ); + // Quoted commas are ignored + $this->assertEquals( + ['a, b', 'c'], + $parser->parseList("'a, b', c") + ); + // Trailing comma adds an empty element + $this->assertEquals( + [null, null, 'c', null], + $parser->parseList(",,c,") + ); + } + + public function testParseListValues() + { + $parser = new MixedTypeParser(); + $this->assertEquals([], $parser->parse("[]")); + $this->assertEquals([], $parser->parse("[ ]")); + + // $this->assertEquals([''], $parser->parse("['']")); + + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parse("[a,b,c,d]") + ); + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parse(" [a, b, c ,d] ") + ); + + } + + public function testParseFlatDictionary() + { + $parser = new MixedTypeParser(); + // Empty object + $this->assertEquals([], $parser->parseDictionary('')); + $this->assertEquals([], $parser->parseDictionary(' ')); + // Compact dictionary + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parseDictionary("a:1,b:2,c:3,d:4") + ); + // Spaces are stripped + $this->assertEquals( + ['a'=>'1', 'b'=>'2'], + $parser->parseDictionary(" a : 1, b: 2\n\t ") + ); + // Internal spaces are preserved + $this->assertEquals( + ['a b'=>'1', 'b'=>'2 3 4'], + $parser->parseDictionary(" a b: 1, b: 2 3 4") + ); + // Escaped delimiters are ignored + $this->assertEquals( + ['a,b'=>'1', 'b'=>'2:3,4'], + $parser->parseDictionary("a\\,b:1,b:2\\:3\\,4") + ); + // Quoted delimiters are ignored + $this->assertGeneratorOutput( + $parser->tokenized("'a:1,b':\"23,4\""), + [['a:1,b',':'],['23,4',null]] + ); + $this->assertEquals( + ['a:1,b' => '23,4'], + $parser->parseDictionary("'a:1,b':\"23,4\"") + ); + // Trailing commas are ignored + $this->assertEquals( + ['a' => 1, 'b' => 2], + $parser->parseDictionary('a:1, b:2, ') + ); + } + + public function testParseDictValues() + { + $parser = new MixedTypeParser(); + $this->assertEquals([], $parser->parse("{}")); + $this->assertEquals([], $parser->parse("{ }")); + + $this->assertEquals(['a' => null], $parser->parse("{a:}")); + + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parse("{a:1,b:2,c:3,d:4}") + ); + + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parse(" { a:1, b:2, c : 3 ,d:4 } ") + ); + + } + + public function testParseNestedStructures() + { + $parser = new MixedTypeParser(); + // Nested lists + $this->assertEquals( + ['a', 'b', ['ca', 'cb'], 'd'], + $parser->parse("[a, b, [ca, cb], d]") + ); + // Dictionary nested in a list + $this->assertEquals( + ['a', 'b', ['ca'=>'0', 'cb'=>'1'], 'd'], + $parser->parse("[a, b, {ca:0, cb:1}, d]") + ); + // List nested in a dictionary + $this->assertEquals( + ['a'=>'0', 'b'=>'1', 'c'=>['ca', 'cb'], 'd'=>'3'], + $parser->parse("{a:0, b:1, c:[ca, cb], d:3}") + ); + // Nested dictionaries + $this->assertEquals( + ['a'=>'0', 'b'=>'1', 'c'=>['ca'=>'5', 'cb'=>'6'], 'd'=>'3'], + $parser->parse("{a:0, b:1, c:{ca:5, cb:6}, d:3}") + ); + } + + public function testRestrictedParser() + { + $parser = new MixedTypeParser(',: {}'); + $this->assertEquals( + ['a' => '[b]', 'c' => '[d', 'e]' => ''], + $parser->parse('{a:[b],c:[d,e]}') + ); + } + + public function testJWT() + { + $parser = new MixedTypeParser(); + $payload = $parser->parseDictionary("exp:16161616, txt:Hello world!"); + $this->assertArrayHasKey('exp', $payload); + $this->assertArrayHasKey('txt', $payload); + } +} \ No newline at end of file diff --git a/extension/Tests/Unit/Core/Parser/SimpleParserTest.php b/extension/Tests/Unit/Core/Parser/SimpleParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2fc4a4be33f3ee7d0fff65599a031f7e4917e0a9 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/SimpleParserTest.php @@ -0,0 +1,76 @@ +<?php + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\SimpleParser; +use PHPUnit\Framework\TestCase; + +class SimpleParserTest extends TestCase +{ + public function testEmptyString() + { + $parser = new SimpleParser(','); + $this->assertEquals( + [], + $parser->parse('') + ); + } + + public function testSingletons() + { + $parser = new SimpleParser(','); + $this->assertEquals(['abcd'], $parser->parse('abcd')); + $this->assertEquals([42], $parser->parse('42')); + } + + public function testSimpleList() + { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd','ef'], + $parser->parse('ab,cd,ef') + ); + $parser = new SimpleParser('|'); + $this->assertEquals( + ['ab','cd','ef'], + $parser->parse('ab|cd|ef') + ); + $parser = new SimpleParser('|,.'); + $this->assertEquals( + ['ab','cd','ef','gh'], + $parser->parse('ab|cd,ef.gh') + ); + } + + public function testEscapedSeparators() + { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd,ef'], + $parser->parse('ab,cd\\,ef') + ); + } + + public function testQuotedSeparators() + { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd, ef'], + $parser->parse('ab,"cd, ef"') + ); + } + + public function testIterate() + { + $parser = new SimpleParser(','); + $iterator = $parser->iterate('a,b,c'); + $expected = ['a', 'b', 'c']; + foreach($iterator as $value) { + $this->assertSame(current($expected), $value); + next($expected); + } + $this->assertFalse(current($expected)); + + } +} + diff --git a/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php b/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4aa77a0704e2350629cdd824846094d454896189 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php @@ -0,0 +1,145 @@ +<?php + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\StringTokenizer; +use PHPUnit\Framework\TestCase; + +class StringTokenizerTest extends TestCase +{ + + protected function assertTokenizer($delimiters, $data, $expected) + { + $parser = new StringTokenizer($delimiters); + $generator = $parser->tokenized($data); + foreach ($expected as $data) { + list($token, $delimiter) = $generator->current(); + if (is_array($data)) { + $this->assertSame($data[0], $token->value, "Expecting $data[0]"); + $this->assertSame($data[1], $delimiter, "Expecting '$data[1]'"); + } else { + $this->assertSame($data, $token->value, "Expecting $data"); + } + $generator->next(); + } + $this->assertNull($generator->current()); + } + + public function testOffset() + { + $parser = new StringTokenizer(':|'); + $input = 'a:bc:de|f'; + $offsets = [1,4,7,null]; + $tokens = $parser->tokenized($input); + foreach($offsets as $value) { + $tokens->current(); + if (!is_null($value)) $this->assertSame($value, $parser->offset()); + $tokens->next(); + } + $this->assertFalse($tokens->valid()); + } + + public function testEmptyString() + { + $this->assertTokenizer(':,[]{}', '', []); + } + + public function testSimpleString() + { + $this->assertTokenizer(':,[]{}', 'abc', [['abc',null]]); + } + + public function testUnescapedDelimiters() + { + $this->assertTokenizer( + ':,./]{', + "x:7,y.z/24]{x", + [[ 'x', ':'], ['7', ','], ['y', '.'], ['z', '/'], + ['24', ']'], ['','{'], ['x',null]] + ); + } + + public function testEscapedDelimiters() + { + $this->assertTokenizer( + ':,./]{', + "x\\:7\\,y\\.z\\/24\\]\\{x", + [[ "x:7,y.z/24]{x", null]] + ); + } + + public function testEscapedNonDelimiters() + { + $this->assertTokenizer( + ':', + 'x\\\\y\\n', + ['x\\\\y\\n'] + ); + } + + public function testTokensAreTrimmed() + { + $this->assertTokenizer( + '|', + ' a|b | cd ', + ['a', 'b', 'cd'] + ); + } + + public function testSpacesInside() + { + $this->assertTokenizer( + ':', + "x y: sy ca : f\t\ng", + ['x y', 'sy ca', "f\t\ng"] + ); + } + + public function testQuotesAreRemoved() + { + $this->assertTokenizer( + ':', + '"x y":ab\'x\'cd', + ['x y', 'abxcd'] + ); + } + + public function testQuotedSpacesArePreserved() + { + $this->assertTokenizer( + ':', + '" "x:ab" x ":\' \'', + [' x', 'ab x ', ' '] + ); + } + + + + public function testEscapedQuotesArePreserved() + { + $this->assertTokenizer( + ':', + '\"x y\":ab\\\'cd', + ['"x y"', "ab'cd"] + ); + } + + public function testNestedQuotesAreNotParsed() + { + $this->assertTokenizer( + ':', + '"\'":\'"\'', + ["'", '"'] + ); + } + + public function testQuotedDelimitersAreIgnored() + { + $this->assertTokenizer( + ':,|', + 'x:a\\|b|c\\,d\\:e:24', + ['x', 'a|b', 'c,d:e', '24'] + ); + } + +} \ No newline at end of file diff --git a/extension/composer.json b/extension/composer.json index 848142983e29bb2e9ddcc9e0b9ab647c57ce8e58..be32013f560e6fa6bf04c5bedcc9ae2fdf879032 100644 --- a/extension/composer.json +++ b/extension/composer.json @@ -3,7 +3,8 @@ "phpoffice/phpspreadsheet": "^1.3", "ext-json": "*", "twig/twig": "^2.0", - "ezyang/htmlpurifier": "^4.15" + "ezyang/htmlpurifier": "^4.15", + "firebase/php-jwt": "^6.9" }, "require-dev": { "phpunit/phpunit": "^9"