PreparseCode::_fixMistakes()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 52
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 2.0014

Importance

Changes 0
Metric Value
cc 2
eloc 26
nc 2
nop 0
dl 0
loc 52
ccs 13
cts 14
cp 0.9286
crap 2.0014
rs 9.504
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This class contains those functions pertaining to preparsing BBC data
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * This file contains code covered by:
11
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
12
 *
13
 * @version 2.0 Beta 1
14
 *
15
 */
16
17
namespace BBC;
18
19
use ElkArte\Helper\TokenHash;
20
21
/**
22
 * Class PreparseCode
23
 *
24
 * @package BBC
25
 */
26
class PreparseCode
27
{
28
	/** The regular expression non-breaking space */
29
	public const NBS = '\x{A0}';
30
31
	/** @var string the message to preparse */
32
	public $message = '';
33
34
	/** @var string the username of the current user */
35
	public $user_name = '';
36
37
	/** @var bool if this is just a preview */
38
	protected $previewing = false;
39
40
	/** @var array the code blocks that we want to protect */
41
	public $code_blocks = [];
42
43
	/** @var PreparseCode */
44
	public static $instance;
45
46 2
	/**
47
	 * PreparseCode constructor.
48 2
	 *
49 2
	 * @param string $user_name
50
	 */
51
	protected function __construct($user_name)
52
	{
53
		$this->user_name = $user_name;
54
	}
55
56
	/**
57
	 * Takes a message and parses it, returning the prepared message as a reference
58
	 * for use by parse_bbc.
59
	 *
60
	 * What it does:
61
	 *   - Cleans up links (javascript, etc.)
62
	 *   - Fixes improperly constructed lists [lists]
63
	 *   - Repairs improperly constructed tables, row, headers, etc.
64
	 *   - Protects code sections
65
	 *   - Checks for proper quote open / closing
66
	 *   - Processes /me tag
67
	 *   - Converts color tags to ones parse_bbc will understand
68
	 *   - Removes empty tags outside of code blocks
69 18
	 *   - Won't convert \n's and a few other things if previewing is true.
70
	 *
71
	 * @param string $message
72 18
	 * @param bool $previewing
73 18
	 */
74
	public function preparsecode(&$message, $previewing = false): ?string
75
	{
76 18
		if (empty($message))
77
		{
78
			return '';
79
		}
80
81 18
		// Load passed values to the class
82
		$this->message = $message;
83
		$this->previewing = $previewing;
84 18
85
		// Clean out control characters
86
		$this->message = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $this->message);
87 18
88
		// This line makes all languages *theoretically* work even with the wrong charset ;).
89
		$this->message = preg_replace('~&amp;#(\d{4,5}|[2-9]\d{2,4}|1[2-9]\d);~', '&#$1;', $this->message);
90 18
91
		// Clean up after nobbc ;).
92
		$this->message = preg_replace_callback('~\[nobbc\](.+?)\[/nobbc\]~i', fn($matches) => $this->_preparsecode_nobbc_callback($matches), $this->message);
93 18
94
		// Remove \r's... they're evil!
95
		$this->message = strtr($this->message, ["\r" => '']);
96 18
97
		// You won't believe this - but too many periods upset apache, it seems!
98
		$this->message = preg_replace('~\.{100,}~', '...', $this->message);
99 18
100
		// Remove Trailing Quotes
101
		$this->_trimTrailingQuotes();
102 18
103
		// Validate code blocks are properly closed.
104
		$this->_validateCodeBlocks();
105 18
		$this->_validateICodeBlocks();
106
107 4
		// Protect CODE blocks from further processing
108 18
		$this->message = $this->tokenizeCodeBlocks($this->message);
109
110
		//  Now that we've fixed all the code tags, let's fix the img and url tags...
111 18
		$this->_fixTags();
112
113
		// Replace /me.+?\n with [me=name]dsf[/me]\n.
114 18
		$this->_itsAllAbout();
115
116
		// Make sure list and table tags are lowercase.
117 18
		$this->message = preg_replace_callback('~\[([/]?)(list|li|table|tr|td|th)((\s[^\]]+)*)\]~i',
118 18
			fn($matches) => $this->_preparsecode_lowertags_callback($matches), $this->message);
119
120
		// Don't leave any lists that were never opened or closed
121 18
		$this->_validateLists();
122
123
		// Attempt to repair common BBC input mistakes
124 18
		$this->_fixMistakes();
125
126 4
		// Remove empty bbc tags
127 18
		$this->message = preg_replace('~\[[bisu]]\s*\[/[bisu]]~i', '', $this->message);
128
		$this->message = preg_replace('~\[quote]\s*\[/quote]~i', '', $this->message);
129
130 18
		// Fix color tags of many forms so they parse properly
131
		$this->message = preg_replace('~\[color=(?:#[\da-fA-F]{3}|#[\da-fA-F]{6}|[A-Za-z]{1,20}|rgb\(\d{1,3}, ?\d{1,3}, ?\d{1,3}\))\]\s*\[/color\]~', '', $this->message);
132
133 18
		// Font tags with multiple fonts (copy&paste in the WYSIWYG by some browsers).
134
		$this->message = preg_replace_callback('~\[font=([^]]*)](.*?(?:\[/font\]))~s',
135
			fn($matches) => $this->_preparsecode_font_callback($matches), $this->message);
136 18
137
		// Don't allow rel follow links if they don't have permissions
138
		$this->_validateLinks();
139 18
140
		// Allow integration to do further processing on a protected code block message
141 18
		call_integration_hook('integrate_preparse_tokenized_code', [&$this->message, $previewing, $this->code_blocks]);
142
143
		// Put it back together!
144
		$this->message = $this->restoreCodeBlocks($this->message);
145
146
		// Allow integration to do further processing
147
		call_integration_hook('integrate_preparse_code', [&$this->message, 0, $previewing]);
148
149 18
		// Safe Spacing
150
		if (!$previewing)
151
		{
152 18
			$this->message = strtr($this->message, ['  ' => '&nbsp; ', "\n" => '<br />', "\xC2\xA0" => '&nbsp;']);
153 18
		}
154
		else
155
		{
156
			$this->message = strtr($this->message, ['  ' => '&nbsp; ', "\xC2\xA0" => '&nbsp;']);
157
		}
158 18
159
		// Now we're going to do full-scale table checking...
160
		$this->_preparseTable();
161 18
162
		// Quickly clean up things that will slow our parser (which are common in posted code.)
163
		$message = strtr($this->message, ['[]' => '&#91;]', '[&#039;' => '&#91;&#039;']);
164
165
		return null;
166
	}
