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 '[' => '[',
14 ']' => ']',
15 '.' => '.',
16 ':' => ':',
17 "\n" => ' ',
18 '@' => '@',
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(?:<|[.:([])*)', "((?:\[.+?\]|>|[.:)\]])*(?:$|\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(?:=".+?")?)\]/', '[/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}