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 // javascript:, <script>, \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 <b> 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&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 }