167 18
168
	/**
169
	 * Trim dangling quotes
170
	 */
171 18
	private function _trimTrailingQuotes(): void
172
	{
173
		// Trim off trailing quotes - these often happen by accident.
174
		while (str_ends_with($this->message, '[quote]'))
175
		{
176
			$this->message = trim(substr($this->message, 0, -7));
177 18
		}
178
179 18
		// Trim off leading ones as well
180 18
		while (str_starts_with($this->message, '[/quote]'))
181 18
		{
182
			$this->message = trim(substr($this->message, 8));
183 18
		}
184
	}
185 4
186
	/**
187
	 * Find all code blocks, work out whether we'd be parsing them,
188 4
	 * then ensure they are all closed.
189
	 */
190
	private function _validateCodeBlocks(): void
191 4
	{
192
		$in_tag = false;
193
		$had_tag = false;
194
		$code_open = false;
195
196
		if (preg_match_all('~(\[/?code(?:=[^]]+)?])~i', $this->message, $matches))
197 4
		{
198
			foreach ($matches[0] as $match)
199
			{
200
				// Closing?
201
				if ($match[1] === '/')
202 4
				{
203
					// If it's closing, and we're not in a tag, we need to open it...
204
					if (!$in_tag)
205 4
					{
206
						$code_open = true;
207 4
					}
208
209
					// Either way we ain't in one anymore.
210
					$in_tag = false;
211
				}
212
				// Opening tag...
213
				else
214 18
				{
215
					$had_tag = true;
216 4
217
					// If we're in a tag, don't do nought!
218
					if (!$in_tag)
219
					{
220 18
						$in_tag = true;
221
					}
222
				}
223
			}
224 18
		}
225
226
		// If we have an open code tag, close it.
227
		if ($in_tag)
228
		{
229 18
			$this->message .= '[/code]';
230
		}
231
		// Open any ones that need to be open, only if we've never had a tag.
232 18
		if (!$code_open)
233
		{
234
			return;
235 18
		}
236
		if ($had_tag)
237 18
		{
238
			return;
239
		}
240 18
		$this->message = '[code]' . $this->message;
241
	}
242
243 4
	/**
244
	 * Find all icode blocks, ensure they are complete pairs and do not span lines
245
	 */
246 4
	private function _validateICodeBlocks(): void
