1 <?php 2 /** 3 * Nexus Forum — Addon System 4 * 5 * Two hook patterns: 6 * 7 * AddonManager::fire($hook, $data) 8 * FILTER pattern — each callback receives the return value of the previous. 9 * Use for transforming a single value (e.g. render_post_content: HTML string in/out). 10 * 11 * AddonManager::collect($hook, $context) 12 * COLLECTOR pattern — every callback receives the same $context array. 13 * Non-empty string returns are accumulated and joined. 14 * Use for output hooks where multiple addons each contribute HTML 15 * (e.g. render_post_footer, render_topic_header, before_page_head). 16 */ 17 if (!defined('NEXUS')) exit('Forbidden'); 18 19 class AddonManager { 20 private static array $hooks = []; 21 private static array $loaded = []; 22 private static ?array $active = null; 23 24 /* ── Boot all active addons ─────────────────────────────── */ 25 public static function boot(): void { 26 foreach (self::activeList() as $slug) { 27 $main = ROOT . '/addons/' . $slug . '/main.php'; 28 if (file_exists($main) && !isset(self::$loaded[$slug])) { 29 try { 30 require_once $main; 31 self::$loaded[$slug] = true; 32 } catch (Throwable $e) { 33 error_log("Addon [$slug] boot error: " . $e->getMessage()); 34 } 35 } 36 } 37 } 38 39 /* ── Register a hook callback ────────────────────────────── */ 40 public static function on(string $hook, callable $cb, int $priority = 10): void { 41 self::$hooks[$hook][$priority][] = $cb; 42 } 43 44 /* ── FILTER: chain callbacks, each receives previous return ─ */ 45 public static function fire(string $hook, mixed $data = null): mixed { 46 if (empty(self::$hooks[$hook])) return $data; 47 $buckets = self::$hooks[$hook]; 48 ksort($buckets); 49 foreach ($buckets as $callbacks) { 50 foreach ($callbacks as $cb) { 51 try { 52 $result = $cb($data); 53 if ($result !== null) $data = $result; 54 } catch (Throwable $e) { 55 error_log("Addon hook [$hook] filter error: " . $e->getMessage()); 56 } 57 } 58 } 59 return $data; 60 } 61 62 /* ── COLLECTOR: all callbacks receive same $context ──────── */ 63 /* Each callback should return '' or an HTML string to append. */ 64 public static function collect(string $hook, mixed $context = null): string { 65 if (empty(self::$hooks[$hook])) return ''; 66 $buckets = self::$hooks[$hook]; 67 ksort($buckets); 68 $output = ''; 69 foreach ($buckets as $callbacks) { 70 foreach ($callbacks as $cb) { 71 try { 72 $result = $cb($context); 73 if (is_string($result) && $result !== '') { 74 $output .= $result; 75 } 76 } catch (Throwable $e) { 77 error_log("Addon hook [$hook] collect error: " . $e->getMessage()); 78 } 79 } 80 } 81 return $output; 82 } 83 84 public static function hasHook(string $hook): bool { 85 return !empty(self::$hooks[$hook]); 86 } 87 88 /* ── Active addon list ──────────────────────────────────── */ 89 public static function activeList(): array { 90 if (self::$active !== null) return self::$active; 91 try { 92 $val = DB::val("SELECT value FROM settings WHERE `key`='active_addons'"); 93 self::$active = $val ? (json_decode($val, true) ?: []) : []; 94 } catch (Throwable $e) { 95 self::$active = []; 96 } 97 return self::$active; 98 } 99 100 /* ── Scan all installed addons ──────────────────────────── */ 101 public static function all(): array { 102 $dir = ROOT . '/addons'; 103 $active = self::activeList(); 104 $addons = []; 105 if (!is_dir($dir)) return []; 106 foreach (scandir($dir) as $slug) { 107 if ($slug[0] === '.') continue; 108 $manifest = $dir . '/' . $slug . '/nexus-addon.json'; 109 if (!file_exists($manifest)) continue; 110 $info = json_decode(file_get_contents($manifest), true); 111 if (!$info || empty($info['name'])) continue; 112 $addons[$slug] = array_merge([ 113 'slug' => $slug, 'name' => $slug, 'description' => '', 114 'version' => '1.0.0', 'author' => 'Unknown', 'url' => '', 115 'hooks' => [], 'requires' => [], 116 ], $info, [ 117 'slug' => $slug, 118 'active' => in_array($slug, $active), 119 ]); 120 } 121 return $addons; 122 } 123 124 /* ── Activate / deactivate ──────────────────────────────── */ 125 public static function activate(string $slug): bool { 126 $active = self::activeList(); 127 if (in_array($slug, $active)) return true; 128 $install = ROOT . '/addons/' . $slug . '/install.php'; 129 if (file_exists($install)) { 130 try { require $install; } catch (Throwable $e) { 131 error_log("Addon [$slug] install error: " . $e->getMessage()); 132 return false; 133 } 134 } 135 $active[] = $slug; 136 self::saveActive($active); 137 self::$active = $active; 138 return true; 139 } 140 141 public static function deactivate(string $slug): bool { 142 $active = self::activeList(); 143 if (!in_array($slug, $active)) return true; 144 $uninstall = ROOT . '/addons/' . $slug . '/uninstall.php'; 145 if (file_exists($uninstall)) { 146 try { require $uninstall; } catch (Throwable $e) { 147 error_log("Addon [$slug] uninstall error: " . $e->getMessage()); 148 } 149 } 150 $active = array_values(array_filter($active, fn($s) => $s !== $slug)); 151 self::saveActive($active); 152 self::$active = $active; 153 return true; 154 } 155 156 private static function saveActive(array $active): void { 157 DB::upsert('settings', 'key', 'value', 'active_addons', json_encode(array_values($active))); 158 } 159 } 160 161 /* ── Global convenience helpers ─────────────────────────────── */ 162 163 /** FILTER hook — transforms a value through all callbacks */ 164 function addon_hook(string $hook, mixed $data = null): mixed { 165 return AddonManager::fire($hook, $data); 166 } 167 168 /** COLLECTOR hook — every callback gets same $context, returns accumulated HTML */ 169 function addon_collect(string $hook, mixed $context = null): string { 170 return AddonManager::collect($hook, $context); 171 } 172 173 /** Register a callback on a hook */ 174 function addon_on(string $hook, callable $cb, int $priority = 10): void { 175 AddonManager::on($hook, $cb, $priority); 176 }