1<?php
2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
3//
4// All Rights Reserved. See copyright.txt for details and a complete list of authors.
5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
6// $Id$
7
8/**
9 * A text of markup, usually using Tiki's syntax ("wiki syntax"), which can be parsed
10 *
11 * This class is a contextual version of ParserLib. ParserLib is not contextual.
12 * This class can be used to analyze 2 different pages in a single request and recognize those as different contexts. 2 fragments of the same wiki page can also be different contexts.
13 * The extension of ParserLib is hopefully temporary. Ideally ParserLib would be replaced by a more complete version of this class.
14 * TODO: Move remaining ParserLib methods and option property here
15*/
16class WikiParser_Parsable extends ParserLib
17{
18	/** @var string Code usually containing text and markup */
19	private $markup;
20
21	// Properties used by parallel parsing functions to share data
22
23	/** @var array Footnotes added via the FOOTNOTE plugin. These are read by wikiplugin_footnotearea(). */
24	public $footnotes;
25
26	function __construct($markup)
27	{
28		$this->markup = $markup;
29	}
30
31	// This recursive function handles pre- and no-parse sections and plugins
32	function parse_first(&$data, &$preparsed, &$noparsed, $real_start_diff = '0')
33	{
34		global $tikilib, $tiki_p_edit, $prefs, $pluginskiplist;
35		$smarty = TikiLib::lib('smarty');
36		$smarty->loadPlugin('smarty_function_icon');
37
38		if (! is_array($pluginskiplist)) {
39			$pluginskiplist = [];
40		}
41
42		$is_html = (isset($this->option['is_html']) ? $this->option['is_html'] : false);
43		$data = $this->protectSpecialChars($data, $is_html);
44
45		$matches = WikiParser_PluginMatcher::match($data);
46		$argumentParser = new WikiParser_PluginArgumentParser;
47
48		foreach ($matches as $match) {
49			if ($this->option['parseimgonly'] && $this->getName() != 'img') {
50				continue;
51			}
52
53			//note parent plugin in case of plugins nested in an include - to suppress plugin edit icons below
54			$plugin_parent = isset($plugin_name) ? $plugin_name : false;
55			$plugin_name = $match->getName();
56
57			if (! $this->option['exclude_all_plugins'] && ! empty($this->option['exclude_plugins']) && in_array($plugin_name, $this->option['exclude_plugins'])) {
58				$match->replaceWith('');
59				continue;
60			}
61
62			if ($this->option['exclude_all_plugins'] && (empty($this->option['include_plugins']) || ! in_array($plugin_name, $this->option['include_plugins']))) {
63				$match->replaceWith('');
64				continue;
65			}
66
67			$plugin_data = $match->getBody();
68			$arguments = $argumentParser->parse($match->getArguments());
69			$start = $match->getStart();
70
71			$pluginOutput = null;
72			if ($this->plugin_enabled($plugin_name, $pluginOutput) || $this->option['ck_editor']) {
73				static $plugin_indexes = [];
74
75				if (! array_key_exists($plugin_name, $plugin_indexes)) {
76					$plugin_indexes[$plugin_name] = 0;
77				}
78
79				$current_index = ++$plugin_indexes[$plugin_name];
80
81				// get info to test for preview with auto_save
82				if (! $this->option['skipvalidation']) {
83					$status = $this->plugin_can_execute($plugin_name, $plugin_data, $arguments, $this->option['preview_mode'] || $this->option['ck_editor']);
84				} else {
85					$status = true;
86				}
87				global $tiki_p_plugin_viewdetail, $tiki_p_plugin_preview, $tiki_p_plugin_approve;
88				$details = $tiki_p_plugin_viewdetail == 'y' && $status != 'rejected';
89				$preview = $tiki_p_plugin_preview == 'y' && $details && ! $this->option['preview_mode'];
90				$approve = $tiki_p_plugin_approve == 'y' && $details && ! $this->option['preview_mode'];
91
92				if ($status === true || ($tiki_p_plugin_preview == 'y' && $details && $this->option['preview_mode'] && $prefs['ajax_autosave'] === 'y') || (isset($this->option['noparseplugins']) && $this->option['noparseplugins'])) {
93					if (isset($this->option['stripplugins']) && $this->option['stripplugins']) {
94						$ret = $plugin_data;
95					} elseif (isset($this->option['noparseplugins']) && $this->option['noparseplugins']) {
96						$ret = '~np~' . (string) $match . '~/np~';
97					} else {
98						//suppress plugin edit icons for plugins within includes since edit doesn't work for these yet
99						$suppress_icons = $this->option['suppress_icons'];
100						$this->option['suppress_icons'] = $plugin_name != 'include' && $plugin_parent && $plugin_parent == 'include' ?
101							true : $this->option['suppress_icons'];
102
103						$ret = $this->plugin_execute($plugin_name, $plugin_data, $arguments, $start, false);
104
105						// restore previous suppress_icons state
106						$this->option['suppress_icons'] = $suppress_icons;
107					}
108				} else {
109					if ($status != 'rejected') {
110						$smarty->assign('plugin_fingerprint', $status);
111						$status = 'pending';
112					}
113
114					if ($this->option['ck_editor']) {
115						$ret = $this->convert_plugin_for_ckeditor($plugin_name, $arguments, tra('Plugin execution pending approval'), $plugin_data, ['icon' => 'img/icons/error.png']);
116					} else {
117						$smarty->assign('plugin_name', $plugin_name);
118						$smarty->assign('plugin_index', $current_index);
119
120						$smarty->assign('plugin_status', $status);
121
122						if (! $this->option['inside_pretty']) {
123							$smarty->assign('plugin_details', $details);
124						} else {
125							$smarty->assign('plugin_details', '');
126						}
127						$smarty->assign('plugin_preview', $preview);
128						$smarty->assign('plugin_approve', $approve);
129
130						$smarty->assign('plugin_body', $plugin_data);
131						$smarty->assign('plugin_args', $arguments);
132
133						$ret = '~np~' . $smarty->fetch('tiki-plugin_blocked.tpl') . '~/np~';
134					}
135				}
136			} else {
137				$ret = $pluginOutput->toWiki();
138			}
139
140			if ($ret === false) {
141				continue;
142			}
143
144			if ($this->plugin_is_editable($plugin_name) && (empty($this->option['preview_mode']) || ! $this->option['preview_mode']) && empty($this->option['indexing']) && (empty($this->option['print']) || ! $this->option['print']) && ! $this->option['suppress_icons']) {
145				$headerlib = TikiLib::lib('header');
146				$smarty->loadPlugin('smarty_function_icon');
147
148				$id = 'plugin-edit-' . $plugin_name . $current_index;
149
150				$headerlib->add_js(
151					"\$(document).ready( function() {
152if ( \$('#$id') ) {
153\$('#$id').click( function(event) {
154	popupPluginForm("
155					. json_encode('editwiki')
156					. ', '
157					. json_encode($plugin_name)
158					. ', '
159					. json_encode($current_index)
160					. ', '
161					. json_encode($this->option['page'])
162					. ', '
163					. json_encode($arguments)
164					. ', '
165					. json_encode($this->unprotectSpecialChars($plugin_data, true)) //we restore it back to html here so that it can be edited, we want no modification, ie, it is brought back to html
166					. ", event.target);
167} );
168}
169} );
170"
171				);
172
173				$displayIcon = $prefs['wiki_edit_icons_toggle'] != 'y' || isset($_COOKIE['wiki_plugin_edit_view']);
174
175				$ret .= '~np~' .
176						'<a id="' . $id . '" href="javascript:void(1)" class="editplugin"' . ($displayIcon ? '' : ' style="display:none;"') . '>' .
177						smarty_function_icon(['name' => 'plugin', 'iclass' => 'tips', 'ititle' => tra('Edit plugin') . ':' . ucfirst($plugin_name)], $smarty->getEmptyInternalTemplate()) .
178						'</a>' .
179						'~/np~';
180			}
181
182			// End plugin handling
183
184			$ret = str_replace('~/np~~np~', '', $ret);
185			$match->replaceWith($ret);
186		}
187
188		$data = $matches->getText();
189
190		$this->strip_unparsed_block($data, $noparsed);
191
192		// ~pp~
193		$start = -1;
194		while (false !== $start = strpos($data, '~pp~', $start + 1)) {
195			if (false !== $end = strpos($data, '~/pp~', $start)) {
196				$content = substr($data, $start + 4, $end - $start - 4);
197
198				// ~pp~ type "plugins"
199				$key = "§" . md5($tikilib->genPass()) . "§";
200				$noparsed["key"][] = preg_quote($key);
201				$noparsed["data"][] = '<pre>' . $content . '</pre>';
202				$data = substr($data, 0, $start) . $key . substr($data, $end + 5);
203			}
204		}
205	}
206
207	/**
208	 * Standard parsing
209	 * options defaults : is_html => false, absolute_links => false, language => ''
210	 * @return string
211	 */
212	function parse($options)
213	{
214		// Don't bother if there's nothing...
215		if (gettype($this->markup) <> 'string' || mb_strlen($this->markup) < 1) {
216			return '';
217		}
218
219		global $prefs;
220
221		$this->setOptions(); //reset options;
222
223		// Handle parsing options
224		if (! empty($options)) {
225			$this->setOptions($options);
226		}
227
228		if ($this->option['is_html'] && ! $this->option['parse_wiki']) {
229			return $this->markup;
230		}
231
232		// remove tiki comments first
233		if ($this->option['ck_editor']) {
234			$data = preg_replace(';~tc~(.*?)~/tc~;s', '<tikicomment>$1</tikicomment>', $this->markup);
235		} else {
236			$data = preg_replace(';~tc~(.*?)~/tc~;s', '', $this->markup);
237		}
238
239		$this->parse_wiki_argvariable($data);
240
241		/* <x> XSS Sanitization handling */
242
243		// Fix false positive in wiki syntax
244		//   It can't be done in the sanitizer, that can't know if the input will be wiki parsed or not
245		$data = preg_replace('/(\{img [^\}]+li)<x>(nk[^\}]+\})/i', '\\1\\2', $data);
246
247		// Handle pre- and no-parse sections and plugins
248		$preparsed = ['data' => [],'key' => []];
249		$noparsed = ['data' => [],'key' => []];
250		$this->strip_unparsed_block($data, $noparsed, true);
251		if (! $this->option['noparseplugins'] || $this->option['stripplugins']) {
252			$this->parse_first($data, $preparsed, $noparsed);
253			$this->parse_wiki_argvariable($data);
254		}
255
256		// Handle ~pre~...~/pre~ sections
257		$data = preg_replace(';~pre~(.*?)~/pre~;s', '<pre>$1</pre>', $data);
258
259		// Strike-deleted text --text-- (but not in the context <!--[if IE]><--!> or <!--//--<!CDATA[//><!--
260		// FIXME produces false positive for strings containing html comments. e.g: --some text<!-- comment -->
261		$data = preg_replace("#(?<!<!|//)--([^\s>].+?)--#", "<strike>$1</strike>", $data);
262
263		// Handle comments again in case parse_first method above returned wikiplugins with comments (e.g. PluginInclude a wiki page with comments)
264		$data = preg_replace(';~tc~(.*?)~/tc~;s', '', $data);
265
266		// Handle html comment sections
267		$data = preg_replace(';~hc~(.*?)~/hc~;s', '<!-- $1 -->', $data);
268
269		// Replace special characters
270		// done after url catching because otherwise urls of dyn. sites will be modified // What? Chealer
271		// must be done before color as we can have "~hs~~hs" (2 consecutive non-breaking spaces. The color syntax uses "~~".)
272		// jb 9.0 html entity fix - excluded not $this->option['is_html'] pages
273		if (! $this->option['is_html']) {
274			$this->parse_htmlchar($data);
275		}
276
277		//needs to be before text color syntax because of use of htmlentities in lib/core/WikiParser/OutputLink.php
278		$data = $this->parse_data_wikilinks($data, false, $this->option['ck_editor']);
279
280		// Replace colors ~~foreground[,background]:text~~
281		// must be done before []as the description may contain color change
282		$parse_color = 1;
283		$temp = $data;
284		while ($parse_color) { // handle nested colors, parse innermost first
285			$temp = preg_replace_callback(
286				"/~~([^~:,]+)(,([^~:]+))?:([^~]*)(?!~~[^~:,]+(?:,[^~:]+)?:[^~]*~~)~~/Ums",
287				'ParserLib::colorAttrEscape',
288				$temp,
289				-1,
290				$parse_color
291			);
292
293			if (! empty($temp)) {
294				$data = $temp;
295			}
296		}
297
298		// On large pages, the above preg rule can hit a BACKTRACE LIMIT
299		// In case it does, use the simpler color replacement pattern.
300		if (empty($temp)) {
301			$data = preg_replace_callback(
302				"/\~\~([^\:\,]+)(,([^\:]+))?:([^~]*)\~\~/Ums",
303				'ParserLib::colorAttrEscape',
304				$data
305			);
306		}
307
308		// Extract [link] sections (to be re-inserted later)
309		$noparsedlinks = [];
310
311		// This section matches [...].
312		// Added handling for [[foo] sections.  -rlpowell
313		preg_match_all("/(?<!\[)(\[[^\[][^\]]+\])/", $data, $noparseurl);
314
315		foreach (array_unique($noparseurl[1]) as $np) {
316			$key = '§' . md5(TikiLib::genPass()) . '§';
317
318			$aux["key"] = $key;
319			$aux["data"] = $np;
320			$noparsedlinks[] = $aux;
321			$data = preg_replace('/(^|[^a-zA-Z0-9])' . preg_quote($np, '/') . '([^a-zA-Z0-9]|$)/', '\1' . $key . '\2', $data);
322		}
323
324		// BiDi markers
325		$bidiCount = 0;
326		$bidiCount = preg_match_all("/(\{l2r\})/", $data, $pages);
327		$bidiCount += preg_match_all("/(\{r2l\})/", $data, $pages);
328
329		$data = preg_replace("/\{l2r\}/", "<div dir='ltr'>", $data);
330		$data = preg_replace("/\{r2l\}/", "<div dir='rtl'>", $data);
331		$data = preg_replace("/\{lm\}/", "&lrm;", $data);
332		$data = preg_replace("/\{rm\}/", "&rlm;", $data);
333		// smileys
334		$data = $this->parse_smileys($data);
335
336		// parse_tagged_users
337		if (isset($prefs['feature_tag_users']) && $prefs['feature_tag_users'] == 'y') {
338			$data = $this->parse_tagged_users($data);
339		}
340
341		$data = $this->parse_data_dynamic_variables($data, $this->option['language']);
342
343		// Replace boxes
344		$delim = (isset($prefs['feature_simplebox_delim']) && $prefs['feature_simplebox_delim'] != "" ) ? preg_quote($prefs['feature_simplebox_delim']) : preg_quote("^");
345		$data = preg_replace("/${delim}(.+?)${delim}/s", "<div class=\"card bg-light\"><div class=\"card-body\">$1</div></div>", $data);
346
347		// Underlined text
348		$data = preg_replace("/===(.+?)===/", "<u>$1</u>", $data);
349		// Center text
350		if ($prefs['feature_use_three_colon_centertag'] == 'y' || ($prefs['namespace_enabled'] == 'y' && $prefs['namespace_separator'] == '::')) {
351			$data = preg_replace("/:::(.+?):::/", "<div style=\"text-align: center;\">$1</div>", $data);
352		} else {
353			$data = preg_replace("/::(.+?)::/", "<div style=\"text-align: center;\">$1</div>", $data);
354		}
355
356		// reinsert hash-replaced links into page
357		foreach ($noparsedlinks as $np) {
358			$data = str_replace($np["key"], $np["data"], $data);
359		}
360
361		if ($prefs['wiki_pagination'] != 'y') {
362			$data = str_replace($prefs['wiki_page_separator'], $prefs['wiki_page_separator'] . ' <em>' . tr('Wiki page pagination has not been enabled.') . '</em>', $data);
363		}
364
365		$data = $this->parse_data_externallinks($data);
366
367		$data = $this->parse_data_tables($data);
368
369		/* parse_data_process_maketoc() calls parse_data_inline_syntax().
370
371		It seems wrong to just call parse_data_inline_syntax() when the parsetoc option is disabled.
372		Despite its name, parse_data_process_maketoc() does not just deal with TOC-s.
373
374		I believe it would be better that parse_data_process_maketoc() check parsetoc, only to set $need_maketoc, so that the following calls parse_data_process_maketoc() unconditionally. Chealer 2018-01-02
375		*/
376		if ($this->option['parsetoc']) {
377			$this->parse_data_process_maketoc($data, $noparsed);
378		} else {
379			$data = $this->parse_data_inline_syntax($data);
380		}
381
382		// linebreaks using %%%
383		$data = preg_replace("/\n?(?<![^%]\d)%%%/", "<br />", $data);
384
385		// Close BiDi DIVs if any
386		for ($i = 0; $i < $bidiCount; $i++) {
387			$data .= "</div>";
388		}
389
390		// Put removed strings back.
391		$this->replace_preparse($data, $preparsed, $noparsed, $this->option['is_html']);
392
393		// Converts &lt;x&gt; (<x> tag using HTML entities) into the tag <x>. This tag comes from the input sanitizer (XSS filter).
394		// This is not HTML valid and avoids using <x> in a wiki text,
395		//   but hide '<x>' text inside some words like 'style' that are considered as dangerous by the sanitizer.
396		$data = str_replace([ '&lt;x&gt;', '~np~', '~/np~' ], [ '<x>', '~np~', '~/np~' ], $data);
397
398		if ($this->option['typography'] && ! $this->option['ck_editor']) {
399			$data = typography($data, $this->option['language']);
400		}
401
402		return $data;
403	}
404
405	function plugin_execute($name, $data = '', $args = [], $offset = 0, $validationPerformed = false, $option = [])
406	{
407		global $killtoc;
408
409		if (! empty($option)) {
410			$this->setOptions($option);
411		}
412
413		$data = $this->unprotectSpecialChars($data, true);					// We want to give plugins original
414		$args = preg_replace(['/^&quot;/', '/&quot;$/'], '', $args);		// Similarly remove the encoded " chars from the args
415
416		$outputFormat = 'wiki';
417		if (isset($this->option['context_format'])) {
418			$outputFormat = $this->option['context_format'];
419		}
420
421		if (! $this->plugin_exists($name, true)) {
422			return false;
423		}
424
425		if (! $validationPerformed && ! $this->plugin_enabled($name, $output)) {
426			return $this->convert_plugin_output($output, '', $outputFormat);
427		}
428
429		if ($this->option['inside_pretty'] === true) {
430			$trklib = TikiLib::lib('trk');
431			$trklib->replace_pretty_tracker_refs($args);
432
433			// Reset the tr_offset1 value, which comes from a list selection and specifies the offset to use within the resultset.
434			//  Pretty trackers can contain other tracker plugins. These plugins should get the results from index = 0, and not the index in the calling list
435			if (isset($_REQUEST['tr_offset1'])) {
436				$_REQUEST['list_tr_offset1'] = $_REQUEST['tr_offset1'];
437				$_REQUEST['tr_offset1'] = 0;
438			}
439			foreach ($args as $arg) {
440				if (substr($arg, 0, 4) == '{$f_') {
441					return $name . ': ' . tr(
442						'Pretty tracker reference "%0" could not be replaced in plugin "%1".',
443						str_replace(['{','}'], '', $arg),
444						$name
445					);
446				}
447			}
448		}
449
450		$func_name = 'wikiplugin_' . $name;
451
452		if (! $validationPerformed && ! $this->option['ck_editor']) {
453			$this->plugin_apply_filters($name, $data, $args);
454		}
455
456		if (function_exists($func_name)) {
457			$pluginFormat = 'wiki';
458
459			$info = $this->plugin_info($name, $args);
460			if (isset($info['format'])) {
461				$pluginFormat = $info['format'];
462			}
463
464			$killtoc = false;
465
466			if ($pluginFormat === 'wiki' && $this->option['preview_mode'] === true && $_SESSION['wysiwyg'] === 'y') {	// fix lost new lines in wysiwyg plugins data
467				$data = nl2br($data);
468			}
469
470			$saved_options = $this->option;	// save current options (but do not reset)
471
472			$output = $func_name($data, $args, $offset, $this);
473
474			$this->option = $saved_options; // restore parsing options after plugin has executed
475
476			//This was added to remove the table of contents sometimes returned by other plugins, to use, simply have global $killtoc, and $killtoc = true;
477			if ($killtoc == true) {
478				while (($maketoc_start = strpos($output, "{maketoc")) !== false) {
479					$maketoc_end = strpos($output, "}");
480					$output = substr_replace($output, "", $maketoc_start, $maketoc_end - $maketoc_start + 1);
481				}
482			}
483
484			$killtoc = false;
485
486			$plugin_result = $this->convert_plugin_output($output, $pluginFormat, $outputFormat);
487			if ($this->option['ck_editor'] == true) {
488				return $this->convert_plugin_for_ckeditor($name, $args, $plugin_result, $data, $info);
489			} else {
490				return $plugin_result;
491			}
492		} elseif (WikiPlugin_Negotiator_Wiki_Alias::findImplementation($name, $data, $args)) {
493			return $this->plugin_execute($name, $data, $args, $offset, $validationPerformed);
494		}
495	}
496}
497