247
	{
248
		$lines = explode("\n", $this->message);
249 4
		foreach ($lines as $number => $line)
250 4
		{
251 11
			$depth = 0;
252
			preg_match_all('~(\[\/?icode(?:=[^\]]+)?\])~i', $line, $matches);
253
			foreach ($matches[0] as $match)
254
			{
255
				// Closing icode
256 18
				if ($match[1] === '/')
257 18
				{
258
					--$depth;
259
					continue;
260
				}
261
262
				++$depth;
263
			}
264
265 18
			// Open any ones that need to be open, or close if left open
266
			if ($depth !== 0)
267 18
			{
268
				$lines[$number] = $depth > 0 ? $line . '[/icode]' : '[icode]' . $line;
269
			}
270
		}
271
272
		// Put it back together
273
		$this->message = implode("\n", $lines);
274
275 18
		// Clear empty ones caused by linebreaks inside icode tags.
276
		$this->message = preg_replace('~(?<!\[icode\])\[icode\]\s*\[\/icode\]~i', '', $this->message);
277
	}
278
279
	/**
280
	 * Protects code / icode blocks from preparse by replacing them with %%token%% values
281
	 *
282
	 * @param string $message
283
	 * @param bool $html = false
284
	 * @return string
285
	 */
286
	public function tokenizeCodeBlocks($message, $html = false): string
287
	{
288
		// Split up the message on the code start/end tags/
289
		$patterns = $html
290
			? ['~(</code>|<code(?:[^>]+)?>)~', '~(</icode>|<icode(?:[^>]+)?>)~']
291
			: ['~(\[\/code\]|\[code(?:=[^\]]+)?\])~i', '~(\[\/icode\]|\[icode(?:=[^\]]+)?\])~i'];
292
293
		// Token generator
294
		$tokenizer = new TokenHash();
295
296
		foreach ($patterns as $pattern)
297
		{
298
			$parts = preg_split($pattern, $message, -1, PREG_SPLIT_DELIM_CAPTURE);
299
			foreach ($parts as $i => $part)
300
			{
301
				// It goes 0 = outside, 1 = begin tag, 2 = inside, 3 = close tag, repeat.
302
				if ($i % 4 === 0 && isset($parts[$i + 3]))
303
				{
304
					// Create a unique key to put in place of the code block
305
					$key = $tokenizer->generate_hash(8);
306
307
					// Save what is there [code]stuff[/code]
308
					$this->code_blocks['%%' . $key . '%%'] = $parts[$i + 1] . $parts[$i + 2] . $parts[$i + 3];
309
310
					// Replace the code block with %%$key%% so It's protected from further preparsecode processing
311
					$parts[$i + 1] = '%%';
312 18
					$parts[$i + 2] = $key;
313
					$parts[$i + 3] = '%%';
314
				}
315 18
			}
316
317 18
			// The message with code blocks as %%tokens%%
318
			$message = implode('', $parts);
319
		}
320
321 18
		return $message;
322
	}
323
324 18
	/**
325
	 * Fix any URLs posted - i.e., remove 'javascript:'.
326
	 *
327 18
	 * - Fix the img and url tags...
328
	 * - Fixes links in message and returns nothing.
329
	 */
330
	private function _fixTags(): void
331 18
	{
332
		global $modSettings;
333
334
		// WARNING: Editing the below can cause large security holes in your forum.
335
		// Edit only if you are sure you know what you are doing.
336
337
		$fixArray = [
338
			// [img]http://...[/img] or [img width=1]http://...[/img]
339
			[
340
				'tag' => 'img',
341
				'protocols' => ['http', 'https'],
342
				'embeddedUrl' => false,
343
				'hasEqualSign' => false,
344 18
				'hasExtra' => true,
345
			],
346 18
			// [url]http://...[/url]
347
			[
348 18
				'tag' => 'url',
349
				'protocols' => ['http', 'https'],
350 18
				'embeddedUrl' => true,
351
				'hasEqualSign' => false,
352 18
			],
353
			// [url=http://...]name[/url]
354 18
			[
355
				'tag' => 'url',
356
				'protocols' => ['http', 'https'],
357
				'embeddedUrl' => true,
358 18
				'hasEqualSign' => true,
359
			],
360
			// [iurl]http://...[/iurl]
361 18
			[
362
				'tag' => 'iurl',
363
				'protocols' => ['http', 'https'],
364 2
				'embeddedUrl' => true,
365 2
				'hasEqualSign' => false,
366 2
			],
367
			// [iurl=http://...]name[/iurl]
368 2
			[
369 2
				'tag' => 'iurl',
370
				'protocols' => ['http', 'https'],
371 2
				'embeddedUrl' => true,
372 2
				'hasEqualSign' => true,
373
			],
374 2
		];
375
376
		// Integration may want to add to this array
377
		call_integration_hook('integrate_fixtags', [&$fixArray, &$this->message]);
378
379 2
		// Fix each type of tag.
380
		foreach ($fixArray as $param)
381 2
		{
382
			$this->_fixTag($param['tag'], $param['protocols'], $param['embeddedUrl'], $param['hasEqualSign'], !empty($param['hasExtra']));
383
		}
384
385 2
		// Now fix possible security problems with images loading links automatically...
386
		$this->message = preg_replace_callback('~(\[img.*?\])(.+?)\[/img\]~is',
387
			fn($matches) => $this->_fixTags_img_callback($matches), $this->message);
388
389 2
		// Limit the size of images posted?
390
		if (!empty($modSettings['max_image_width']) || !empty($modSettings['max_image_height']))
391
		{
392
			$this->resizeBBCImages();
393
		}
394
	}
