1<?php
2
3/**
4* @package   s9e\TextFormatter
5* @copyright Copyright (c) 2010-2021 The s9e authors
6* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7*/
8namespace s9e\TextFormatter\Plugins\BBCodes\Configurator;
9
10use Exception;
11use InvalidArgumentException;
12use RuntimeException;
13use s9e\TextFormatter\Configurator;
14use s9e\TextFormatter\Configurator\Helpers\RegexpBuilder;
15use s9e\TextFormatter\Configurator\Items\Attribute;
16use s9e\TextFormatter\Configurator\Items\ProgrammableCallback;
17use s9e\TextFormatter\Configurator\Items\Tag;
18use s9e\TextFormatter\Configurator\Items\Template;
19
20class BBCodeMonkey
21{
22	/**
23	* Expression that matches a regexp such as /foo/i
24	*/
25	const REGEXP = '(.).*?(?<!\\\\)(?>\\\\\\\\)*+\\g{-1}[DSUisu]*';
26
27	/**
28	* @var array List of pre- and post- filters that are explicitly allowed in BBCode definitions.
29	*            We use a whitelist approach because there are so many different risky callbacks
30	*            that it would be too easy to let something dangerous slip by, e.g.: unlink,
31	*            system, etc...
32	*/
33	public $allowedFilters = [
34		'addslashes',
35		'dechex',
36		'intval',
37		'json_encode',
38		'ltrim',
39		'mb_strtolower',
40		'mb_strtoupper',
41		'rawurlencode',
42		'rtrim',
43		'str_rot13',
44		'stripslashes',
45		'strrev',
46		'strtolower',
47		'strtotime',
48		'strtoupper',
49		'trim',
50		'ucfirst',
51		'ucwords',
52		'urlencode'
53	];
54
55	/**
56	* @var Configurator Instance of Configurator;
57	*/
58	protected $configurator;
59
60	/**
61	* @var array Regexps used in the named subpatterns generated automatically for composite
62	*            attributes. For instance, "foo={NUMBER},{NUMBER}" will be transformed into
63	*            'foo={PARSE=#^(?<foo0>\\d+),(?<foo1>\\d+)$#D}'
64	*/
65	public $tokenRegexp = [
66		'ANYTHING'   => '[\\s\\S]*?',
67		'COLOR'      => '[a-zA-Z]+|#[0-9a-fA-F]+',
68		'EMAIL'      => '[^@]+@.+?',
69		'FLOAT'      => '(?>0|-?[1-9]\\d*)(?>\\.\\d+)?(?>e[1-9]\\d*)?',
70		'ID'         => '[-a-zA-Z0-9_]+',
71		'IDENTIFIER' => '[-a-zA-Z0-9_]+',
72		'INT'        => '0|-?[1-9]\\d*',
73		'INTEGER'    => '0|-?[1-9]\\d*',
74		'NUMBER'     => '\\d+',
75		'RANGE'      => '\\d+',
76		'SIMPLETEXT' => '[-a-zA-Z0-9+.,_ ]+',
77		'TEXT'       => '[\\s\\S]*?',
78		'UINT'       => '0|[1-9]\\d*'
79	];
80
81	/**
82	* @var array List of token types that are used to represent raw, unfiltered content
83	*/
84	public $unfilteredTokens = [
85		'ANYTHING',
86		'TEXT'
87	];
88
89	/**
90	* Constructor
91	*
92	* @param  Configurator $configurator Instance of Configurator
93	*/
94	public function __construct(Configurator $configurator)
95	{
96		$this->configurator = $configurator;
97	}
98
99	/**
100	* Create a BBCode and its underlying tag and template(s) based on its reference usage
101	*
102	* @param  string          $usage    BBCode usage, e.g. [B]{TEXT}[/b]
103	* @param  string|Template $template BBCode's template
104	* @return array                     An array containing three elements: 'bbcode', 'bbcodeName'
105	*                                   and 'tag'
106	*/
107	public function create($usage, $template)
108	{
109		// Parse the BBCode usage
110		$config = $this->parse($usage);
111
112		// Create a template object for manipulation
113		if (!($template instanceof Template))
114		{
115			$template = new Template($template);
116		}
117
118		// Replace the passthrough token in the BBCode's template
119		$template->replaceTokens(
120			'#\\{(?:[A-Z]+[A-Z_0-9]*|@[-\\w]+)\\}#',
121			function ($m) use ($config)
122			{
123				$tokenId = substr($m[0], 1, -1);
124
125				// Acknowledge {@foo} as an XPath expression even outside of attribute value
126				// templates
127				if ($tokenId[0] === '@')
128				{
129					return ['expression', $tokenId];
130				}
131
132				// Test whether this is a known token
133				if (isset($config['tokens'][$tokenId]))
134				{
135					// Replace with the corresponding attribute
136					return ['expression', '@' . $config['tokens'][$tokenId]];
137				}
138
139				// Test whether the token is used as passthrough
140				if ($tokenId === $config['passthroughToken'])
141				{
142					return ['passthrough'];
143				}
144
145				// Undefined token. If it's the name of a filter, consider it's an error
146				if ($this->isFilter($tokenId))
147				{
148					throw new RuntimeException('Token {' . $tokenId . '} is ambiguous or undefined');
149				}
150
151				// Use the token's name as parameter name
152				return ['expression', '$' . $tokenId];
153			}
154		);
155
156		// Prepare the return array
157		$return = [
158			'bbcode'     => $config['bbcode'],
159			'bbcodeName' => $config['bbcodeName'],
160			'tag'        => $config['tag']
161		];
162
163		// Set the template for this BBCode's tag
164		$return['tag']->template = $template;
165
166		return $return;
167	}
168
169	/**
170	* Create a BBCode based on its reference usage
171	*
172	* @param  string $usage BBCode usage, e.g. [B]{TEXT}[/b]
173	* @return array
174	*/
175	protected function parse($usage)
176	{
177		$tag    = new Tag;
178		$bbcode = new BBCode;
179
180		// This is the config we will return
181		$config = [
182			'tag'              => $tag,
183			'bbcode'           => $bbcode,
184			'passthroughToken' => null
185		];
186
187		// Encode maps to avoid special characters to interfere with definitions
188		$usage = preg_replace_callback(
189			'#(\\{(?>HASH)?MAP=)([^:]+:[^,;}]+(?>,[^:]+:[^,;}]+)*)(?=[;}])#',
190			function ($m)
191			{
192				return $m[1] . base64_encode($m[2]);
193			},
194			$usage
195		);
196
197		// Encode regexps to avoid special characters to interfere with definitions
198		$usage = preg_replace_callback(
199			'#(\\{(?:PARSE|REGEXP)=)(' . self::REGEXP . '(?:,' . self::REGEXP . ')*)#',
200			function ($m)
201			{
202				return $m[1] . base64_encode($m[2]);
203			},
204			$usage
205		);
206
207		$regexp = '(^'
208		        // [BBCODE
209		        . '\\[(?<bbcodeName>\\S+?)'
210		        // ={TOKEN}
211		        . '(?<defaultAttribute>=.+?)?'
212		        // foo={TOKEN} bar={TOKEN1},{TOKEN2}
213		        . '(?<attributes>(?:\\s+[^=]+=\\S+?)*?)?'
214		        // ] or /] or ]{TOKEN}[/BBCODE]
215		        . '\\s*(?:/?\\]|\\]\\s*(?<content>.*?)\\s*(?<endTag>\\[/\\1]))'
216		        . '$)i';
217
218		if (!preg_match($regexp, trim($usage), $m))
219		{
220			throw new InvalidArgumentException('Cannot interpret the BBCode definition');
221		}
222
223		// Save the BBCode's name
224		$config['bbcodeName'] = BBCode::normalizeName($m['bbcodeName']);
225
226		// Prepare the attributes definition, e.g. "foo={BAR}"
227		$definitions = preg_split('#\\s+#', trim($m['attributes']), -1, PREG_SPLIT_NO_EMPTY);
228
229		// If there's a default attribute, we prepend it to the list using the BBCode's name as
230		// attribute name
231		if (!empty($m['defaultAttribute']))
232		{
233			array_unshift($definitions, $m['bbcodeName'] . $m['defaultAttribute']);
234		}
235
236		// Append the content token to the attributes list under the name "content" if it's anything
237		// but raw {TEXT} (or other unfiltered tokens)
238		if (!empty($m['content']))
239		{
240			$regexp = '#^\\{' . RegexpBuilder::fromList($this->unfilteredTokens) . '[0-9]*\\}$#D';
241
242			if (preg_match($regexp, $m['content']))
243			{
244				$config['passthroughToken'] = substr($m['content'], 1, -1);
245			}
246			else
247			{
248				$definitions[] = 'content=' . $m['content'];
249				$bbcode->contentAttributes[] = 'content';
250			}
251		}
252
253		// Separate the attribute definitions from the BBCode options
254		$attributeDefinitions = [];
255		foreach ($definitions as $definition)
256		{
257			$pos   = strpos($definition, '=');
258			$name  = substr($definition, 0, $pos);
259			$value = preg_replace('(^"(.*?)")s', '$1', substr($definition, 1 + $pos));
260
261			// Decode base64-encoded tokens
262			$value = preg_replace_callback(
263				'#(\\{(?>HASHMAP|MAP|PARSE|REGEXP)=)([A-Za-z0-9+/]+=*)#',
264				function ($m)
265				{
266					return $m[1] . base64_decode($m[2]);
267				},
268				$value
269			);
270
271			// If name starts with $ then it's a BBCode/tag option. If it starts with # it's a rule.
272			// Otherwise, it's an attribute definition
273			if ($name[0] === '$')
274			{
275				$optionName = substr($name, 1);
276
277				// Allow nestingLimit and tagLimit to be set on the tag itself. We don't necessarily
278				// want every other tag property to be modifiable this way, though
279				$object = ($optionName === 'nestingLimit' || $optionName === 'tagLimit') ? $tag : $bbcode;
280
281				$object->$optionName = $this->convertValue($value);
282			}
283			elseif ($name[0] === '#')
284			{
285				$ruleName = substr($name, 1);
286
287				// Supports #denyChild=foo,bar
288				foreach (explode(',', $value) as $value)
289				{
290					$tag->rules->$ruleName($this->convertValue($value));
291				}
292			}
293			else
294			{
295				$attrName = strtolower(trim($name));
296				$attributeDefinitions[] = [$attrName, $value];
297			}
298		}
299
300		// Add the attributes and get the token translation table
301		$tokens = $this->addAttributes($attributeDefinitions, $bbcode, $tag);
302
303		// Test whether the passthrough token is used for something else, in which case we need
304		// to unset it
305		if (isset($tokens[$config['passthroughToken']]))
306		{
307			$config['passthroughToken'] = null;
308		}
309
310		// Add the list of known (and only the known) tokens to the config
311		$config['tokens'] = array_filter($tokens);
312
313		return $config;
314	}
315
316	/**
317	* Parse a string of attribute definitions and add the attributes/options to the tag/BBCode
318	*
319	* Attributes come in two forms. Most commonly, in the form of a single token, e.g.
320	*   [a href={URL} title={TEXT}]
321	*
322	* Sometimes, however, we need to parse more than one single token. For instance, the phpBB
323	* [FLASH] BBCode uses two tokens separated by a comma:
324	*   [flash={NUMBER},{NUMBER}]{URL}[/flash]
325	*
326	* In addition, some custom BBCodes circulating for phpBB use a combination of token and static
327	* text such as:
328	*   [youtube]http://www.youtube.com/watch?v={SIMPLETEXT}[/youtube]
329	*
330	* Any attribute that is not a single token is implemented as an attribute preprocessor, with
331	* each token generating a matching attribute. Tentatively, those  of those attributes are
332	* created by taking the attribute preprocessor's name and appending a unique number counting the
333	* number of created attributes. In the [FLASH] example above, an attribute preprocessor named
334	* "flash" would be created as well as two attributes named "flash0" and "flash1" respectively.
335	*
336	* @link https://www.phpbb.com/community/viewtopic.php?f=46&t=2127991
337	* @link https://www.phpbb.com/community/viewtopic.php?f=46&t=579376
338	*
339	* @param  array  $definitions List of attributes definitions as [[name, definition]*]
340	* @param  BBCode $bbcode      Owner BBCode
341	* @param  Tag    $tag         Owner tag
342	* @return array               Array of [token id => attribute name] where FALSE in place of the
343	*                             name indicates that the token is ambiguous (e.g. used multiple
344	*                             times)
345	*/
346	protected function addAttributes(array $definitions, BBCode $bbcode, Tag $tag)
347	{
348		/**
349		* @var array List of composites' tokens. Each element is composed of an attribute name, the
350		*            composite's definition and an array of tokens
351		*/
352		$composites = [];
353
354		/**
355		* @var array Map of [tokenId => attrName]. If the same token is used in multiple attributes
356		*            it is set to FALSE
357		*/
358		$table = [];
359
360		foreach ($definitions as list($attrName, $definition))
361		{
362			// The first attribute defined is set as default
363			if (!isset($bbcode->defaultAttribute))
364			{
365				$bbcode->defaultAttribute = $attrName;
366			}
367
368			// Parse the tokens in that definition
369			$tokens = $this->parseTokens($definition);
370
371			if (empty($tokens))
372			{
373				throw new RuntimeException('No valid tokens found in ' . $attrName . "'s definition " . $definition);
374			}
375
376			// Test whether this attribute has one single all-encompassing token
377			if ($tokens[0]['content'] === $definition)
378			{
379				$token = $tokens[0];
380
381				if ($token['type'] === 'PARSE')
382				{
383					foreach ($token['regexps'] as $regexp)
384					{
385						$tag->attributePreprocessors->add($attrName, $regexp);
386					}
387				}
388				elseif (isset($tag->attributes[$attrName]))
389				{
390					throw new RuntimeException("Attribute '" . $attrName . "' is declared twice");
391				}
392				else
393				{
394					// Remove the "useContent" option and add the attribute's name to the list of
395					// attributes to use this BBCode's content
396					if (!empty($token['options']['useContent']))
397					{
398						$bbcode->contentAttributes[] = $attrName;
399					}
400					unset($token['options']['useContent']);
401
402					// Add the attribute
403					$tag->attributes[$attrName] = $this->generateAttribute($token);
404
405					// Record the token ID if applicable
406					$tokenId = $token['id'];
407					$table[$tokenId] = (isset($table[$tokenId]))
408					                 ? false
409					                 : $attrName;
410				}
411			}
412			else
413			{
414				$composites[] = [$attrName, $definition, $tokens];
415			}
416		}
417
418		foreach ($composites as list($attrName, $definition, $tokens))
419		{
420			$regexp  = '/^';
421			$lastPos = 0;
422
423			$usedTokens = [];
424
425			foreach ($tokens as $token)
426			{
427				$tokenId   = $token['id'];
428				$tokenType = $token['type'];
429
430				if ($tokenType === 'PARSE')
431				{
432					// Disallow {PARSE} tokens because attribute preprocessors cannot feed into
433					// other attribute preprocessors
434					throw new RuntimeException('{PARSE} tokens can only be used has the sole content of an attribute');
435				}
436
437				// Ensure that tokens are only used once per definition so we don't have multiple
438				// subpatterns using the same name
439				if (isset($usedTokens[$tokenId]))
440				{
441					throw new RuntimeException('Token {' . $tokenId . '} used multiple times in attribute ' . $attrName . "'s definition");
442				}
443				$usedTokens[$tokenId] = 1;
444
445				// Find the attribute name associated with this token, or create an attribute
446				// otherwise
447				if (isset($table[$tokenId]))
448				{
449					$matchName = $table[$tokenId];
450
451					if ($matchName === false)
452					{
453						throw new RuntimeException('Token {' . $tokenId . "} used in attribute '" . $attrName . "' is ambiguous");
454					}
455				}
456				else
457				{
458					// The name of the named subpattern and the corresponding attribute is based on
459					// the attribute preprocessor's name, with an incremented ID that ensures we
460					// don't overwrite existing attributes
461					$i = 0;
462					do
463					{
464						$matchName = $attrName . $i;
465						++$i;
466					}
467					while (isset($tag->attributes[$matchName]));
468
469					// Create the attribute that corresponds to this subpattern
470					$attribute = $tag->attributes->add($matchName);
471
472					// Append the corresponding filter if applicable
473					if (!in_array($tokenType, $this->unfilteredTokens, true))
474					{
475						$filter = $this->configurator->attributeFilters->get('#' . strtolower($tokenType));
476						$attribute->filterChain->append($filter);
477					}
478
479					// Record the attribute name associated with this token ID
480					$table[$tokenId] = $matchName;
481				}
482
483				// Append the literal text between the last position and current position.
484				// Replace whitespace with a flexible whitespace pattern
485				$literal = preg_quote(substr($definition, $lastPos, $token['pos'] - $lastPos), '/');
486				$literal = preg_replace('(\\s+)', '\\s+', $literal);
487				$regexp .= $literal;
488
489				// Grab the expression that corresponds to the token type, or use a catch-all
490				// expression otherwise
491				$expr = (isset($this->tokenRegexp[$tokenType]))
492				      ? $this->tokenRegexp[$tokenType]
493				      : '.+?';
494
495				// Append the named subpattern. Its name is made of the attribute preprocessor's
496				// name and the subpattern's position
497				$regexp .= '(?<' . $matchName . '>' . $expr . ')';
498
499				// Update the last position
500				$lastPos = $token['pos'] + strlen($token['content']);
501			}
502
503			// Append the literal text that follows the last token and finish the regexp
504			$regexp .= preg_quote(substr($definition, $lastPos), '/') . '$/D';
505
506			// Add the attribute preprocessor to the config
507			$tag->attributePreprocessors->add($attrName, $regexp);
508		}
509
510		// Now create attributes generated from attribute preprocessors. For instance, preprocessor
511		// #(?<width>\\d+),(?<height>\\d+)# will generate two attributes named "width" and height
512		// with a regexp filter "#^(?:\\d+)$#D", unless they were explicitly defined otherwise
513		$newAttributes = [];
514		foreach ($tag->attributePreprocessors as $attributePreprocessor)
515		{
516			foreach ($attributePreprocessor->getAttributes() as $attrName => $regexp)
517			{
518				if (isset($tag->attributes[$attrName]))
519				{
520					// This attribute was already explicitly defined, nothing else to add
521					continue;
522				}
523
524				if (isset($newAttributes[$attrName])
525				 && $newAttributes[$attrName] !== $regexp)
526				{
527					throw new RuntimeException("Ambiguous attribute '" . $attrName . "' created using different regexps needs to be explicitly defined");
528				}
529
530				$newAttributes[$attrName] = $regexp;
531			}
532		}
533
534		foreach ($newAttributes as $attrName => $regexp)
535		{
536			$filter = $this->configurator->attributeFilters->get('#regexp');
537
538			// Create the attribute using this regexp as filter
539			$tag->attributes->add($attrName)->filterChain->append($filter)->setRegexp($regexp);
540		}
541
542		return $table;
543	}
544
545	/**
546	* Convert a human-readable value to a typed PHP value
547	*
548	* @param  string      $value Original value
549	* @return bool|string        Converted value
550	*/
551	protected function convertValue($value)
552	{
553		if ($value === 'true')
554		{
555			return true;
556		}
557
558		if ($value === 'false')
559		{
560			return false;
561		}
562
563		return $value;
564	}
565
566	/**
567	* Parse and return all the tokens contained in a definition
568	*
569	* @param  string $definition
570	* @return array
571	*/
572	protected function parseTokens($definition)
573	{
574		$tokenTypes = [
575			'choice' => 'CHOICE[0-9]*=(?<choices>.+?)',
576			'map'    => '(?:HASH)?MAP[0-9]*=(?<map>.+?)',
577			'parse'  => 'PARSE=(?<regexps>' . self::REGEXP . '(?:,' . self::REGEXP . ')*)',
578			'range'  => 'RANGE[0-9]*=(?<min>-?[0-9]+),(?<max>-?[0-9]+)',
579			'regexp' => 'REGEXP[0-9]*=(?<regexp>' . self::REGEXP . ')',
580			'other'  => '(?<other>[A-Z_]+[0-9]*)'
581		];
582
583		// Capture the content of every token in that attribute's definition. Usually there will
584		// only be one, as in "foo={URL}" but some older BBCodes use a form of composite
585		// attributes such as [FLASH={NUMBER},{NUMBER}]
586		preg_match_all(
587			'#\\{(' . implode('|', $tokenTypes) . ')(?<options>\\??(?:;[^;]*)*)\\}#',
588			$definition,
589			$matches,
590			PREG_SET_ORDER | PREG_OFFSET_CAPTURE
591		);
592
593		$tokens = [];
594		foreach ($matches as $m)
595		{
596			if (isset($m['other'][0])
597			 && preg_match('#^(?:CHOICE|HASHMAP|MAP|REGEXP|PARSE|RANGE)#', $m['other'][0]))
598			{
599				throw new RuntimeException("Malformed token '" . $m['other'][0] . "'");
600			}
601
602			$token = [
603				'pos'     => $m[0][1],
604				'content' => $m[0][0],
605				'options' => (isset($m['options'][0])) ? $this->parseOptionString($m['options'][0]) : []
606			];
607
608			// Get this token's type by looking at the start of the match
609			$head = $m[1][0];
610			$pos  = strpos($head, '=');
611
612			if ($pos === false)
613			{
614				// {FOO}
615				$token['id'] = $head;
616			}
617			else
618			{
619				// {FOO=...}
620				$token['id'] = substr($head, 0, $pos);
621
622				// Copy the content of named subpatterns into the token's config
623				foreach ($m as $k => $v)
624				{
625					if (!is_numeric($k) && $k !== 'options' && $v[1] !== -1)
626					{
627						$token[$k] = $v[0];
628					}
629				}
630			}
631
632			// The token's type is its id minus the number, e.g. NUMBER1 => NUMBER
633			$token['type'] = rtrim($token['id'], '0123456789');
634
635			// {PARSE} tokens can have several regexps separated with commas, we split them up here
636			if ($token['type'] === 'PARSE')
637			{
638				// Match all occurences of a would-be regexp followed by a comma or the end of the
639				// string
640				preg_match_all('#' . self::REGEXP . '(?:,|$)#', $token['regexps'], $m);
641
642				$regexps = [];
643				foreach ($m[0] as $regexp)
644				{
645					// remove the potential comma at the end
646					$regexps[] = rtrim($regexp, ',');
647				}
648
649				$token['regexps'] = $regexps;
650			}
651
652			$tokens[] = $token;
653		}
654
655		return $tokens;
656	}
657
658	/**
659	* Generate an attribute based on a token
660	*
661	* @param  array     $token Token this attribute is based on
662	* @return Attribute
663	*/
664	protected function generateAttribute(array $token)
665	{
666		$attribute = new Attribute;
667
668		if (isset($token['options']['preFilter']))
669		{
670			$this->appendFilters($attribute, $token['options']['preFilter']);
671			unset($token['options']['preFilter']);
672		}
673
674		if ($token['type'] === 'REGEXP')
675		{
676			$filter = $this->configurator->attributeFilters->get('#regexp');
677			$attribute->filterChain->append($filter)->setRegexp($token['regexp']);
678		}
679		elseif ($token['type'] === 'RANGE')
680		{
681			$filter = $this->configurator->attributeFilters->get('#range');
682			$attribute->filterChain->append($filter)->setRange($token['min'], $token['max']);
683		}
684		elseif ($token['type'] === 'CHOICE')
685		{
686			$filter = $this->configurator->attributeFilters->get('#choice');
687			$attribute->filterChain->append($filter)->setValues(
688				explode(',', $token['choices']),
689				!empty($token['options']['caseSensitive'])
690			);
691			unset($token['options']['caseSensitive']);
692		}
693		elseif ($token['type'] === 'HASHMAP' || $token['type'] === 'MAP')
694		{
695			// Build the map from the string
696			$map = [];
697			foreach (explode(',', $token['map']) as $pair)
698			{
699				$pos = strpos($pair, ':');
700
701				if ($pos === false)
702				{
703					throw new RuntimeException("Invalid map assignment '" . $pair . "'");
704				}
705
706				$map[substr($pair, 0, $pos)] = substr($pair, 1 + $pos);
707			}
708
709			// Create the filter then append it to the attribute
710			if ($token['type'] === 'HASHMAP')
711			{
712				$filter = $this->configurator->attributeFilters->get('#hashmap');
713				$attribute->filterChain->append($filter)->setMap(
714					$map,
715					!empty($token['options']['strict'])
716				);
717			}
718			else
719			{
720				$filter = $this->configurator->attributeFilters->get('#map');
721				$attribute->filterChain->append($filter)->setMap(
722					$map,
723					!empty($token['options']['caseSensitive']),
724					!empty($token['options']['strict'])
725				);
726			}
727
728			// Remove options that are not needed anymore
729			unset($token['options']['caseSensitive']);
730			unset($token['options']['strict']);
731		}
732		elseif (!in_array($token['type'], $this->unfilteredTokens, true))
733		{
734			$filter = $this->configurator->attributeFilters->get('#' . $token['type']);
735			$attribute->filterChain->append($filter);
736		}
737
738		if (isset($token['options']['postFilter']))
739		{
740			$this->appendFilters($attribute, $token['options']['postFilter']);
741			unset($token['options']['postFilter']);
742		}
743
744		// Set the "required" option if "required" or "optional" is set, then remove
745		// the "optional" option
746		if (isset($token['options']['required']))
747		{
748			$token['options']['required'] = (bool) $token['options']['required'];
749		}
750		elseif (isset($token['options']['optional']))
751		{
752			$token['options']['required'] = !$token['options']['optional'];
753		}
754		unset($token['options']['optional']);
755
756		foreach ($token['options'] as $k => $v)
757		{
758			$attribute->$k = $v;
759		}
760
761		return $attribute;
762	}
763
764	/**
765	* Append a list of filters to an attribute's filterChain
766	*
767	* @param  Attribute $attribute
768	* @param  string    $filters   List of filters, separated with commas
769	* @return void
770	*/
771	protected function appendFilters(Attribute $attribute, $filters)
772	{
773		foreach (preg_split('#\\s*,\\s*#', $filters) as $filterName)
774		{
775			if (substr($filterName, 0, 1) !== '#'
776			 && !in_array($filterName, $this->allowedFilters, true))
777			{
778				throw new RuntimeException("Filter '" . $filterName . "' is not allowed in BBCodes");
779			}
780
781			$filter = $this->configurator->attributeFilters->get($filterName);
782			$attribute->filterChain->append($filter);
783		}
784	}
785
786	/**
787	* Test whether a token's name is the name of a filter
788	*
789	* @param  string $tokenId Token ID, e.g. "TEXT1"
790	* @return bool
791	*/
792	protected function isFilter($tokenId)
793	{
794		$filterName = rtrim($tokenId, '0123456789');
795
796		if (in_array($filterName, $this->unfilteredTokens, true))
797		{
798			return true;
799		}
800
801		// Try to load the filter
802		try
803		{
804			if ($this->configurator->attributeFilters->get('#' . $filterName))
805			{
806				return true;
807			}
808		}
809		catch (Exception $e)
810		{
811			// Nothing to do here
812		}
813
814		return false;
815	}
816
817	/**
818	* Parse the option string into an associative array
819	*
820	* @param  string $string Serialized options
821	* @return array          Associative array of options
822	*/
823	protected function parseOptionString($string)
824	{
825		// Use the first "?" as an alias for the "optional" option
826		$string = preg_replace('(^\\?)', ';optional', $string);
827
828		$options = [];
829		foreach (preg_split('#;+#', $string, -1, PREG_SPLIT_NO_EMPTY) as $pair)
830		{
831			$pos = strpos($pair, '=');
832			if ($pos === false)
833			{
834				// Options with no value are set to true, e.g. {FOO;useContent}
835				$k = $pair;
836				$v = true;
837			}
838			else
839			{
840				$k = substr($pair, 0, $pos);
841				$v = substr($pair, 1 + $pos);
842			}
843
844			$options[$k] = $v;
845		}
846
847		return $options;
848	}
849}