1<?php
2if (!defined('NEXUS')) exit('Forbidden');
3
4
5function e(mixed $v): string {
6 return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
7}
8
9
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
18function go(string $path): never {
19 header('Location: ' . u($path));
20 exit;
21}
22
23
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
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
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
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
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
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
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
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
155function add_karma(int $uid, int $pts = 1): void {
156 DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$pts, $uid]);
157}
158
159
160function nav_categories(): array {
161 return DB::rows('SELECT * FROM categories WHERE parent_id IS NULL ORDER BY position, id');
162}
163
164
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
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
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
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
213 $s = preg_replace('/<\?(?:php|=)?.*?\?>/si', '', $input);
214
215
216
217 for ($i = 0; $i < 4; $i++) {
218 $prev = $s;
219 $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
220
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
228
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
234 $s = preg_replace('/<\?.*?\?>/s', '', $s);
235
236
237
238
239
240 $s = htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
241
242
243 $s = preg_replace('/\b(javascript|vbscript|livescript|mocha)\s*:/i', '[blocked]:', $s);
244
245
246
247 $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s);
248
249
250 $s = str_replace("\r\n", "\n", $s);
251 $s = str_replace("\r", "\n", $s);
252
253 return trim($s);
254}
255
256
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
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
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';
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 [];
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 [];
320 }
321}
322
323
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;
335 }
336}
337
338
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
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
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');
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
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
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
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
439
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
453 if (preg_match('~youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) {
454 return yt_embed($m[1]);
455 }
456
457 if (preg_match('~youtu\.be/([a-zA-Z0-9_\-]{11})~', $url, $m)) {
458 return yt_embed($m[1]);
459 }
460
461 if (preg_match('~youtube\.com/shorts/([a-zA-Z0-9_\-]{11})~', $url, $m)) {
462 return yt_embed($m[1], true);
463 }
464
465 if (preg_match('~music\.youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) {
466 return yt_embed($m[1]);
467 }
468
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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&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
549
550
551function role_level(string $role): int {
552 return match($role) {
553 'admin' => 30,
554 'moderator' => 20,
555 'member' => 10,
556 default => 0,
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
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}