395 2
396
	/**
397 2
	 * Fix a specific class of tag - i.e., url with =.
398
	 *
399
	 * - Used by fixTags, fixes a specific tag's links.
400
	 *
401 2
	 * @param string $myTag - the tag
402
	 * @param string[] $protocols - http, https, or ftp
403
	 * @param bool $embeddedUrl = false - whether it *can* be set to something
404
	 * @param bool $hasEqualSign = false, whether it *is* set to something
405 2
	 * @param bool $hasExtra = false - whether it can have extra cruft after the beginning tag.
406
	 */
407
	private function _fixTag($myTag, $protocols, $embeddedUrl = false, $hasEqualSign = false, $hasExtra = false): void
408
	{
409 2
		global $boardurl, $scripturl;
410
411
		$replaces = [];
412
413
		$domain_url = preg_match('~^([^:]+://[^/]+)~', $boardurl, $match) != 0 ? $match[1] : $boardurl . '/';
414
415 2
		if ($hasEqualSign)
416
		{
417 2
			preg_match_all('~\[(' . $myTag . ')=([^\]]*?)\](?:(.+?)\[/(' . $myTag . ')\])?~is', $this->message, $matches);
418
		}
419 2
		else
420
		{
421
			preg_match_all('~\[(' . $myTag . ($hasExtra ? '(?:[^\]]*?)' : '') . ')\](.+?)\[/(' . $myTag . ')\]~is', $this->message, $matches);
422
		}
423 2
424
		foreach ($matches[0] as $k => $dummy)
425 2
		{
426
			// Remove all leading and trailing whitespace.
427
			$replace = trim($matches[2][$k]);
428
			$this_tag = $matches[1][$k];
429 1
			$this_close = $hasEqualSign ? (empty($matches[4][$k]) ? '' : $matches[4][$k]) : $matches[3][$k];
430
431
			$found = false;
432
			foreach ($protocols as $protocol)
433 18
			{
434
				$found = strncasecmp($replace, $protocol . '://', strlen($protocol) + 3) === 0;
435 2
				if ($found)
436
				{
437 2
					break;
438
				}
439
			}
440
441
			// Http url checking?
442 18
			if (!$found && $protocols[0] === 'http')
443
			{
444 2
				if (str_starts_with($replace, '/') && !str_starts_with($replace, '//'))
445
				{
446 18
					$replace = $domain_url . $replace;
447
				}
448
				elseif (str_starts_with($replace, '?'))
449
				{
450
					$replace = $scripturl . $replace;
451
				}
452
				elseif (str_starts_with($replace, '#') && $embeddedUrl)
453
				{
454
					$replace = '#' . preg_replace('~[^A-Za-z0-9_\-#]~', '', substr($replace, 1));
455
					$this_tag = 'iurl';
456
					$this_close = 'iurl';
457
				}
458
				elseif (str_starts_with($replace, '//'))
459
				{
460
					$replace = $protocols[0] . ':' . $replace;
461
				}
462
				else
463
				{
464
					$replace = $protocols[0] . '://' . $replace;
465
				}
466
			}
467
			// FTP URL Checking
468
			elseif (!$found && $protocols[0] === 'ftp')
469
			{
470
				$replace = $protocols[0] . '://' . preg_replace('~^(?!ftps?)[^:]+://~', '', $replace);
471
			}
472
			elseif (!$found)
473
			{
474
				$replace = $protocols[0] . '://' . $replace;
475
			}
476
477
			// Build a replacement array considered safe and proper
478
			if ($hasEqualSign && $embeddedUrl)
479
			{
480
				$replaces[$matches[0][$k]] = '[' . $this_tag . '=' . $replace . ']' . (empty($matches[4][$k]) ? '' : $matches[3][$k] . '[/' . $this_close . ']');
481
			}
482
			elseif ($hasEqualSign)
483
			{
484
				$replaces['[' . $matches[1][$k] . '=' . $matches[2][$k] . ']'] = '[' . $this_tag . '=' . $replace . ']';
485
			}
486
			elseif ($embeddedUrl)
487
			{
488
				$replaces['[' . $matches[1][$k] . ']' . $matches[2][$k] . '[/' . $matches[3][$k] . ']'] = '[' . $this_tag . '=' . $replace . ']' . $matches[2][$k] . '[/' . $this_close . ']';
489
			}
490
			else
491
			{
492
				$replaces['[' . $matches[1][$k] . ']' . $matches[2][$k] . '[/' . $matches[3][$k] . ']'] = '[' . $this_tag . ']' . $replace . '[/' . $this_close . ']';
493
			}
494
		}
495
496
		foreach ($replaces as $k => $v)
497
		{
498
			if ($k == $v)
499
			{
500
				unset($replaces[$k]);
501
			}
502
		}
503
504
		// Update as needed
505
		if (!empty($replaces))
506
		{
507
			$this->message = strtr($this->message, $replaces);
508
		}
509
	}
