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\}/", "‎", $data); 332 $data = preg_replace("/\{rm\}/", "‏", $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 <x> (<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([ '<x>', '~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(['/^"/', '/"$/'], '', $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