xgit simple git

nexus

nexus

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

includes/functions.php


1<?php
2if (!defined('NEXUS')) exit('Forbidden');
3 
4/* ── Output escaping ───────────────────────────────────────── */
5function e(mixed $v): string {
6    return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
7}
8 
9/* ── URL building ──────────────────────────────────────────── */
10function u(string $path = ''): string {
11    return BASE . '/' . ltrim($path, '/');
12}
13function asset(string $path): string {
14    return BASE . '/public/' . ltrim($path, '/');
15}
16 
17/* ── Redirects ─────────────────────────────────────────────── */
18function go(string $path): never {
19    header('Location: ' . u($path));
20    exit;
21}
22 
23/* ── Settings ──────────────────────────────────────────────── */
24function 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}
34function cfg_set(string $key, string $value): void {
35    DB::upsert('settings', 'key', 'value', $key, $value);
36}
37 
38/* ── Active theme CSS ──────────────────────────────────────── */
39function 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 ──────────────────────────────────────────────────── */
47function 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}
52function 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}
61function login_user(int $id): void {
62    start_session();
63    session_regenerate_id(true);
64    $_SESSION['uid'] = $id;
65}
66function logout_user(): void {
67    start_session();
68    session_destroy();
69}
70function must_login(): void {
71    global $USER;
72    if (!$USER) go('auth/login.php?next=' . urlencode($_SERVER['REQUEST_URI']));
73}
74function must_admin(): void {
75    global $USER;
76    must_login();
77    if (!is_admin()) render_403();
78}
79function must_admin_only(): void {
80    global $USER;
81    must_login();
82    if ($USER['role'] !== 'admin') render_403();
83}
84function 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) */
90function 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 ──────────────────────────────────────────────────── */
100function csrf(): string {
101    start_session();
102    if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
103    return $_SESSION['csrf'];
104}
105function csrf_input(): string {
106    return '<input type="hidden" name="csrf" value="' . e(csrf()) . '">';
107}
108function csrf_ok(): bool {
109    $t = $_POST['csrf'] ?? '';
110    return $t !== '' && hash_equals(csrf(), $t);
111}
112 
113/* ── Slugs ─────────────────────────────────────────────────── */
114function 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}
120function 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 ──────────────────────────────────────────────────── */
132function 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 ─────────────────────────────────────────── */
142function 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}
147function 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 ─────────────────────────────────────────────────── */
155function 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 ────────────────────────────────────── */
160function 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 ──────────────────────────────────────── */
165function 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 ───────────────────────────────────────────── */
176function 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}
185function 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 ──────────────────────────────────────── */
196function post(string $k, string $d = ''): string { return trim($_POST[$k] ?? $d); }
197function get(string $k, string $d = ''): string  { return trim($_GET[$k]  ?? $d); }
198 
199/* ── JSON response ─────────────────────────────────────────── */
200function 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 */
211function 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 ─────────────────────────────────────────── */
257function 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}
271function 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 ───────────────────────────────────────── */
279function 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 
297function 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 
311function 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 ─────────────────────────────────── */
324function 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 ───────────────────────────────────── */
339function 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 */
360function 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 
396function 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 ────────────────────────────────────── */
408function post_captcha_enabled(): bool {
409    return cfg('post_captcha_enabled','0') === '1';
410}
411function 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}
418function 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 */
437function 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 
451function 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 
537function 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}
541function 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}
544function 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
551function 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 
560function 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 
567function 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 
572function 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 
577function 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 
582function 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}