510
511
	/**
512
	 * Updates BBC img tags in a message so that the width / height respect the forum settings.
513
	 *
514
	 * - Will add the width/height attrib if needed, or update existing ones if they break the rules
515
	 */
516
	public function resizeBBCImages(): void
517
	{
518
		global $modSettings;
519
520
		// We'll need this for image processing
521
		require_once(SUBSDIR . '/Attachments.subs.php');
522
523
		// Find all the img tags - with or without width and height.
524
		preg_match_all('~\[img(\s+width=\d+)?(\s+height=\d+)?(\s+width=\d+)?](.+?)\[/img]~is', $this->message, $matches, PREG_PATTERN_ORDER);
525
526
		$replaces = [];
527
		foreach (array_keys($matches[0]) as $match)
528
		{
529 18
			// If the width was after the height, handle it.
530
			$matches[1][$match] = empty($matches[3][$match]) ? $matches[1][$match] : $matches[3][$match];
531 18
532 18
			// Now figure out if they had a desired height or width...
533
			$desired_width = empty($matches[1][$match]) ? 0 : (int) substr(trim($matches[1][$match]), 6);
534 18
			$desired_height = empty($matches[2][$match]) ? 0 : (int) substr(trim($matches[2][$match]), 7);
535
536 18
			// One was omitted, or both.  We'll have to find its real size...
537 18
			if (empty($desired_width) || empty($desired_height))
538
			{
539
				[$width, $height] = url_image_size(un_htmlspecialchars($matches[4][$match]));
540
541
				// They don't have any desired width or height!
542
				if (empty($desired_width) && empty($desired_height))
543
				{
544 18
					$desired_width = $width;
545
					$desired_height = $height;
546
				}
547
				// Scale it to the width...
548
				elseif (empty($desired_width) && !empty($height))
549 18
				{
550
					$desired_width = (int) (($desired_height * $width) / $height);
551 18
				}
552 18
				// Scale if to the height.
553
				elseif (!empty($width))
554 18
				{
555
					$desired_height = (int) (($desired_width * $height) / $width);
556
				}
557
			}
558
559 18
			// If the width and height are fine, just continue along...
560
			if ($desired_width <= $modSettings['max_image_width'] && $desired_height <= $modSettings['max_image_height'])
561
			{
562
				continue;
563 18
			}
564
565
			// Too bad, it's too wide.  Make it as wide as the maximum.
566
			if ($desired_width > $modSettings['max_image_width'] && !empty($modSettings['max_image_width']))
567
			{
568 18
				$desired_height = (int) (($modSettings['max_image_width'] * $desired_height) / $desired_width);
569
				$desired_width = $modSettings['max_image_width'];
570
			}
571
572 18
			// Now check the height, as well.  Might have to scale twice, even...
573
			if ($desired_height > $modSettings['max_image_height'] && !empty($modSettings['max_image_height']))
574 18
			{
575
				$desired_width = (int) (($modSettings['max_image_height'] * $desired_width) / $desired_height);
576 18
				$desired_height = $modSettings['max_image_height'];
577
			}
578 18
579
			$replaces[$matches[0][$match]] = '[img' . (empty($desired_width) ? '' : ' width=' . $desired_width) . (empty($desired_height) ? '' : ' height=' . $desired_height) . ']' . $matches[4][$match] . '[/img]';
580 18
		}
581
582 18
		// If any img tags were actually changed...
583
		if (!empty($replaces))
584 18
		{
585
			$this->message = strtr($this->message, $replaces);
586 18
		}
587
	}
588 18
589
	/**
590 18
	 * Replace /me with the users name, including inside footnotes
591
	 */
592 18
	private function _itsAllAbout(): void
