xgit simple git

nexus

nexus

clone git clone https://kb.hax.al/nexus

includes/functions.php

1 <?php
2 if (!defined('NEXUS')) exit('Forbidden');
3 
4 /* ── Output escaping ───────────────────────────────────────── */
5 function e(mixed $v): string {
6     return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
7 }
8 
9 /* ── URL building ──────────────────────────────────────────── */
10 function u(string $path = ''): string {
11     return BASE . '/' . ltrim($path, '/');
12 }
13 function asset(string $path): string {
14     return BASE . '/public/' . ltrim($path, '/');
15 }
16 
17 /* ── Redirects ─────────────────────────────────────────────── */
18 function go(string $path): never {
19     header('Location: ' . u($path));
20     exit;
21 }
22 
23 /* ── Settings ──────────────────────────────────────────────── */
24 function cfg(string $key, string $default = ''): string {
25     static $c = null;
26     if ($c === null) {
27         try {
28             $rows = DB::rows('SELECT key, value FROM settings');
29             $c = array_column($rows, 'value', 'key');
30         } catch (\Throwable $e) { $c = []; }
31     }
32     return $c[$key] ?? $default;
33 }
34 function cfg_set(string $key, string $value): void {
35     DB::upsert('settings', 'key', 'value', $key, $value);
36 }
37 
38 /* ── Active theme CSS ──────────────────────────────────────── */
39 function active_theme_css(): string {
40     try {
41         $theme = DB::row("SELECT css FROM themes WHERE is_active=1 LIMIT 1");
42         return $theme ? '<style id="theme-css">' . $theme['css'] . '</style>' : '';
43     } catch (\Throwable $e) { return ''; }
44 }
45 
46 /* ── Auth ──────────────────────────────────────────────────── */
47 function start_session(): void {
48     if (session_status() === PHP_SESSION_ACTIVE) return;
49     session_set_cookie_params(['lifetime' => 86400*30,'path'=>'/','httponly'=>true,'samesite'=>'Lax']);
50     session_start();
51 }
52 function current_user(): ?array {
53     start_session();
54     if (empty($_SESSION['uid'])) return null;
55     $u = DB::row('SELECT * FROM users WHERE id=?', [$_SESSION['uid']]);
56     if (!$u) { unset($_SESSION['uid']); return null; }
57     $now = DB::now();
58     DB::run("UPDATE users SET last_seen=$now WHERE id=?", [$u['id']]);
59     return $u;
60 }
61 function login_user(int $id): void {
62     start_session();
63     session_regenerate_id(true);
64     $_SESSION['uid'] = $id;
65 }
66 function logout_user(): void {
67     start_session();
68     session_destroy();
69 }
70 function must_login(): void {
71     global $USER;
72     if (!$USER) go('auth/login.php?next=' . urlencode($_SERVER['REQUEST_URI']));
73 }
74 function must_admin(): void {
75     global $USER;
76     must_login();
77     if (!is_admin()) render_403();
78 }
79 function must_admin_only(): void {
80     global $USER;
81     must_login();
82     if ($USER['role'] !== 'admin') render_403();
83 }
84 function is_admin(?array $u = null): bool {
85     global $USER;
86     $u = $u ?? $USER;
87     return $u && in_array($u['role'], ['admin','moderator']);
88 }
89 /* Check a specific permission (stored as JSON in users.permissions) */
90 function has_perm(string $perm, ?array $u = null): bool {
91     global $USER;
92     $u = $u ?? $USER;
93     if (!$u) return false;
94     if ($u['role'] === 'admin') return true;
95     $perms = json_decode($u['permissions'] ?? '{}', true) ?: [];
96     return !empty($perms[$perm]);
97 }
98 
99 /* ── CSRF ──────────────────────────────────────────────────── */
100 function csrf(): string {
101     start_session();
102     if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
103     return $_SESSION['csrf'];
104 }
105 function csrf_input(): string {
106     return '<input type="hidden" name="csrf" value="' . e(csrf()) . '">';
107 }
108 function csrf_ok(): bool {
109     $t = $_POST['csrf'] ?? '';
110     return $t !== '' && hash_equals(csrf(), $t);
111 }
112 
113 /* ── Slugs ─────────────────────────────────────────────────── */
114 function make_slug(string $text): string {
115     $s = mb_strtolower(trim($text), 'UTF-8');
116     $s = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $s);
117     $s = preg_replace('/[\s\-]+/', '-', $s);
118     return substr($s, 0, 80) ?: 'post';
119 }
120 function unique_slug(string $text, string $table, ?int $skip = null): string {
121     $base = make_slug($text); $slug = $base; $n = 1;
122     while (true) {
123         $sql = "SELECT id FROM $table WHERE slug=?"; $p = [$slug];
124         if ($skip) { $sql .= ' AND id!=?'; $p[] = $skip; }
125         if (!DB::row($sql, $p)) break;
126         $slug = $base . '-' . $n++;
127     }
128     return $slug;
129 }
130 
131 /* ── Time ──────────────────────────────────────────────────── */
132 function time_ago(string $dt): string {
133     $diff = time() - strtotime($dt);
134     if ($diff <    60) return 'just now';
135     if ($diff <  3600) return floor($diff/60)   . 'm ago';
136     if ($diff < 86400) return floor($diff/3600)  . 'h ago';
137     if ($diff < 604800) return floor($diff/86400) . 'd ago';
138     return date('M j, Y', strtotime($dt));
139 }
140 
141 /* ── Notifications ─────────────────────────────────────────── */
142 function unread_count(): int {
143     global $USER;
144     if (!$USER) return 0;
145     return (int) DB::val('SELECT COUNT(*) FROM notifications WHERE user_id=? AND `read`=0', [$USER['id']]);
146 }
147 function add_notification(int $uid, string $type, array $data): void {
148     global $USER;
149     if ($USER && $uid === (int)$USER['id']) return;
150     DB::insert('INSERT INTO notifications (user_id,type,payload) VALUES (?,?,?)',
151         [$uid, $type, json_encode($data)]);
152 }
153 
154 /* ── Karma ─────────────────────────────────────────────────── */
155 function add_karma(int $uid, int $pts = 1): void {
156     DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$pts, $uid]);
157 }
158 
159 /* ── Sidebar categories ────────────────────────────────────── */
160 function nav_categories(): array {
161     return DB::rows('SELECT * FROM categories WHERE parent_id IS NULL ORDER BY position, id');
162 }
163 
164 /* ── Forum-wide stats ──────────────────────────────────────── */
165 function forum_stats(): array {
166     return [
167         'topics'  => (int) DB::val('SELECT COUNT(*) FROM topics WHERE archived=0'),
168         'posts'   => (int) DB::val('SELECT COUNT(*) FROM posts WHERE deleted=0'),
169         'users'   => (int) DB::val('SELECT COUNT(*) FROM users'),
170         'online'  => (int) DB::val("SELECT COUNT(*) FROM users WHERE last_seen >= ".DB::sinceSeconds(900)),
171         'newest'  => DB::row('SELECT username FROM users ORDER BY joined_at DESC LIMIT 1'),
172     ];
173 }
174 
175 /* ── Error pages ───────────────────────────────────────────── */
176 function render_403(): never {
177     http_response_code(403);
178     echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>403</title>'
179         .'<link rel="stylesheet" href="'.asset('css/main.css').'"></head>'
180         .'<body class="auth-body"><div class="err-pg"><div class="err-code">403</div>'
181         .'<h1>Access Denied</h1><p>You do not have permission to view this page.</p>'
182         .'<a href="'.u('/').'\" class="btn-primary">← Home</a></div></body></html>';
183     exit;
184 }
185 function render_404(): never {
186     http_response_code(404);
187     echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>404</title>'
188         .'<link rel="stylesheet" href="'.asset('css/main.css').'"></head>'
189         .'<body class="auth-body"><div class="err-pg"><div class="err-code">404</div>'
190         .'<h1>Not Found</h1><p>The page you are looking for does not exist.</p>'
191         .'<a href="'.u('/').'\" class="btn-primary">← Home</a></div></body></html>';
192     exit;
193 }
194 
195 /* ── POST/GET helpers ──────────────────────────────────────── */
196 function post(string $k, string $d = ''): string { return trim($_POST[$k] ?? $d); }
197 function get(string $k, string $d = ''): string  { return trim($_GET[$k]  ?? $d); }
198 
199 /* ── JSON response ─────────────────────────────────────────── */
200 function json_out(mixed $data, int $code = 200): never {
201     http_response_code($code);
202     header('Content-Type: application/json');
203     echo json_encode($data);
204     exit;
205 }
206 
207 /* ── Security: Input sanitisation ────────────────────────────
208  * ALL user content MUST pass through sanitise() before DB insert.
209  * Strips HTML, PHP, script tags, and HTML entities.
210  */
211 function sanitise(string $input): string {
212     // ── 1. Kill PHP execution tags completely ────────────────────────
213     $s = preg_replace('/<\?(?:php|=)?.*?\?>/si', '', $input);
214 
215     // ── 2. Multi-round decode to catch all encoding tricks ───────────
216     //      &#106;avascript:, &lt;script&gt;, \u003cscript\u003e etc.
217     for ($i = 0; $i < 4; $i++) {
218         $prev = $s;
219         $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
220         // Resolve JS-style unicode escapes: \u003c → <
221         $s = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($m) {
222             return mb_chr(hexdec($m[1]), 'UTF-8') ?? $m[0];
223         }, $s);
224         if ($s === $prev) break;
225     }
226 
227     // ── 3. Strip execution-dangerous HTML completely (with content) ──
228     //      Everything inside these tags is removed, not just the tags.
229     $exec = 'script|style|iframe|frame|frameset|object|embed|applet|form';
230     $s = preg_replace('/<(' . $exec . ')\b[^>]*>.*?<\/\1>/si', '', $s);
231     $s = preg_replace('/<(' . $exec . ')\b[^>]*\/?>/si', '', $s);
232 
233     // Also strip PHP/server-side tags that survived step 1
234     $s = preg_replace('/<\?.*?\?>/s', '', $s);
235 
236     // ── 4. NOW escape all remaining < > & so they display as text ────
237     //      Safe HTML tags like <b>, <i>, <p>, custom tags from users:
238     //      they are NOT executed — they show as literal &lt;b&gt; etc.
239     //      This is the key change: ESCAPE instead of STRIP harmless tags.
240     $s = htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
241 
242     // ── 5. Remove dangerous URL schemes (defence in depth) ───────────
243     $s = preg_replace('/\b(javascript|vbscript|livescript|mocha)\s*:/i', '[blocked]:', $s);
244 
245     // ── 6. Remove ASCII control characters ───────────────────────────
246     //      Keep tab (\x09) and newlines. Remove everything else < \x20.
247     $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s);
248 
249     // ── 7. Normalise line endings ─────────────────────────────────────
250     $s = str_replace("\r\n", "\n", $s);
251     $s = str_replace("\r",   "\n", $s);
252 
253     return trim($s);
254 }
255 
256 /* ── Math captcha ─────────────────────────────────────────── */
257 function captcha_generate(): array {
258     start_session();
259     $a  = random_int(2, 9);
260     $b  = random_int(1, 9);
261     $op = ['+', '-'][random_int(0, 1)];
262     // Ensure subtraction result is always positive and non-zero
263     if ($op === '-') {
264         if ($a <= $b) $b = $a - 1;
265         if ($b < 1)   { $op = '+'; }
266     }
267     $ans = $op === '+' ? $a + $b : $a - $b;
268     $_SESSION['captcha_ans'] = $ans;
269     return ['q' => "$a $op $b = ?"];
270 }
271 function captcha_verify(string $input): bool {
272     start_session();
273     $ans = $_SESSION['captcha_ans'] ?? null;
274     unset($_SESSION['captcha_ans']);
275     return $ans !== null && intval(trim($input)) === (int)$ans;
276 }
277 
278 /* ── Friend helpers ───────────────────────────────────────── */
279 function friend_status(int $userId, int $otherId): string {
280     if ($userId === $otherId) return 'self';
281     try {
282         $r = DB::row(
283             'SELECT status, user_id FROM friends
284              WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)',
285             [$userId, $otherId, $otherId, $userId]
286         );
287         if (!$r) return 'none';
288         if ($r['status'] === 'accepted') return 'friends';
289         if ($r['status'] === 'pending')
290             return ((int)$r['user_id'] === $userId) ? 'pending_sent' : 'pending_received';
291     } catch (Throwable $e) {
292         return 'none'; // Table may not exist on old installs
293     }
294     return 'none';
295 }
296 
297 function friends_list(int $userId): array {
298     try {
299         return DB::rows("
300             SELECT u.* FROM users u
301             JOIN friends f ON (f.user_id=? AND f.friend_id=u.id)
302                            OR (f.friend_id=? AND f.user_id=u.id)
303             WHERE f.status='accepted'
304             ORDER BY u.username
305         ", [$userId, $userId]);
306     } catch (Throwable $e) {
307         return []; // Table may not exist on old installs
308     }
309 }
310 
311 function pending_requests(int $userId): array {
312     try {
313         return DB::rows("
314             SELECT u.*, f.id AS fid FROM users u
315             JOIN friends f ON f.user_id=u.id AND f.friend_id=? AND f.status='pending'
316             ORDER BY f.created_at DESC
317         ", [$userId]);
318     } catch (Throwable $e) {
319         return []; // Table may not exist on old installs
320     }
321 }
322 
323 /* ── Message unread count ─────────────────────────────────── */
324 function unread_messages(): int {
325     global $USER;
326     if (!$USER) return 0;
327     try {
328         return (int) DB::val(
329             'SELECT COUNT(*) FROM messages
330              WHERE receiver_id=? AND is_read=0 AND deleted_by_receiver=0',
331             [$USER['id']]
332         );
333     } catch (Throwable $e) {
334         return 0; // Table may not exist on old installs
335     }
336 }
337 
338 /* ── @mention processor ───────────────────────────────────── */
339 function process_mentions(string $content, int $postId, string $topicSlug, int $authorId, string $authorName): void {
340     preg_match_all('/@([a-zA-Z0-9_\-]{3,30})/', $content, $m);
341     $mentioned = array_unique($m[1] ?? []);
342     foreach ($mentioned as $uname) {
343         $t = DB::row('SELECT id FROM users WHERE username=?', [$uname]);
344         if ($t && (int)$t['id'] !== $authorId) {
345             add_notification((int)$t['id'], 'mention', [
346                 'from'      => $authorName,
347                 'topicSlug' => $topicSlug,
348                 'postId'    => $postId,
349             ]);
350         }
351     }
352 }
353 
354 /* ── Rate limiting ─────────────────────────────────────────── */
355 /**
356  * Check if a user has exceeded their rate limit.
357  * Returns true = allowed, false = rate-limited.
358  * $limit = max posts allowed, $window = seconds window, $type = event type
359  */
360 function rate_check(int $userId, string $type = 'post'): array {
361     $enabled = cfg('rate_limit_enabled','0');
362     if ($enabled !== '1') return ['ok'=>true,'wait'=>0];
363 
364     // Admins/moderators bypass rate limits
365     $user = DB::row('SELECT role FROM users WHERE id=?', [$userId]);
366     if ($user && in_array($user['role'],['admin','moderator'])) return ['ok'=>true,'wait'=>0];
367 
368     $limit  = (int) cfg('rate_limit_count','3');
369     $window = (int) cfg('rate_limit_window','60'); // seconds
370 
371     $since = DB::sinceSeconds($window);
372     $count = (int) DB::val(
373         "SELECT COUNT(*) FROM rate_events
374          WHERE user_id=? AND event_type=? AND created_at >= $since",
375         [$userId, $type]
376     );
377 
378     if ($count >= $limit) {
379         // Find oldest event in window to calculate wait time
380         $oldest = DB::row(
381             "SELECT created_at FROM rate_events
382              WHERE user_id=? AND event_type=? AND created_at >= $since
383              ORDER BY created_at ASC LIMIT 1",
384             [$userId, $type]
385         );
386         $wait = 0;
387         if ($oldest) {
388             $elapsed = time() - strtotime($oldest['created_at']);
389             $wait    = max(0, $window - $elapsed);
390         }
391         return ['ok'=>false,'wait'=>$wait,'limit'=>$limit,'window'=>$window];
392     }
393     return ['ok'=>true,'wait'=>0];
394 }
395 
396 function rate_record(int $userId, string $type = 'post'): void {
397     $enabled = cfg('rate_limit_enabled','0');
398     if ($enabled !== '1') return;
399     DB::insert('INSERT INTO rate_events (user_id,event_type) VALUES (?,?)', [$userId, $type]);
400     // Prune old events (older than 24h) periodically
401     if (random_int(1,50) === 1) {
402         $cutoff = DB::sinceSeconds(86400);
403         DB::run("DELETE FROM rate_events WHERE created_at < $cutoff");
404     }
405 }
406 
407 /* ── Post/Reply captcha ────────────────────────────────────── */
408 function post_captcha_enabled(): bool {
409     return cfg('post_captcha_enabled','0') === '1';
410 }
411 function post_captcha_verify(string $input): bool {
412     start_session();
413     $key = 'post_captcha_ans';
414     $ans = $_SESSION[$key] ?? null;
415     unset($_SESSION[$key]);
416     return $ans !== null && intval(trim($input)) === (int)$ans;
417 }
418 function post_captcha_generate(): array {
419     start_session();
420     $a  = random_int(2, 9);
421     $b  = random_int(1, 9);
422     $op = ['+', '-'][random_int(0, 1)];
423     if ($op === '-') {
424         if ($a <= $b) $b = $a - 1;
425         if ($b < 1)   { $op = '+'; }
426     }
427     $ans = $op === '+' ? $a + $b : $a - $b;
428     $_SESSION['post_captcha_ans'] = $ans;
429     return ['q' => "$a $op $b = ?"];
430 }
431 
432 /* ── Media embed processor ─────────────────────────────────────
433  * Converts bare URLs in post content into embeds.
434  * Called server-side so embeds render even without JS.
435  * Each URL on its own line (possibly wrapped in <p>) gets replaced.
436  */
437 function process_embeds(string $html): string {
438     // Process each <p>...</p> block that contains a bare URL.
439     // We do this line by line to avoid catastrophic regex failures.
440     return preg_replace_callback(
441         '/<p>\s*(https?:\/\/[^\s<>"\']+)\s*<\/p>/i',
442         function ($m) {
443             $url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
444             $em  = try_embed($url);
445             return $em !== null ? $em : $m[0];
446         },
447         $html
448     );
449 }
450 
451 function try_embed(string $url): ?string {
452     // YouTube (full URL)
453     if (preg_match('~youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) {
454         return yt_embed($m[1]);
455     }
456     // YouTube short URL
457     if (preg_match('~youtu\.be/([a-zA-Z0-9_\-]{11})~', $url, $m)) {
458         return yt_embed($m[1]);
459     }
460     // YouTube Shorts
461     if (preg_match('~youtube\.com/shorts/([a-zA-Z0-9_\-]{11})~', $url, $m)) {
462         return yt_embed($m[1], true);
463     }
464     // YouTube Music
465     if (preg_match('~music\.youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) {
466         return yt_embed($m[1]);
467     }
468     // Vimeo
469     if (preg_match('~vimeo\.com/(\d{5,12})~', $url, $m)) {
470         return embed_iframe('https://player.vimeo.com/video/' . $m[1] . '?dnt=1', 'Vimeo');
471     }
472     // Twitch VOD
473     if (preg_match('~twitch\.tv/videos/(\d+)~', $url, $m)) {
474         $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
475         return embed_iframe('https://player.twitch.tv/?video=v' . $m[1] . '&parent=' . urlencode($host) . '&autoplay=false', 'Twitch VOD');
476     }
477     // Twitch channel
478     if (preg_match('~twitch\.tv/([a-zA-Z0-9_]{4,25})(?:\?|$|/)~', $url, $m)) {
479         $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
480         return embed_iframe('https://player.twitch.tv/?channel=' . $m[1] . '&parent=' . urlencode($host) . '&autoplay=false', 'Twitch');
481     }
482     // Dailymotion
483     if (preg_match('~dailymotion\.com/video/([a-zA-Z0-9]+)~', $url, $m)) {
484         return embed_iframe('https://www.dailymotion.com/embed/video/' . $m[1], 'Dailymotion');
485     }
486     // Streamable
487     if (preg_match('~streamable\.com/([a-zA-Z0-9]+)~', $url, $m)) {
488         return embed_iframe('https://streamable.com/e/' . $m[1], 'Streamable');
489     }
490     // Rumble
491     if (preg_match('~rumble\.com/(?:embed/)?([a-zA-Z0-9\-]+)(?:\.html)?~', $url, $m)) {
492         return embed_iframe('https://rumble.com/embed/' . $m[1] . '/', 'Rumble');
493     }
494     // Spotify
495     if (preg_match('~open\.spotify\.com/(track|album|playlist|episode|artist)/([a-zA-Z0-9]+)~', $url, $m)) {
496         $h = ($m[1] === 'track' || $m[1] === 'episode') ? '152' : '352';
497         return '<div class="embed-spotify"><iframe src="https://open.spotify.com/embed/' . $m[1] . '/' . $m[2]
498             . '" width="100%" height="' . $h . '" frameborder="0" allow="autoplay;clipboard-write;encrypted-media;fullscreen" loading="lazy"></iframe></div>';
499     }
500     // SoundCloud
501     if (preg_match('~soundcloud\.com/[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+~', $url)) {
502         return '<div class="embed-sc"><iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay"'
503             . ' src="https://w.soundcloud.com/player/?url=' . urlencode($url) . '&color=%233b82f6&auto_play=false"></iframe></div>';
504     }
505     // Loom
506     if (preg_match('~loom\.com/share/([a-zA-Z0-9]+)~', $url, $m)) {
507         return embed_iframe('https://www.loom.com/embed/' . $m[1], 'Loom');
508     }
509     // CodePen
510     if (preg_match('~codepen\.io/([a-zA-Z0-9\-_]+)/pen/([a-zA-Z0-9]+)~', $url, $m)) {
511         return embed_iframe_tall('https://codepen.io/' . $m[1] . '/embed/' . $m[2] . '?default-tab=result', 'CodePen', 420);
512     }
513     // JSFiddle
514     if (preg_match('~jsfiddle\.net/([a-zA-Z0-9/]+)~', $url, $m)) {
515         return embed_iframe_tall('https://jsfiddle.net/' . rtrim($m[1], '/') . '/embedded/result', 'JSFiddle', 380);
516     }
517     // Twitter / X
518     if (preg_match('~(?:twitter|x)\.com/[a-zA-Z0-9_]+/status/(\d+)~', $url, $m)) {
519         $safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
520         return '<div class="embed-tweet" data-tweet-id="' . $m[1] . '">'
521             . '<a href="' . $safe . '" target="_blank" rel="noopener" class="tweet-fallback">🐦 View on Twitter/X →</a></div>';
522     }
523     // TED Talks
524     if (preg_match('~ted\.com/talks/([a-zA-Z0-9_]+)~', $url, $m)) {
525         return embed_iframe('https://embed.ted.com/talks/' . $m[1], 'TED Talk');
526     }
527     // Bandcamp track
528     if (preg_match('~([a-zA-Z0-9\-]+)\.bandcamp\.com/track/([a-zA-Z0-9\-]+)~', $url, $m)) {
529         return '<div class="embed-spotify"><iframe style="border:0;width:100%;height:120px"'
530             . ' src="https://bandcamp.com/EmbeddedPlayer/track=' . urlencode($m[2]) . '/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/" seamless></iframe></div>';
531     }
532     // No match
533     return null;
534 }
535 
536 
537 function yt_embed(string $id, bool $short = false): string {
538     $pad = $short ? 'padding-bottom:177.78%;max-width:360px' : 'padding-bottom:56.25%';
539     return '<div class="embed-wrap" style="'.$pad.'"><iframe class="embed-yt" src="https://www.youtube-nocookie.com/embed/'.htmlspecialchars($id).'?rel=0&amp;modestbranding=1" allowfullscreen loading="lazy" title="YouTube video"></iframe></div>';
540 }
541 function embed_iframe(string $src, string $label = ''): string {
542     return '<div class="embed-wrap"><iframe class="embed-yt" src="'.htmlspecialchars($src).'" allowfullscreen loading="lazy" title="'.htmlspecialchars($label).'"></iframe></div>';
543 }
544 function embed_iframe_tall(string $src, string $label = '', int $height = 400): string {
545     return '<div class="embed-wrap" style="padding-bottom:0;height:'.$height.'px"><iframe class="embed-yt" src="'.htmlspecialchars($src).'" allowfullscreen loading="lazy" title="'.htmlspecialchars($label).'"></iframe></div>';
546 }
547 
548 /* ── Karma tier system ─────────────────────────────────────── */
549 /* ── Category permission helpers ──────────────────────── */
550 // Roles in ascending order: guest < member < moderator < admin
551 function role_level(string $role): int {
552     return match($role) {
553         'admin'     => 30,
554         'moderator' => 20,
555         'member'    => 10,
556         default     => 0,   // guest / not logged in
557     };
558 }
559 
560 function user_role_level(?array $u = null): int {
561     global $USER;
562     $u = $u ?? $USER;
563     if (!$u) return 0;
564     return role_level($u['role'] ?? 'member');
565 }
566 
567 function can_read_category(array $cat, ?array $u = null): bool {
568     $required = role_level($cat['read_role'] ?? 'guest');
569     return user_role_level($u) >= $required;
570 }
571 
572 function can_post_topic(array $cat, ?array $u = null): bool {
573     $required = role_level($cat['post_role'] ?? 'member');
574     return user_role_level($u) >= $required;
575 }
576 
577 function can_reply_topic(array $cat, ?array $u = null): bool {
578     $required = role_level($cat['reply_role'] ?? 'member');
579     return user_role_level($u) >= $required;
580 }
581 
582 function karma_tier(int $karma): array {
583     $tiers = [
584         ['name'=>'Newcomer',     'min'=>0,    'max'=>9,    'icon'=>'🌱', 'color'=>'#94a3b8', 'next'=>10],
585         ['name'=>'Member',       'min'=>10,   'max'=>49,   'icon'=>'💬', 'color'=>'#64748b', 'next'=>50],
586         ['name'=>'Regular',      'min'=>50,   'max'=>99,   'icon'=>'⭐', 'color'=>'#f59e0b', 'next'=>100],
587         ['name'=>'Contributor',  'min'=>100,  'max'=>249,  'icon'=>'🌟', 'color'=>'#f97316', 'next'=>250],
588         ['name'=>'Veteran',      'min'=>250,  'max'=>499,  'icon'=>'🔥', 'color'=>'#ef4444', 'next'=>500],
589         ['name'=>'Expert',       'min'=>500,  'max'=>999,  'icon'=>'💎', 'color'=>'#8b5cf6', 'next'=>1000],
590         ['name'=>'Elite',        'min'=>1000, 'max'=>2499, 'icon'=>'👑', 'color'=>'#7c3aed', 'next'=>2500],
591         ['name'=>'Legend',       'min'=>2500, 'max'=>PHP_INT_MAX, 'icon'=>'🏆', 'color'=>'#6d28d9', 'next'=>null],
592     ];
593     $current = $tiers[0];
594     foreach ($tiers as $tier) {
595         if ($karma >= $tier['min']) $current = $tier;
596         else break;
597     }
598     // Progress to next tier
599     $progress = 0;
600     if ($current['next'] !== null) {
601         $range    = $current['next'] - $current['min'];
602         $earned   = $karma - $current['min'];
603         $progress = $range > 0 ? min(100, (int)round($earned / $range * 100)) : 100;
604     } else {
605         $progress = 100;
606     }
607     return array_merge($current, ['karma' => $karma, 'progress' => $progress]);
608 }