the browser-facing portion of osu!
at master 17 kB view raw
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=&quot;([^:]+)&quot;:{$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}