593
	{
594 18
		$me_regex = '~(\A|\n)/me(?: |&nbsp;)([^\n]*)(?:\z)?~i';
595 18
		$footnote_regex = '~(\[footnote\])/me(?: |&nbsp;)([^\n]*?)(\[\/footnote\])~i';
596 18
597
		if (preg_match('~[\[\]\\"]~', $this->user_name) !== false)
598 18
		{
599
			$this->message = preg_replace($me_regex, '$1[me=&quot;' . $this->user_name . '&quot;]$2[/me]', $this->message);
600 18
			$this->message = preg_replace($footnote_regex, '$1[me=&quot;' . $this->user_name . '&quot;]$2[/me]$3', $this->message);
601
		}
602 18
		else
603
		{
604 18
			$this->message = preg_replace($me_regex, '$1[me=' . $this->user_name . ']$2[/me]', $this->message);
605 18
			$this->message = preg_replace($footnote_regex, '$1[me=' . $this->user_name . ']$2[/me]$3', $this->message);
606
		}
607 18
	}
608
609 18
	/**
610
	 * Make sure lists have open and close tags
611 18
	 */
612
	private function _validateLists(): void
613 18
	{
614
		$list_open = substr_count($this->message, '[list]') + substr_count($this->message, '[list ');
615
		$list_close = substr_count($this->message, '[/list]');
616
617 18
		if ($list_close - $list_open > 0)
618
		{
619 18
			$this->message = str_repeat('[list]', $list_close - $list_open) . $this->message;
620
		}
621 18
622
		if ($list_open - $list_close > 0)
623
		{
624
			$this->message .= str_repeat('[/list]', $list_open - $list_close);
625
		}
626 18
	}
627
628 18
	/**
629
	 * Repair a few *cough* common mistakes from user input and from wizzy cut/paste
630 4
	 */
631
	private function _fixMistakes(): void
632 18
	{
633
		$mistake_fixes = [
634
			// Find [table]s not followed by [tr].
635
			'~\[table\](?![\s' . self::NBS . ']*\[tr\])~su' => '[table][tr]',
636
			// Find [tr]s not followed by [td] or [th]
637
			'~\[tr\](?![\s' . self::NBS . ']*\[t[dh]\])~su' => '[tr][td]',
638
			// Find [/td] and [/th]s not followed by something valid.
639
			'~\[/t([dh])\](?![\s' . self::NBS . ']*(?:\[t[dh]\]|\[/tr\]|\[/table\]))~su' => '[/t$1][/tr]',
640
			// Find [/tr]s not followed by something valid.
641
			'~\[/tr\](?![\s' . self::NBS . ']*(?:\[tr\]|\[/table\]))~su' => '[/tr][/table]',
642
			// Find [/td] [/th]s incorrectly followed by [/table].
643
			'~\[/t([dh])\][\s' . self::NBS . ']*\[/table\]~su' => '[/t$1][/tr][/table]',
644 18
			// Find [table]s, [tr]s, and [/td]s (possibly correctly) followed by [td].
645
			'~\[(table|tr|/td)\]([\s' . self::NBS . ']*)\[td\]~su' => '[$1]$2[_td_]',
646 18
			// Now, any [td]s left should have a [tr] before them.
647 18
			'~\[td\]~s' => '[tr][td]',
648 18
			// Look for [tr]s which are correctly placed.
649
			'~\[(table|/tr)\]([\s' . self::NBS . ']*)\[tr\]~su' => '[$1]$2[_tr_]',
650
			// Any remaining [tr]s should have a [table] before them.
651
			'~\[tr\]~s' => '[table][tr]',
652 18
			// Look for [/td]s or [/th]s followed by [/tr].
653
			'~\[/t([dh])\]([\s' . self::NBS . ']*)\[/tr\]~su' => '[/t$1]$2[_/tr_]',
654
			// Any remaining [/tr]s should have a [/td].
655
			'~\[/tr\]~s' => '[/td][/tr]',
656
			// Look for properly opened [li]s which aren't closed.
657
			'~\[li\]([^\[\]]+?)\[li\]~s' => '[li]$1[_/li_][_li_]',
658
			'~\[li\]([^\[\]]+?)\[/list\]~s' => '[_li_]$1[_/li_][/list]',
659 18
			'~\[li\]([^\[\]]+?)$~s' => '[li]$1[/li]',
660
			// Lists - find correctly closed items/lists.
661
			'~\[/li\]([\s' . self::NBS . ']*)\[/list\]~su' => '[_/li_]$1[/list]',
662 2
			// Find list items closed and then opened.
663 2
			'~\[/li\]([\s' . self::NBS . ']*)\[li\]~su' => '[_/li_]$1[_li_]',
664
			// Now, find any [list]s or [/li]s followed by [li].
665
			'~\[(list(?: [^\]]*?)?|/li)\]([\s' . self::NBS . ']*)\[li\]~su' => '[$1]$2[_li_]',
666 2
			// Allow for sub lists.
667
			'~\[/li\]([\s' . self::NBS . ']*)\[list\]~u' => '[_/li_]$1[list]',
668
			'~\[/list\]([\s' . self::NBS . ']*)\[li\]~u' => '[/list]$1[_li_]',
669 2
			// Any remaining [li]s weren't inside a [list].
670
			'~\[li\]~' => '[list][li]',
671
			// Any remaining [/li]s weren't before a [/list].
672
			'~\[/li\]~' => '[/li][/list]',
673
			// Put the correct ones back how we found them.
674
			'~\[_(li|/li|td|tr|/tr)_\]~' => '[$1]',
675
			// Images with no real url.
676 2
			'~\[img\]https?://.{0,7}\[/img\]~' => '',
677
		];
678
679
		// Fix up some use of tables without [tr]s, etc. (it has to be done more than once to catch it all.)
680 2
		for ($j = 0; $j < 3; $j++)
681
		{
682
			$this->message = preg_replace(array_keys($mistake_fixes), $mistake_fixes, $this->message);
683
		}
684
	}
