1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Libraries;
7
8/*
9* Note that this class doesn't actually parse random bbcode.
10* It only does "second pass" parsing of phpbb-preprocessed bbcode.
11* Nothing in this class does any kind of checking because it should
12* be already done by phpbb.
13*/
14class BBCodeFromDB
15{
16 public $text;
17 public $uid;
18 public $refId;
19 public $withGallery;
20
21 private array $options;
22
23 public function __construct($text, $uid = '', $options = [])
24 {
25 $defaultOptions = [
26 'withGallery' => false,
27 'ignoreLineHeight' => false,
28 'extraClasses' => '',
29 'modifiers' => [],
30 ];
31
32 $this->text = $text;
33 $this->uid = presence($uid) ?? $GLOBALS['cfg']['osu']['bbcode']['uid'];
34 $this->options = array_merge($defaultOptions, $options);
35
36 if ($this->options['withGallery']) {
37 $this->refId = rand();
38 }
39 }
40
41 public function parseAudio($text)
42 {
43 preg_match_all("#\[audio:{$this->uid}\](?<url>[^[]+)\[/audio:{$this->uid}\]#", $text, $matches, PREG_SET_ORDER);
44
45 foreach ($matches as $match) {
46 $proxiedSrc = proxy_media(html_entity_decode_better($match['url']));
47 $tag = '<audio controls="controls" preload="none" src="'.$proxiedSrc.'"></audio>';
48
49 $text = str_replace($match[0], $tag, $text);
50 }
51
52 return $text;
53 }
54
55 public function parseBold($text)
56 {
57 $text = str_replace("[b:{$this->uid}]", '<strong>', $text);
58 $text = str_replace("[/b:{$this->uid}]", '</strong>', $text);
59
60 return $text;
61 }
62
63 public function parseBox($text)
64 {
65 $text = preg_replace("#\[box=((\\\[\[\]]|[^][]|\[(\\\[\[\]]|[^][]|(?R))*\])*?):{$this->uid}\]\n*#s", $this->parseBoxHelperPrefix('\\1'), $text);
66 $text = preg_replace("#\n*\[/box:{$this->uid}]\n?#s", $this->parseBoxHelperSuffix(), $text);
67
68 $text = preg_replace("#\[spoilerbox:{$this->uid}\]\n*#s", $this->parseBoxHelperPrefix(), $text);
69 $text = preg_replace("#\n*\[/spoilerbox:{$this->uid}]\n?#s", $this->parseBoxHelperSuffix(), $text);
70
71 return $text;
72 }
73
74 public function parseBoxHelperPrefix($linkText = null)
75 {
76 $linkText = presence($linkText) ?? 'SPOILER';
77
78 return "<div class='js-spoilerbox bbcode-spoilerbox'><a class='js-spoilerbox__link bbcode-spoilerbox__link' href='#'><span class='bbcode-spoilerbox__link-icon'></span>{$linkText}</a><div class='js-spoilerbox__body bbcode-spoilerbox__body'>";
79 }
80
81 public function parseBoxHelperSuffix()
82 {
83 return '</div></div>';
84 }
85
86 public function parseCentre($text)
87 {
88 $text = str_replace("[centre:{$this->uid}]", '<center>', $text);
89 $text = str_replace("[/centre:{$this->uid}]", '</center>', $text);
90
91 return $text;
92 }
93
94 public function parseCode($text)
95 {
96 return preg_replace(
97 "#\[code:{$this->uid}\]\n*(.*?)\n*\[/code:{$this->uid}\]\n?#s",
98 '<pre>\\1</pre>',
99 $text
100 );
101 }
102
103 public function parseColour($text)
104 {
105 $text = preg_replace("#\[color=([^:]+):{$this->uid}\]#", "<span style='color:\\1'>", $text);
106 $text = str_replace("[/color:{$this->uid}]", '</span>', $text);
107
108 return $text;
109 }
110
111 public function parseEmail($text)
112 {
113 $text = preg_replace(
114 "#\[email:{$this->uid}\](.+?)\[/email:{$this->uid}\]#",
115 "<a rel='nofollow' href='mailto:\\1'>\\1</a>",
116 $text
117 );
118 $text = preg_replace("#\[email=([^\]]+):{$this->uid}\]#", "<a rel='nofollow' href='mailto:\\1'>", $text);
119 $text = str_replace("[/email:{$this->uid}]", '</a>', $text);
120
121 return $text;
122 }
123
124 public function parseHeading($text)
125 {
126 $text = str_replace("[heading:{$this->uid}]", '<h2>', $text);
127 $text = preg_replace("#\[/heading:{$this->uid}\]\n?#", '</h2>', $text);
128
129 return $text;
130 }
131
132 public function parseImagemap($text)
133 {
134 return preg_replace_callback(
135 '#(\[imagemap\].+?\[/imagemap\]\n?)#',
136 function ($m) {
137 $unescaped = html_entity_decode_better(BBCodeForDB::extraUnescape($m[1]));
138 $parsed = preg_replace_callback(
139 '#\[imagemap\]\n(?<imageUrl>https?://.+)\n(?<links>(?:(?:[0-9.]+ ){4}(?:\#|https?://[^\s]+|mailto:[^\s]+)(?: .*)?\n)+)\[/imagemap\]\n?#',
140 function ($map) {
141 $links = array_map(
142 fn ($rawLink) => explode(' ', $rawLink, 6),
143 explode("\n", $map['links']),
144 );
145 array_pop($links); // remove the empty string from last newline
146
147 $linksHtml = implode('', array_map(
148 fn ($link) => tag($link[4] === '#' ? 'span' : 'a', [
149 'class' => 'imagemap__link',
150 'href' => $link[4],
151 'style' => implode(';', [
152 "left: {$link[0]}%",
153 "top: {$link[1]}%",
154 "width: {$link[2]}%",
155 "height: {$link[3]}%",
156 ]),
157 'title' => $link[5] ?? '',
158 ]),
159 $links,
160 ));
161
162 $imageUrl = proxy_media($map['imageUrl']);
163 $imageAttributes = [
164 'class' => 'imagemap__image',
165 'loading' => 'lazy',
166 'src' => $imageUrl,
167 ];
168 $imageSize = fast_imagesize($imageUrl);
169 if ($imageSize !== null) {
170 $imageAttributes['width'] = $imageSize[0];
171 $imageAttributes['height'] = $imageSize[1];
172 }
173 $imageHtml = tag('img', $imageAttributes);
174
175 return tag('div', ['class' => 'imagemap'], $imageHtml.$linksHtml);
176 },
177 $unescaped,
178 );
179
180 return $parsed === $unescaped ? $m[1] : $parsed;
181 },
182 $text,
183 );
184 }
185
186 public function parseItalic($text)
187 {
188 $text = str_replace("[i:{$this->uid}]", '<em>', $text);
189 $text = str_replace("[/i:{$this->uid}]", '</em>', $text);
190
191 return $text;
192 }
193
194 public function parseImage($text)
195 {
196 preg_match_all("#\[img:{$this->uid}\](?<url>[^[]+)\[/img:{$this->uid}\]#", $text, $images, PREG_SET_ORDER);
197
198 $index = 0;
199 $replacements = [];
200
201 foreach ($images as $i) {
202 $proxiedSrc = proxy_media(html_entity_decode_better($i['url']));
203
204 $attributes = [
205 'alt' => '',
206 'src' => $proxiedSrc,
207 'loading' => 'lazy',
208 ];
209
210 $imageSize = fast_imagesize($proxiedSrc);
211 if ($imageSize !== null && $imageSize[1] !== 0) {
212 $aspectRatio = round($imageSize[0] / $imageSize[1], 4);
213
214 $attributes['style'] = "aspect-ratio: {$aspectRatio}; width: {$imageSize[0]}px;";
215
216 if ($this->options['withGallery']) {
217 $attributes = [
218 ...$attributes,
219 'class' => 'js-gallery',
220 'data-width' => $imageSize[0],
221 'data-height' => $imageSize[1],
222 'data-index' => $index,
223 'data-gallery-id' => $this->refId,
224 'data-src' => $proxiedSrc,
225 ];
226 }
227
228 $index += 1;
229 }
230
231 $replacements[$i[0]] = tag('img', $attributes);
232 }
233
234 return strtr($text, $replacements);
235 }
236
237 public function parseInlineCode(string $text): string
238 {
239 return strtr($text, [
240 "[c:{$this->uid}]" => '<code>',
241 "[/c:{$this->uid}]" => '</code>',
242 ]);
243 }
244
245 public function parseList($text)
246 {
247 // basic list.
248 $text = preg_replace("#\[list=[^]]+:{$this->uid}\]\s*\[\*:{$this->uid}\]#", '<ol><li>', $text);
249 $text = preg_replace("#\[list:{$this->uid}\]\s*\[\*:{$this->uid}\]#", '<ol class="unordered"><li>', $text);
250
251 // convert list items.
252 $text = preg_replace("#\[/\*(:m)?:{$this->uid}\]\n?\n?#", '</li>', $text);
253 $text = preg_replace("#\s*\[\*:{$this->uid}\]#", '<li>', $text);
254
255 // close list tags.
256 $text = preg_replace("#\s*\[/list:(o|u):{$this->uid}\]\n?\n?#", '</ol>', $text);
257
258 // list with "title", with it being just a list without style.
259 $text = preg_replace("#\[list=[^]]+:{$this->uid}\](.+?)(<li>|</ol>)#s", '<ul class="bbcode__list-title"><li>$1</li></ul><ol>$2', $text);
260 $text = preg_replace("#\[list:{$this->uid}\](.+?)(<li>|</ol>)#s", '<ul class="bbcode__list-title"><li>$1</li></ul><ol class="unordered">$2', $text);
261
262 return $text;
263 }
264
265 public function parseNotice($text)
266 {
267 return preg_replace(
268 "#\[notice:{$this->uid}\]\n*(.*?)\n*\[/notice:{$this->uid}\]\n?#s",
269 "<div class='well'>\\1</div>",
270 $text
271 );
272 }
273
274 public function parseProfile($text)
275 {
276 preg_match_all("#\[profile(?:=(?<id>[0-9]+))?:{$this->uid}\](?<name>.*?)\[/profile:{$this->uid}\]#", $text, $users, PREG_SET_ORDER);
277
278 foreach ($users as $user) {
279 $username = html_entity_decode_better($user['name']);
280 $userId = presence($user['id']) ?? "@{$username}";
281 $userLink = link_to_user($userId, $username, null);
282 $text = str_replace($user[0], $userLink, $text);
283 }
284
285 return $text;
286 }
287
288 public function parseQuote($text)
289 {
290 $text = preg_replace("#\[quote="([^:]+)":{$this->uid}\]\s*#", '<blockquote><h4>\\1 wrote:</h4>', $text);
291 $text = preg_replace("#\[quote:{$this->uid}\]\s*#", '<blockquote>', $text);
292 $text = preg_replace("#\s*\[/quote:{$this->uid}\]\n?\n?#", '</blockquote>', $text);
293
294 return $text;
295 }
296
297 // stolen from: www/forum/includes/functions.php:2845
298 public function parseSmilies($text)
299 {
300 return preg_replace('#<!\-\- s(.*?) \-\-><img src="\{SMILIES_PATH\}\/(.*?) \/><!\-\- s\1 \-\->#', '<img class="smiley" src="'.osu_url('smilies').'/\2 />', $text);
301 }
302
303 public function parseStrike($text)
304 {
305 $text = str_replace("[s:{$this->uid}]", '<del>', $text);
306 $text = str_replace("[/s:{$this->uid}]", '</del>', $text);
307 $text = str_replace("[strike:{$this->uid}]", '<del>', $text);
308 $text = str_replace("[/strike:{$this->uid}]", '</del>', $text);
309
310 return $text;
311 }
312
313 public function parseUnderline($text)
314 {
315 $text = str_replace("[u:{$this->uid}]", '<u>', $text);
316 $text = str_replace("[/u:{$this->uid}]", '</u>', $text);
317
318 return $text;
319 }
320
321 public function parseSpoiler($text)
322 {
323 $text = str_replace("[spoiler:{$this->uid}]", "<span class='spoiler'>", $text);
324 $text = str_replace("[/spoiler:{$this->uid}]", '</span>', $text);
325
326 return $text;
327 }
328
329 public function parseSize($text)
330 {
331 $text = preg_replace_callback(
332 "#\[size=(\d+):{$this->uid}\]#",
333 fn ($m) => '<span style="font-size:'.\Number::clamp((int) $m[1], 30, 200).'%;">',
334 $text,
335 );
336 $text = strtr($text, ["[/size:{$this->uid}]" => '</span>']);
337
338 return $text;
339 }
340
341 public function parseUrl($text)
342 {
343 $text = preg_replace("#\[url:{$this->uid}\](.+?)\[/url:{$this->uid}\]#", "<a rel='nofollow' href='\\1'>\\1</a>", $text);
344 $text = preg_replace("#\[url=([^\]]+):{$this->uid}\]#", "<a rel='nofollow' href='\\1'>", $text);
345 $text = str_replace("[/url:{$this->uid}]", '</a>', $text);
346
347 return $text;
348 }
349
350 public function parseYoutube(string $text): string
351 {
352 return strtr($text, [
353 "[youtube:{$this->uid}]" => "<iframe class='u-embed-wide u-embed-wide--bbcode' src='https://www.youtube.com/embed/",
354 "[/youtube:{$this->uid}]" => "?rel=0' allowfullscreen></iframe>",
355 ]);
356 }
357
358 public function toHTML()
359 {
360 $text = $this->text;
361
362 // block
363 $text = $this->parseImagemap($text);
364 $text = $this->parseBox($text);
365 $text = $this->parseCode($text);
366 $text = $this->parseList($text);
367 $text = $this->parseNotice($text);
368 $text = $this->parseQuote($text);
369 $text = $this->parseHeading($text);
370
371 // inline
372 $text = $this->parseAudio($text);
373 $text = $this->parseBold($text);
374 $text = $this->parseCentre($text);
375 $text = $this->parseInlineCode($text);
376 $text = $this->parseColour($text);
377 $text = $this->parseEmail($text);
378 $text = $this->parseImage($text);
379 $text = $this->parseItalic($text);
380 $text = $this->parseSize($text);
381 $text = $this->parseSmilies($text);
382 $text = $this->parseSpoiler($text);
383 $text = $this->parseStrike($text);
384 $text = $this->parseUnderline($text);
385 $text = $this->parseUrl($text);
386 $text = $this->parseYoutube($text);
387 $text = $this->parseProfile($text);
388
389 $text = str_replace("\n", '<br />', $text);
390 $text = app('clean-html')->purify($text);
391
392 $className = class_with_modifiers('bbcode', $this->options['modifiers']);
393
394 if (present($this->options['extraClasses'])) {
395 $className .= " {$this->options['extraClasses']}";
396 }
397
398 if ($this->options['ignoreLineHeight']) {
399 $className .= ' bbcode--normal-line-height';
400 }
401
402 return "<div class='{$className}'>{$text}</div>";
403 }
404
405 public function toEditor()
406 {
407 $text = $this->text;
408
409 // remove list item closing tags
410 $text = str_replace("[/*:m:{$this->uid}]", '', $text);
411
412 // remove list item type marker at closing tags
413 $text = preg_replace("#\[/list:[ou]:{$this->uid}\]#", '[/list]', $text);
414
415 // strip uids
416 $text = str_replace(":{$this->uid}]", ']', $text);
417
418 // strip url
419 $text = preg_replace('#<!-- ([mw]) --><a.*?href=[\'"]([^"\']+)[\'"].*?>.*?</a><!-- \\1 -->#', '\\2', $text);
420 $text = preg_replace('#<!-- e --><a.*?href=[\'"]mailto:([^"\']+)[\'"].*?>.*?</a><!-- e -->#', '\\1', $text);
421
422 // strip relative url
423 $text = preg_replace('#<!-- l --><a.*?href="(.*?)".*?>.*?</a><!-- l -->#', '\\1', $text);
424
425 // strip smilies
426 $text = preg_replace('#<!-- (s(.*?)) -->.*?<!-- \\1 -->#', '\\2', $text);
427
428 return html_entity_decode_better($text);
429 }
430
431 public static function removeBBCodeTags($text)
432 {
433 // Don't care if too many characters are stripped;
434 // just don't want tags to go into index because they mess up the highlighting.
435
436 static $pattern = '#\[/?(\*|\*:m|audio|b|box|color|spoilerbox|centre|code|email|heading|i|img|list|list:o|list:u|notice|profile|quote|s|strike|u|spoiler|size|url|youtube)(=.*?(?=:))?(:[a-zA-Z0-9]{1,5})?\]#';
437
438 return preg_replace($pattern, '', $text);
439 }
440
441 public static function removeBlockQuotes($text)
442 {
443 static $pattern = '#(?<start>\[quote(=.*?(?=:))?(:[a-zA-Z0-9]{1,5})?\])|(?<end>\[/quote(:[a-zA-Z0-9]{1,5})?\])#';
444
445 $matchCount = preg_match_all($pattern, $text);
446 $quotePositions = [];
447
448 for ($_ = 0; $_ < $matchCount; $_++) {
449 $offset = $quotePositions[count($quotePositions) - 1][1] ?? 0;
450 preg_match($pattern, $text, $match, PREG_OFFSET_CAPTURE, $offset);
451
452 if (present($match['start'][0])) {
453 $quotePositions[] = [
454 $match['start'][1],
455 $match['start'][1] + strlen($match['start'][0]),
456 ];
457 } elseif (!empty($quotePositions)) {
458 $quoteEnd = $match['end'][1] + strlen($match['end'][0]);
459 $text = substr($text, 0, array_pop($quotePositions)[0]).substr($text, $quoteEnd);
460 }
461 }
462
463 return $text;
464 }
465}