bbcode.php

main 210 lines · 8.1 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2
3function znote_bbcode_url(string $url): string {
4 $plain = trim(html_entity_decode($url, ENT_QUOTES, 'UTF-8'));
5
6 if ($plain === '' || preg_match('/[\x00-\x1f\s<>"\']/', $plain)) {
7 return '';
8 }
9 if (!preg_match('#^(https?://[^/]+|/(?!/))#i', $plain)) {
10 return '';
11 }
12
13 return htmlspecialchars($plain, ENT_QUOTES, 'UTF-8');
14}
15
16function znote_bbcode_color(string $color): string {
17 $plain = trim(html_entity_decode($color, ENT_QUOTES, 'UTF-8'));
18
19 if (preg_match('/^#[0-9a-f]{3,8}$/i', $plain)) return $plain;
20 if (preg_match('/^[a-z]{3,20}$/i', $plain)) return strtolower($plain);
21 if (preg_match('/^rgba?\(\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*(,\s*(0|1|0?\.[0-9]+)\s*)?\)$/i', $plain)) return $plain;
22
23 return '';
24}
25
26/**
27 * For text stored WITHOUT HTML escaping - news and changelog, which the admin
28 * panel writes through esc() (SQL escaping only). Escaping here means an admin
29 * typing <script> gets text, not script, while BBCode still renders.
30 *
31 * Forum posts are already escaped by sanitize() on save, so they use
32 * znote_bbcode() directly - running this on them would double-escape.
33 */
34function znote_bbcode_raw(?string $text): string {
35 return znote_bbcode(htmlspecialchars((string)$text, ENT_QUOTES, 'UTF-8'));
36}
37
38function znote_bbcode_count_images(?string $text): int {
39 return preg_match_all('/\[img(?:=[^\]]*)?\]/i', (string)$text);
40}
41
42/** [code]...[/code] is pulled out first (as a \x00CODE<n>\x00 placeholder) so nothing inside it is touched by any tag below - restored at the very end by znote_bbcode_restore_code_blocks(). */
43function znote_bbcode_extract_code_blocks(string $text, array &$codes): string {
44 return preg_replace_callback('/\[code\](.*?)\[\/code\]/is', static function ($m) use (&$codes) {
45 $codes[] = $m[1];
46 return "\x00CODE" . (count($codes) - 1) . "\x00";
47 }, $text);
48}
49
50function znote_bbcode_restore_code_blocks(string $text, array $codes): string {
51 return preg_replace_callback("/\x00CODE([0-9]+)\x00/", static function ($m) use ($codes) {
52 return '<pre class="zbb-code"><code>' . ($codes[(int)$m[1]] ?? '') . '</code></pre>';
53 }, $text);
54}
55
56/** [font]/[table] et al carry no useful markup here - dropped rather than rendered. */
57function znote_bbcode_strip_unsupported_tags(string $text): string {
58 $text = preg_replace('/\[font(?:=[^\]]*)?\]/i', '', $text);
59 $text = preg_replace('/\[\/font\]/i', '', $text);
60 $text = preg_replace('/\[\/?(?:table|tr|td|th)(?:=[^\]]*)?\]/i', '', $text);
61 return $text;
62}
63
64function znote_bbcode_apply_inline_style_tags(string $text): string {
65 foreach (array('b' => 'strong', 'i' => 'em', 'u' => 'u', 's' => 'del') as $tag => $html) {
66 for ($i = 0; $i < 4; $i++) {
67 $out = preg_replace('/\[' . $tag . '\](.*?)\[\/' . $tag . '\]/is', '<' . $html . '>$1</' . $html . '>', $text);
68 if ($out === null || $out === $text) break;
69 $text = $out;
70 }
71 }
72 return $text;
73}
74
75function znote_bbcode_apply_alignment(string $text): string {
76 foreach (array('left', 'center', 'right', 'justify') as $align) {
77 $text = preg_replace(
78 '/\[' . $align . '\](.*?)\[\/' . $align . '\]/is',
79 '<div class="zbb-align" style="text-align:' . $align . '">$1</div>',
80 $text
81 );
82 }
83 return $text;
84}
85
86function znote_bbcode_apply_color(string $text): string {
87 return preg_replace_callback('/\[color=([^\]]{1,30})\](.*?)\[\/color\]/is', static function ($m) {
88 $color = znote_bbcode_color($m[1]);
89 return ($color === '') ? $m[2] : '<span style="color:' . $color . '">' . $m[2] . '</span>';
90 }, $text);
91}
92
93function znote_bbcode_apply_size(string $text): string {
94 $sizes = array(1 => '0.7em', 2 => '0.85em', 3 => '1em', 4 => '1.2em', 5 => '1.5em', 6 => '2em', 7 => '2.5em');
95 return preg_replace_callback('/\[size=([0-9]{1,2})\](.*?)\[\/size\]/is', static function ($m) use ($sizes) {
96 $size = $sizes[(int)$m[1]] ?? null;
97 return ($size === null) ? $m[2] : '<span style="font-size:' . $size . '">' . $m[2] . '</span>';
98 }, $text);
99}
100
101function znote_bbcode_apply_images(string $text): string {
102 return preg_replace_callback('/\[img(?:=([0-9]{1,4})x([0-9]{1,4}))?\]([^\[]+?)\[\/img\]/is', static function ($m) {
103 $url = znote_bbcode_url($m[3]);
104 if ($url === '') return '';
105
106 $w = (int)($m[1] ?? 0);
107 $h = (int)($m[2] ?? 0);
108 if (($w <= 0 || $h <= 0 || $w > 4000 || $h > 4000) && preg_match('~/letters/letter_martel_[a-z]\.gif(?:[?#].*)?$~i', $url)) {
109 $w = 48;
110 $h = 48;
111 }
112 $dim = ($w > 0 && $w <= 4000 && $h > 0 && $h <= 4000) ? ' width="' . $w . '" height="' . $h . '"' : '';
113
114 return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">'
115 . '<img src="' . $url . '" alt=""' . $dim . ' class="zbb-img" style="max-width:100%;height:auto"></a>';
116 }, $text);
117}
118
119function znote_bbcode_apply_links(string $text): string {
120 $text = preg_replace_callback('/\[(?:url|link)=([^\]]+?)\](.*?)\[\/(?:url|link)\]/is', static function ($m) {
121 $url = znote_bbcode_url($m[1]);
122 return ($url === '') ? $m[2] : '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $m[2] . '</a>';
123 }, $text);
124
125 $text = preg_replace_callback('/\[(?:url|link)\]([^\[]+?)\[\/(?:url|link)\]/is', static function ($m) {
126 $url = znote_bbcode_url($m[1]);
127 return ($url === '') ? $m[1] : '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
128 }, $text);
129
130 return $text;
131}
132
133function znote_bbcode_apply_youtube(string $text): string {
134 return preg_replace_callback('/\[youtube\]([^\[]+?)\[\/youtube\]/is', static function ($m) {
135 $id = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'));
136 if (preg_match('#(?:youtu\.be/|v=|embed/)([A-Za-z0-9_-]{6,20})#', $id, $found)) {
137 $id = $found[1];
138 }
139 if (!preg_match('/^[A-Za-z0-9_-]{6,20}$/', $id)) {
140 return '';
141 }
142 return '<div class="zbb-video"><iframe src="https://www.youtube.com/embed/' . $id
143 . '" frameborder="0" allowfullscreen></iframe></div>';
144 }, $text);
145}
146
147function znote_bbcode_apply_quote(string $text): string {
148 return preg_replace_callback('/\[quote(?:=([^\]]{1,40}))?\](.*?)\[\/quote\]/is', static function ($m) {
149 $who = trim($m[1] ?? '');
150 $head = ($who !== '') ? '<cite>' . $who . ' wrote:</cite>' : '';
151 return '<blockquote class="zbb-quote">' . $head . $m[2] . '</blockquote>';
152 }, $text);
153}
154
155/** [*]/[li] items first, then their enclosing [ul]/[list]/[ol] - each loops since tags can nest. */
156function znote_bbcode_apply_lists(string $text): string {
157 $text = preg_replace('/\[\*\](.*?)\[\/\*\]/is', '<li>$1</li>', $text);
158 $text = preg_replace('/\[\*\]\s*([^\[\r\n]*)/i', '<li>$1</li>', $text);
159
160 for ($i = 0; $i < 8; $i++) {
161 $out = preg_replace('/\[li\]((?:(?!\[li\]|\[\/li\]).)*)\[\/li\]/is', '<li>$1</li>', $text);
162 if ($out === null || $out === $text) break;
163 $text = $out;
164 }
165 for ($i = 0; $i < 8; $i++) {
166 $out = preg_replace('/\[(?:ul|list)(?:=[^\]]*)?\]((?:(?!\[(?:ul|list)(?:=[^\]]*)?\]|\[\/(?:ul|list)\]).)*)\[\/(?:ul|list)\]/is', '<ul class="zbb-list">$1</ul>', $text);
167 if ($out === null || $out === $text) break;
168 $text = $out;
169 }
170 for ($i = 0; $i < 8; $i++) {
171 $out = preg_replace('/\[ol\]((?:(?!\[ol\]|\[\/ol\]).)*)\[\/ol\]/is', '<ol class="zbb-list">$1</ol>', $text);
172 if ($out === null || $out === $text) break;
173 $text = $out;
174 }
175
176 return $text;
177}
178
179/** nl2br(), then undo it right next to a block element that already carries its own margin. */
180function znote_bbcode_apply_linebreaks(string $text): string {
181 $text = nl2br($text, false);
182 $text = preg_replace('#<br>\s*(</?(?:ul|ol|li|blockquote|div|cite)[^>]*>)#i', '$1', $text);
183 $text = preg_replace('#(</?(?:ul|ol|li|blockquote|div|cite)[^>]*>)\s*<br>#i', '$1', $text);
184 return $text;
185}
186
187function znote_bbcode(?string $text): string {
188 $text = (string)$text;
189 if ($text === '') {
190 return '';
191 }
192
193 $codes = array();
194 $text = znote_bbcode_extract_code_blocks($text, $codes);
195 $text = znote_bbcode_strip_unsupported_tags($text);
196 $text = znote_bbcode_apply_inline_style_tags($text);
197 $text = znote_bbcode_apply_alignment($text);
198 $text = znote_bbcode_apply_color($text);
199 $text = znote_bbcode_apply_size($text);
200 $text = znote_bbcode_apply_images($text);
201 $text = znote_bbcode_apply_links($text);
202 $text = znote_bbcode_apply_youtube($text);
203 $text = znote_bbcode_apply_quote($text);
204 $text = znote_bbcode_apply_lists($text);
205 $text = znote_bbcode_apply_linebreaks($text);
206 $text = znote_bbcode_restore_code_blocks($text, $codes);
207
208 return $text;
209}
210
Top