685
686
	/**
687 2
	 * Replace our tokenized message with the saved code blocks
688
	 *
689
	 * @param string $message
690
	 * @return string
691 2
	 */
692
	public function restoreCodeBlocks($message): string
693
	{
694
		if (!empty($this->code_blocks))
695
		{
696
			return str_replace(array_keys($this->code_blocks), array_values($this->code_blocks), $message);
697
		}
698
699
		return $message;
700 2
	}
701 2
702
	/**
703
	 * Validates and corrects table structure
704
	 *
705 18
	 * What it does
706
	 *   - Check tables for correct tag order / nesting
707
	 *   - Adds in missing closing tags, removes excess closing tags
708
	 *   - Although it prevents markup error, it can mess up the intended (abet wrong) layout
709 18
	 * driving the post-author in to a furious rage
710
	 *
711
	 */
712
	private function _preparseTable(): void
713
	{
714
		$table_check = $this->message;
715
		$table_offset = 0;
716
		$table_array = [];
717
718
		// Define the allowable tags after a give tag
719
		$table_order = [
720
			'table' => ['tr'],
721
			'tr' => ['td', 'th'],
722
			'td' => ['table'],
723
			'th' => [''],
724
		];
725
726
		// Find all closing tags (/table /tr /td etc.)
727
		while (preg_match('~\[(/)*(table|tr|td|th)\]~', $table_check, $matches) === 1)
728
		{
729
			// Keep track of where this is.
730
			$offset = strpos($table_check, $matches[0]);
731
			$remove_tag = false;
732
733
			// Is it opening?
734
			if ($matches[1] !== '/')
735
			{
736
				// If the previous table tag isn't correct, simply remove it.
737
				if ((!empty($table_array) && !in_array($matches[2], $table_order[$table_array[0]])) || (empty($table_array) && $matches[2] !== 'table'))
738
				{
739
					$remove_tag = true;
740
				}
741
				// Record this was the last tag.
742
				else
743
				{
744
					array_unshift($table_array, $matches[2]);
745
				}
746
			}
747
			// Otherwise is closed!
748
			elseif (empty($table_array) || ($table_array[0] !== $matches[2]))
749
			{
750
				// Only keep the tag if it's closing the right thing.
751
				$remove_tag = true;
752
			}
753 4
			else
754
			{
755 4
				array_shift($table_array);
756 4
			}
757
758 4
			// Removing?
759
			if ($remove_tag)
760
			{
761
				$this->message = substr($this->message, 0, $table_offset + $offset) . substr($this->message, $table_offset + strlen($matches[0]) + $offset);
762
763
				// We've lost some data.
764
				$table_offset -= strlen($matches[0]);
765
			}
766
767
			// Remove everything up to here.
768 4
			$table_offset += $offset + strlen($matches[0]);
769
			$table_check = substr($table_check, $offset + strlen($matches[0]));
770 4
		}
771
772
		// Close any remaining table tags.
773
		foreach ($table_array as $tag)
774
		{
775
			$this->message .= '[/' . $tag . ']';
776
		}
777
	}
778
779
	/**
780
	 * Validates bbc code URL of the form: [url url=123.com follow=true]123[/url]
781
	 *
782
	 * - Modifies if the user does not have the post_nofollow permission
783
	 * - Checks if the domain is on the allowList and modifies as required
784
	 */
785
	private function _validateLinks(): void
