the browser-facing portion of osu!
at master 12 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 8use App\Models\User; 9 10class BBCodeForDB 11{ 12 const EXTRA_ESCAPES = [ 13 '[' => '&#91;', 14 ']' => '&#93;', 15 '.' => '&#46;', 16 ':' => '&#58;', 17 "\n" => '&#10;', 18 '@' => '&#64;', 19 ]; 20 21 public $text; 22 public $uid; 23 24 // "11111111111111111111111111111" 25 // encoded with: https://www.phpbb.com/support/docs/en/3.0/kb/article/how-to-template-bitfield-and-bbcodes/ 26 // number of 1s are arbitrary 27 public $bitfield = '////+A=='; 28 29 public function extraEscapes($text) 30 { 31 return strtr($text, static::EXTRA_ESCAPES); 32 } 33 34 public static function extraUnescape(string $text): string 35 { 36 static $mapping; 37 $mapping ??= array_flip(static::EXTRA_ESCAPES); 38 39 return strtr($text, $mapping); 40 } 41 42 public function __construct($text = '') 43 { 44 $this->text = $text; 45 $this->uid = $GLOBALS['cfg']['osu']['bbcode']['uid']; 46 } 47 48 public function parseAudio($text) 49 { 50 preg_match_all('#\[audio\](?<url>.*?)\[/audio\]#', $text, $audio, PREG_SET_ORDER); 51 52 foreach ($audio as $a) { 53 $escapedUrl = $this->extraEscapes($a['url']); 54 55 $audioTag = "[audio:{$this->uid}]{$escapedUrl}[/audio:{$this->uid}]"; 56 $text = str_replace($a[0], $audioTag, $text); 57 } 58 59 return $text; 60 } 61 62 /** 63 * Handles: 64 * - Centre (centre). 65 */ 66 public function parseBlockSimple($text) 67 { 68 foreach (['centre'] as $tag) { 69 $text = preg_replace( 70 "#\[{$tag}](.*?)\[/{$tag}\]#s", 71 "[{$tag}:{$this->uid}]\\1[/{$tag}:{$this->uid}]", 72 $text 73 ); 74 } 75 76 return $text; 77 } 78 79 public function parseBox($text) 80 { 81 $text = preg_replace('#\[box=((\\\[\[\]]|[^][]|\[(\\\[\[\]]|[^][]|(?R))*\])*?)\]#s', "[box=\\1:{$this->uid}]", $text); 82 83 return strtr($text, [ 84 '[/box]' => "[/box:{$this->uid}]", 85 '[spoilerbox]' => "[spoilerbox:{$this->uid}]", 86 '[/spoilerbox]' => "[/spoilerbox:{$this->uid}]", 87 ]); 88 } 89 90 public function parseCode($text) 91 { 92 return preg_replace_callback( 93 "#\[code\](?<prespaces>\n*)(?<code>.+?)(?<postspaces>\n*)\[/code\]#s", 94 function ($m) { 95 $escapedCode = $this->extraEscapes($m['code']); 96 97 return "[code:{$this->uid}]{$m['prespaces']}{$escapedCode}{$m['postspaces']}[/code:{$this->uid}]"; 98 }, 99 $text 100 ); 101 } 102 103 public function parseColour($text) 104 { 105 return preg_replace( 106 ',\[(color=(?:#[[:xdigit:]]{6}|[[:alpha:]]+))\](.*?)\[(/color)\],s', 107 "[\\1:{$this->uid}]\\2[\\3:{$this->uid}]", 108 $text 109 ); 110 } 111 112 public function parseEmail($text) 113 { 114 $emailPattern = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z-]+'; 115 116 $text = preg_replace_callback( 117 "#\[email\]({$emailPattern})\[/email\]#", 118 fn (array $m): string => "[email:{$this->uid}]{$this->extraEscapes($m[1])}[/email:{$this->uid}]", 119 $text 120 ); 121 $text = preg_replace_callback( 122 "#\[email=({$emailPattern})\](.+?)\[/email\]#", 123 fn (array $m): string => "[email={$this->extraEscapes($m[1])}:{$this->uid}]{$this->extraEscapes($m[2])}[/email:{$this->uid}]", 124 $text 125 ); 126 127 return $text; 128 } 129 130 public function parseImage($text) 131 { 132 preg_match_all('#\[img\](?<url>.*?)\[/img\]#', $text, $images, PREG_SET_ORDER); 133 134 foreach ($images as $i) { 135 $escapedUrl = $this->extraEscapes($i['url']); 136 137 $imageTag = "[img:{$this->uid}]{$escapedUrl}[/img:{$this->uid}]"; 138 $text = str_replace($i[0], $imageTag, $text); 139 } 140 141 return $text; 142 } 143 144 public function parseImagemap($text) 145 { 146 return preg_replace_callback( 147 '#\[imagemap\](.+?)\[/imagemap\]#s', 148 function ($m) { 149 $escapedMap = $this->extraEscapes($m[1]); 150 151 return "[imagemap]{$escapedMap}[/imagemap]"; 152 }, 153 $text 154 ); 155 } 156 157 /** 158 * Handles: 159 * - Code (c) 160 * - Heading (heading) 161 */ 162 public function parseInlineSimple(string $text): string 163 { 164 foreach (['c', 'heading'] as $tag) { 165 $text = preg_replace( 166 "#\[{$tag}](.*?)\[/{$tag}\]#", 167 "[{$tag}:{$this->uid}]\\1[/{$tag}:{$this->uid}]", 168 $text 169 ); 170 } 171 172 return $text; 173 } 174 175 public function parseLinks($text) 176 { 177 $spaces = ['(^|\[.+?\]|\s(?:&lt;|[.:([])*)', "((?:\[.+?\]|&gt;|[.:)\]])*(?:$|\s|\n|\r))"]; 178 $internalUrl = rtrim(preg_quote($GLOBALS['cfg']['app']['url'], '#'), '/'); 179 180 // internal url 181 $text = preg_replace( 182 "#{$spaces[0]}({$internalUrl}/([^\s]+?))(?={$spaces[1]})#", 183 "\\1<!-- m --><a href='\\2' rel='nofollow'>\\3</a><!-- m -->", 184 $text 185 ); 186 187 // plain http/https/ftp 188 $text = preg_replace( 189 "#{$spaces[0]}((?:https?|ftp)://[^\s]+?)(?={$spaces[1]})#", 190 "\\1<!-- m --><a href='\\2' rel='nofollow'>\\2</a><!-- m -->", 191 $text 192 ); 193 194 // www 195 $text = preg_replace( 196 "#{$spaces[0]}(www\.[^\s]+)(?={$spaces[1]})#", 197 "\\1<!-- w --><a href='http://\\2' rel='nofollow'>\\2</a><!-- w -->", 198 $text 199 ); 200 201 // emails 202 $text = preg_replace( 203 "#{$spaces[0]}([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z-]+)(?={$spaces[1]})#", 204 "\\1<!-- e --><a href='mailto:\\2' rel='nofollow'>\\2</a><!-- e -->", 205 $text 206 ); 207 208 return $text; 209 } 210 211 // the implementation here is completely different and incompatible 212 // with phpBB original implementation. 213 214 public function parseList($text) 215 { 216 $patterns = ['/\[(list(?:=.+?)?)\]/', '[/list]']; 217 $counts = [preg_match_all($patterns[0], $text), substr_count($text, $patterns[1])]; 218 $limit = min($counts); 219 220 $text = str_replace('[*]', "[*:{$this->uid}]", $text); 221 $text = str_replace('[/*]', '', $text); 222 223 $text = preg_replace($patterns[0], "[\\1:{$this->uid}]", $text, $limit); 224 $text = preg_replace('/'.preg_quote($patterns[1], '/').'/', "[/list:o:{$this->uid}]", $text, $limit); 225 226 return $text; 227 } 228 229 /** 230 * Handles: 231 * - Bold (b) 232 * - Italic (i) 233 * - Strike (strike, s) 234 * - Underline (u) 235 * - Spoiler (spoiler) 236 */ 237 public function parseMultilineSimple($text) 238 { 239 foreach (['b', 'i', 'strike', 's', 'u', 'spoiler'] as $tag) { 240 $text = preg_replace( 241 "#\[{$tag}](.*?)\[/{$tag}\]#s", 242 "[{$tag}:{$this->uid}]\\1[/{$tag}:{$this->uid}]", 243 $text 244 ); 245 } 246 247 return $text; 248 } 249 250 public function parseNotice($text) 251 { 252 return preg_replace( 253 "#\[(notice)\](.*?)\[/\\1\]#s", 254 "[\\1:{$this->uid}]\\2[/\\1:{$this->uid}]", 255 $text 256 ); 257 } 258 259 public function parseProfile($text) 260 { 261 preg_match_all('#\[profile(?:=(?<id>[0-9]+))?\](?<name>.+?)\[/profile\]#', $text, $tags); 262 263 $count = count($tags[0]); 264 265 if ($count > 0) { 266 $users = User 267 ::whereIn('user_id', $tags['id']) 268 ->orWhereIn('username', $tags['name']) 269 ->orWhereIn('username_clean', $tags['name']) 270 ->get(); 271 272 $usersBy = []; 273 274 foreach ($users as $user) { 275 foreach (['user_id', 'username', 'username_clean'] as $key) { 276 $usersBy[$key][mb_strtolower($user->$key)] = $user; 277 } 278 } 279 280 for ($i = 0; $i < $count; $i++) { 281 $tag = presence($tags[0][$i]); 282 $name = $tags['name'][$i]; 283 $nameNormalized = mb_strtolower($name); 284 $id = presence($tags['id'][$i]); 285 286 $user = $usersBy['user_id'][$id] ?? $usersBy['username'][$nameNormalized] ?? $usersBy['username_clean'][$nameNormalized] ?? null; 287 288 if ($user === null || !$user->hasProfileVisible()) { 289 $idText = ''; 290 } else { 291 $idText = "={$user->getKey()}"; 292 $name = $user->username; 293 } 294 295 $name = $this->extraEscapes($name); 296 297 $text = str_replace($tag, "[profile{$idText}:{$this->uid}]{$name}[/profile:{$this->uid}]", $text); 298 } 299 } 300 301 return $text; 302 } 303 304 // this is quite different and much more dumb than the one in phpbb 305 306 public function parseQuote($text) 307 { 308 $patterns = ['/\[(quote(?:=&quot;.+?&quot;)?)\]/', '[/quote]']; 309 $counts = [preg_match_all($patterns[0], $text), substr_count($text, $patterns[1])]; 310 $limit = min($counts); 311 312 $text = preg_replace($patterns[0], "[\\1:{$this->uid}]", $text, $limit); 313 $text = preg_replace('/'.preg_quote($patterns[1], '/').'/', "[/quote:{$this->uid}]", $text, $limit); 314 315 return $text; 316 } 317 318 public function parseSize($text) 319 { 320 return preg_replace( 321 '#\[(size=(?:\d+))\](.*?)\[(/size)\]#s', 322 "[\\1:{$this->uid}]\\2[\\3:{$this->uid}]", 323 $text 324 ); 325 } 326 327 public function parseSmiley($text) 328 { 329 $replacer = app('smilies')->replacer(); 330 331 if (count($replacer['patterns']) > 0) { 332 // Make sure the delimiter # is added in front and at the end of every element within $match 333 $text = trim(preg_replace($replacer['patterns'], $replacer['replacements'], $text)); 334 } 335 336 return $text; 337 } 338 339 public function parseUrl($text) 340 { 341 $urlPattern = '(?:https?|ftp)://.+?'; 342 343 $text = preg_replace_callback( 344 "#\[url\]({$urlPattern})\[/url\]#", 345 function ($m) { 346 $url = $this->extraEscapes($m[1]); 347 348 return "[url:{$this->uid}]{$url}[/url:{$this->uid}]"; 349 }, 350 $text 351 ); 352 $text = preg_replace_callback( 353 "#\[url=({$urlPattern})\](.+?)\[/url\]#", 354 function ($m) { 355 $url = $this->extraEscapes($m[1]); 356 357 return "[url={$url}:{$this->uid}]{$m[2]}[/url:{$this->uid}]"; 358 }, 359 $text 360 ); 361 362 return $text; 363 } 364 365 public function parseYoutube($text) 366 { 367 return preg_replace_callback( 368 '#\[youtube\](?:https?://(?:youtu\.be/|(?:m\.|www\.|)youtube\.com/(?:embed/|shorts/|watch\?v=))|)(.+?)\[/youtube\]#', 369 function ($m) { 370 $videoId = preg_replace('/\?.*/', '', $this->extraEscapes($m[1])); 371 372 return "[youtube:{$this->uid}]{$videoId}[/youtube:{$this->uid}]"; 373 }, 374 $text 375 ); 376 } 377 378 public function generate() 379 { 380 $text = htmlentities($this->text, ENT_QUOTES, 'UTF-8', true); 381 382 $text = $this->unifyNewline($text); 383 $text = $this->parseImagemap($text); 384 $text = $this->parseCode($text); 385 $text = $this->parseNotice($text); 386 $text = $this->parseBox($text); 387 $text = $this->parseQuote($text); 388 $text = $this->parseList($text); 389 390 $text = $this->parseBlockSimple($text); 391 $text = $this->parseProfile($text); 392 $text = $this->parseImage($text); 393 $text = $this->parseMultilineSimple($text); 394 $text = $this->parseInlineSimple($text); 395 $text = $this->parseAudio($text); 396 $text = $this->parseEmail($text); 397 $text = $this->parseUrl($text); 398 $text = $this->parseSize($text); 399 $text = $this->parseColour($text); 400 $text = $this->parseYoutube($text); 401 402 $text = $this->parseSmiley($text); 403 $text = $this->parseLinks($text); 404 405 return $text; 406 } 407 408 public function unifyNewline($text) 409 { 410 return str_replace(["\r\n", "\r"], ["\n", "\n"], $text); 411 } 412}