786
	{
787
		$allowed = allowedTo('post_nofollow');
788
		$regexFollow = '~\[url[^]]*(follow=([^] \s]+))[^]]*]~';
789
		$regexUrl = '~\[url[^]]*(url=([^] \s]+))[^]]*]~';
790
791
		preg_match_all($regexFollow, $this->message, $matches);
792
		if (isset($matches[1]) && is_array($matches[1]))
793 18
		{
794
			// Every [URL] code with follow= in them
795 18
			foreach ($matches[1] as $key => $followTerm)
796
			{
797 2
				// Flush out the actual URL and follow value
798
				preg_match($regexUrl, $matches[0][$key], $match);
799
				$allowedDomain = validateURLAllowList(addProtocol($match[2]));
800 18
				$followChoice = in_array(trim($matches[2][$key]), ['follow', 'true', 'on', 'yes'], true);
801
802
				// Allowed domain and purposely turning it off?
803
				if ($allowedDomain && $allowed && !$followChoice)
804
				{
805
					$this->message = str_replace($followTerm, 'follow=false', $this->message);
806
				}
807
				// Allowed domain OR you are allowed and already have it on
808
				elseif ($allowedDomain || ($allowed && $followChoice))
809
				{
810
					$this->message = str_replace($followTerm, 'follow=true', $this->message);
811
				}
812
				// Not allowed to use the function, and the domain is not on the allowList
813
				else
814
				{
815
					$this->message = str_replace($followTerm, 'follow=false', $this->message);
816
				}
817
			}
818
		}
819
	}
820
821
	/**
822
	 * This is very simple and just removes things done by preparsecode.
823
	 *
824
	 * @param string $message
825
	 *
826
	 * @return null|string|string[]
827
	 */
828
	public function un_preparsecode($message)
829
	{
830
		// Protect CODE blocks from further processing
831
		$message = $this->tokenizeCodeBlocks($message);
832
833
		// Pass integration the tokenized message and array
834
		call_integration_hook('integrate_unpreparse_code', [&$message, &$this->code_blocks, 0]);
835
836
		// Restore the code blocks
837
		$message = $this->restoreCodeBlocks($message);
838
839
		// Change breaks back to \n's and &nsbp; back to spaces.
840
		return preg_replace('~<br( /)?>~', "\n", str_replace('&nbsp;', ' ', $message));
841
	}
842
843
	/**
844
	 * Ensure tags inside nobbc do not get parsed by converting the markers to HTML entities
845
	 *
846
	 * @param string[] $matches
847
	 *
848
	 * @return string
849
	 */
850
	private function _preparsecode_nobbc_callback($matches): string
851
	{
852
		return '[nobbc]' . strtr($matches[1], ['[' => '&#91;', ']' => '&#93;', ':' => '&#58;', '@' => '&#64;']) . '[/nobbc]';
853
	}
854
855
	/**
856
	 * Use only the primary (first) font face when multiples are supplied
857
	 *
858
	 * @param string[] $matches
859
	 *
860
	 * @return string
861
	 */
862
	private function _preparsecode_font_callback($matches): string
863
	{
864
		$fonts = explode(',', $matches[1]);
865
		$font = trim(un_htmlspecialchars($fonts[0]), ' "\'');
866
867
		return '[font=' . $font . ']' . $matches[2];
868
	}
869
870
	/**
871
	 * Takes a tag and changes it to lowercase
872
	 *
873
	 * @param string[] $matches
874
	 *
875
	 * @return string
876
	 */
877
	private function _preparsecode_lowertags_callback($matches): string
878
	{
879
		return '[' . $matches[1] . strtolower($matches[2]) . $matches[3] . ']';
880
	}
881
882
	/**
883
	 * Ensure image tags do not load anything by themselves (security)
884
	 *
885
	 * @param string[] $matches
886
	 *
887
	 * @return string
888
	 */
889
	private function _fixTags_img_callback($matches): string
890
	{
891
		return $matches[1] . preg_replace('~action(=|%3d)(?!dlattach)~i', 'action-', $matches[2]) . '[/img]';
892
	}
893
894
	/**
895
	 * Find and return PreparseCode instance if it exists,
896
	 * or create a new instance
897
	 *
898
	 * @param string $user the name of the user (mostly used in quote tags)
899
	 *
900
	 * @return PreparseCode
901
	 */
902
	public static function instance($user): PreparseCode
903
	{
904
		if (self::$instance === null)
905
		{
906
			self::$instance = new PreparseCode($user);
907
		}
908
		elseif ($user !== self::$instance->user_name)
909
		{
910
			self::$instance = new PreparseCode($user);
911
		}
912
913
		return self::$instance;
914
	}
915
}
916