| @@ -0,0 +1,764 @@ | |||
| 1 | + | <?php | |
| 2 | + | require_once __DIR__ . '/extensions.php'; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * Plugins. | |
| 6 | + | * | |
| 7 | + | * A plugin is a folder under plugins/. It can add public pages, admin modules, | |
| 8 | + | * database tables and behaviour, without a single edit to ZnoteX itself - which | |
| 9 | + | * is the whole point: someone extending the site should never have to fork it, | |
| 10 | + | * and should not lose their work on the next update. | |
| 11 | + | * | |
| 12 | + | * plugins/<name>/ | |
| 13 | + | * plugin.json name, version, author, description [required] | |
| 14 | + | * plugin.php registers hooks [optional] | |
| 15 | + | * pages/<page>.php public page at page.php?plugin=<name>&p=<page> | |
| 16 | + | * admin/<mod>.php admin module, listed like the built-in ones | |
| 17 | + | * install.sql run once when the plugin is enabled | |
| 18 | + | * assets/ anything it needs to serve | |
| 19 | + | * | |
| 20 | + | * Enabled state lives in znote_config as "plugin:<name>:enabled", so it | |
| 21 | + | * survives an update and needs no table of its own. | |
| 22 | + | * | |
| 23 | + | * See plugins/README.md for the full contract and the hook list. | |
| 24 | + | */ | |
| 25 | + | ||
| 26 | + | define('ZNOTE_PLUGIN_DIR', dirname(__DIR__, 2) . '/plugins'); | |
| 27 | + | ||
| 28 | + | // --------------------------------------------------------------------------- | |
| 29 | + | // Hooks | |
| 30 | + | // | |
| 31 | + | // A hook is a named point where plugins may run. Three shapes: | |
| 32 | + | // | |
| 33 | + | // znote_hook('shop.purchased', $data) - notify, return value ignored | |
| 34 | + | // $html = znote_hook_collect('page.head') - gather markup from every plugin | |
| 35 | + | // $n = znote_hook_filter('shop.price', $n) - pass a value through, changed | |
| 36 | + | // | |
| 37 | + | // A hook that throws is caught and logged: one broken plugin must not take the | |
| 38 | + | // site down. That is the difference between an extension point and a landmine. | |
| 39 | + | // --------------------------------------------------------------------------- | |
| 40 | + | ||
| 41 | + | function znote_hook_register(string $hook, callable $callback, int $priority = 10): void { | |
| 42 | + | $GLOBALS['znote_hooks'][$hook][] = array('fn' => $callback, 'priority' => $priority); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /** Callbacks for one hook, lowest priority first. */ | |
| 46 | + | function znote_hook_callbacks(string $hook): array { | |
| 47 | + | $list = $GLOBALS['znote_hooks'][$hook] ?? array(); | |
| 48 | + | if (!$list) { | |
| 49 | + | return array(); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | usort($list, static fn(array $a, array $b): int => $a['priority'] <=> $b['priority']); | |
| 53 | + | ||
| 54 | + | return array_column($list, 'fn'); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** Fire a hook. Return values are ignored; use it to notify. */ | |
| 58 | + | function znote_hook(string $hook, array $data = array()): void { | |
| 59 | + | foreach (znote_hook_callbacks($hook) as $callback) { | |
| 60 | + | try { | |
| 61 | + | $callback($data); | |
| 62 | + | } catch (Throwable $e) { | |
| 63 | + | error_log('[ZnoteX plugin] hook ' . $hook . ' failed: ' . $e->getMessage()); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /** Fire a hook and concatenate what the callbacks return. For markup. */ | |
| 69 | + | function znote_hook_collect(string $hook, array $data = array()): string { | |
| 70 | + | $out = ''; | |
| 71 | + | ||
| 72 | + | foreach (znote_hook_callbacks($hook) as $callback) { | |
| 73 | + | try { | |
| 74 | + | $out .= (string)$callback($data); | |
| 75 | + | } catch (Throwable $e) { | |
| 76 | + | error_log('[ZnoteX plugin] hook ' . $hook . ' failed: ' . $e->getMessage()); | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | return $out; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** | |
| 84 | + | * Pass a value through every callback and return what comes back. | |
| 85 | + | * | |
| 86 | + | * This is how a plugin changes something rather than merely reacting to it: | |
| 87 | + | * a discount on a shop price, a modified welcome message. Each callback | |
| 88 | + | * receives the current value and $data, and returns the new value; one that | |
| 89 | + | * throws is skipped and the value it was given survives untouched. | |
| 90 | + | */ | |
| 91 | + | function znote_hook_filter(string $hook, $value, array $data = array()) { | |
| 92 | + | foreach (znote_hook_callbacks($hook) as $callback) { | |
| 93 | + | try { | |
| 94 | + | $value = $callback($value, $data); | |
| 95 | + | } catch (Throwable $e) { | |
| 96 | + | error_log('[ZnoteX plugin] filter ' . $hook . ' failed: ' . $e->getMessage()); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | return $value; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /** | |
| 104 | + | * Fire a hook that can veto. Any callback returning false stops the action. | |
| 105 | + | * Used where a plugin must be able to say "no" - a purchase, a registration. | |
| 106 | + | */ | |
| 107 | + | function znote_hook_allows(string $hook, array $data = array()): bool { | |
| 108 | + | foreach (znote_hook_callbacks($hook) as $callback) { | |
| 109 | + | try { | |
| 110 | + | if ($callback($data) === false) { | |
| 111 | + | return false; | |
| 112 | + | } | |
| 113 | + | } catch (Throwable $e) { | |
| 114 | + | error_log('[ZnoteX plugin] hook ' . $hook . ' failed: ' . $e->getMessage()); | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | return true; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // --------------------------------------------------------------------------- | |
| 122 | + | // Registry | |
| 123 | + | // --------------------------------------------------------------------------- | |
| 124 | + | ||
| 125 | + | function znote_plugin_sanitize(string $name): string { | |
| 126 | + | $name = strtolower(trim($name)); | |
| 127 | + | return preg_match('/^[a-z0-9_-]{1,64}$/', $name) === 1 ? $name : ''; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /** Read plugin.json, tolerating a missing or malformed file. */ | |
| 131 | + | function znote_plugin_manifest(string $name): array { | |
| 132 | + | $defaults = array( | |
| 133 | + | 'key' => $name, | |
| 134 | + | 'name' => ucwords(str_replace(array('-', '_'), ' ', $name)), | |
| 135 | + | 'version' => '', | |
| 136 | + | 'author' => '', | |
| 137 | + | 'description' => '', | |
| 138 | + | 'url' => '', | |
| 139 | + | 'requires' => array(), | |
| 140 | + | ); | |
| 141 | + | ||
| 142 | + | $file = ZNOTE_PLUGIN_DIR . '/' . $name . '/plugin.json'; | |
| 143 | + | if (!is_file($file)) { | |
| 144 | + | return $defaults; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | $data = json_decode((string)file_get_contents($file), true); | |
| 148 | + | ||
| 149 | + | return is_array($data) ? array_merge($defaults, $data, array('key' => $name)) : $defaults; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /** Every plugin on disk, keyed by folder name. */ | |
| 153 | + | function znote_plugins(bool $refresh = false): array { | |
| 154 | + | static $plugins = null; | |
| 155 | + | if ($plugins !== null && !$refresh) { | |
| 156 | + | return $plugins; | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | $plugins = array(); | |
| 160 | + | ||
| 161 | + | foreach (glob(ZNOTE_PLUGIN_DIR . '/*', GLOB_ONLYDIR) ?: array() as $dir) { | |
| 162 | + | $name = basename($dir); | |
| 163 | + | if (znote_plugin_sanitize($name) === '' || $name[0] === '_') { | |
| 164 | + | continue; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | $manifest = znote_plugin_manifest($name); | |
| 168 | + | $manifest['path'] = $dir; | |
| 169 | + | $manifest['enabled'] = znote_plugin_enabled($name); | |
| 170 | + | $manifest['installed_version'] = znote_plugin_installed_version($name); | |
| 171 | + | $manifest['installed'] = ($manifest['installed_version'] !== ''); | |
| 172 | + | $manifest['update'] = znote_plugin_update_available($name, (string)$manifest['version']); | |
| 173 | + | $compatibility = znote_extension_compatibility($manifest); | |
| 174 | + | $manifest['compatible'] = $compatibility['compatible']; | |
| 175 | + | $manifest['compatibility_errors'] = $compatibility['errors']; | |
| 176 | + | $manifest['requirements'] = $compatibility['requires']; | |
| 177 | + | $manifest['page_list'] = array_map( | |
| 178 | + | static fn(string $f): string => basename($f, '.php'), | |
| 179 | + | glob($dir . '/pages/*.php') ?: array() | |
| 180 | + | ); | |
| 181 | + | $manifest['pages'] = count($manifest['page_list']); | |
| 182 | + | $manifest['admin'] = count(glob($dir . '/admin/*.php') ?: array()); | |
| 183 | + | $manifest['sql'] = is_file($dir . '/install.sql'); | |
| 184 | + | ||
| 185 | + | $plugins[$name] = $manifest; | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | ksort($plugins); | |
| 189 | + | ||
| 190 | + | return $plugins; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | function znote_plugin_enabled(string $name): bool { | |
| 194 | + | return function_exists('setting') && setting('plugin:' . $name . ':enabled', '0') === '1'; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * The version that was installed, or '' if this plugin has never been installed. | |
| 199 | + | * | |
| 200 | + | * This is what separates "a folder someone uploaded" from "a plugin whose | |
| 201 | + | * tables exist". It is the version recorded at install time, not the one in | |
| 202 | + | * plugin.json - comparing the two is how an update is noticed. | |
| 203 | + | */ | |
| 204 | + | function znote_plugin_installed_version(string $name): string { | |
| 205 | + | return function_exists('setting') ? (string)setting('plugin:' . $name . ':version', '') : ''; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /** True when the folder holds a newer version than the one installed. */ | |
| 209 | + | function znote_plugin_update_available(string $name, string $folderVersion): bool { | |
| 210 | + | $installed = znote_plugin_installed_version($name); | |
| 211 | + | ||
| 212 | + | if ($installed === '' || $folderVersion === '') { | |
| 213 | + | return false; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | return version_compare($folderVersion, $installed, '>'); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /** | |
| 220 | + | * Install or update a plugin: run its install.sql and record its version. | |
| 221 | + | * | |
| 222 | + | * The same call does both. install.sql is required to be idempotent, so | |
| 223 | + | * re-running it on an update creates whatever tables the new version has grown | |
| 224 | + | * and leaves the existing ones alone. Returns '' on success, or the error. | |
| 225 | + | */ | |
| 226 | + | function znote_plugin_install(string $name): string { | |
| 227 | + | $manifest = znote_plugin_manifest($name); | |
| 228 | + | $compatibility = znote_extension_compatibility($manifest); | |
| 229 | + | if (!$compatibility['compatible']) { | |
| 230 | + | return implode(' ', $compatibility['errors']); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | $error = znote_plugin_install_sql($name); | |
| 234 | + | ||
| 235 | + | if ($error !== '') { | |
| 236 | + | return $error; | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Recorded last: a failed install.sql must not leave the plugin looking | |
| 240 | + | // installed, or the admin loses the button that would retry it. | |
| 241 | + | setting_set('plugin:' . $name . ':version', (string)($manifest['version'] ?: '0')); | |
| 242 | + | ||
| 243 | + | return ''; | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /** Forget that a plugin was installed. Its tables are deliberately left alone. */ | |
| 247 | + | function znote_plugin_uninstall(string $name): void { | |
| 248 | + | znote_plugin_set_enabled($name, false); | |
| 249 | + | setting_set('plugin:' . $name . ':version', ''); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | function znote_plugin_set_enabled(string $name, bool $enabled): bool { | |
| 253 | + | if ($enabled) { | |
| 254 | + | $compatibility = znote_extension_compatibility(znote_plugin_manifest($name)); | |
| 255 | + | if (!$compatibility['compatible']) { | |
| 256 | + | return false; | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | return setting_set('plugin:' . $name . ':enabled', $enabled ? '1' : '0'); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /** | |
| 264 | + | * Run a plugin's install.sql, once. | |
| 265 | + | * | |
| 266 | + | * Statements must be idempotent - CREATE TABLE IF NOT EXISTS and the like - | |
| 267 | + | * because a plugin can be disabled and re-enabled, and we do not track which | |
| 268 | + | * statements already ran. A plugin that needs real migrations should ship them | |
| 269 | + | * under SQL/ and say so in its description. | |
| 270 | + | */ | |
| 271 | + | function znote_plugin_install_sql(string $name): string { | |
| 272 | + | $file = ZNOTE_PLUGIN_DIR . '/' . $name . '/install.sql'; | |
| 273 | + | if (!is_file($file)) { | |
| 274 | + | return ''; | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | $sql = (string)file_get_contents($file); | |
| 278 | + | $sql = preg_replace('/^\xEF\xBB\xBF/', '', $sql); // a leading UTF-8 BOM breaks the first statement | |
| 279 | + | $sql = preg_replace('/^--.*$/m', '', $sql); | |
| 280 | + | $failed = array(); | |
| 281 | + | ||
| 282 | + | foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) { | |
| 283 | + | if ($statement === '') { | |
| 284 | + | continue; | |
| 285 | + | } | |
| 286 | + | if (!db()->rawExecute($statement)) { | |
| 287 | + | $dbError = trim((string)db()->connection()->error); | |
| 288 | + | $failed[] = $statement . ($dbError !== '' ? ' -- ' . $dbError : ''); | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | return $failed ? count($failed) . ' statement(s) failed, first: ' . $failed[0] : ''; | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // --------------------------------------------------------------------------- | |
| 296 | + | // Loading | |
| 297 | + | // --------------------------------------------------------------------------- | |
| 298 | + | ||
| 299 | + | /** | |
| 300 | + | * Load every enabled plugin's plugin.php. | |
| 301 | + | * | |
| 302 | + | * Called from engine/init.php once the database and settings are up, because a | |
| 303 | + | * plugin may want either. A plugin that throws on load is skipped and logged | |
| 304 | + | * rather than allowed to break the request. | |
| 305 | + | */ | |
| 306 | + | function znote_plugins_load(): void { | |
| 307 | + | foreach (znote_plugins() as $name => $plugin) { | |
| 308 | + | // Enabled is not enough: a plugin that was never installed has no | |
| 309 | + | // tables, and loading it would only produce SQL errors on every page. | |
| 310 | + | if (!$plugin['enabled'] || !$plugin['installed'] || !$plugin['compatible']) { | |
| 311 | + | continue; | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | $file = $plugin['path'] . '/plugin.php'; | |
| 315 | + | if (!is_file($file)) { | |
| 316 | + | continue; | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | try { | |
| 320 | + | require_once $file; | |
| 321 | + | } catch (Throwable $e) { | |
| 322 | + | error_log('[ZnoteX plugin] ' . $name . ' failed to load: ' . $e->getMessage()); | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | znote_hook('plugins.loaded'); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /** Installed and enabled. What every entry point actually checks. */ | |
| 330 | + | function znote_plugin_active(string $name): bool { | |
| 331 | + | if (!znote_plugin_enabled($name) || znote_plugin_installed_version($name) === '') { | |
| 332 | + | return false; | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | return znote_extension_compatibility(znote_plugin_manifest($name))['compatible']; | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /** Admin modules contributed by active plugins, as key => file path. */ | |
| 339 | + | function znote_plugin_admin_modules(): array { | |
| 340 | + | $modules = array(); | |
| 341 | + | ||
| 342 | + | foreach (znote_plugins() as $name => $plugin) { | |
| 343 | + | if (!$plugin['enabled'] || !$plugin['installed'] || !$plugin['compatible']) { | |
| 344 | + | continue; | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | foreach (glob($plugin['path'] . '/admin/*.php') ?: array() as $file) { | |
| 348 | + | $module = basename($file, '.php'); | |
| 349 | + | if ($module === '' || $module[0] === '_') { | |
| 350 | + | continue; | |
| 351 | + | } | |
| 352 | + | // Namespaced so a plugin cannot shadow a built-in module. | |
| 353 | + | $modules[$name . '__' . $module] = $file; | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | return $modules; | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | /** Resolve a plugin page, or null. */ | |
| 361 | + | function znote_plugin_page(string $plugin, string $page): ?string { | |
| 362 | + | $plugin = znote_plugin_sanitize($plugin); | |
| 363 | + | $page = znote_plugin_sanitize($page); | |
| 364 | + | ||
| 365 | + | if ($plugin === '' || $page === '' || !znote_plugin_active($plugin)) { | |
| 366 | + | return null; | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | $file = ZNOTE_PLUGIN_DIR . '/' . $plugin . '/pages/' . $page . '.php'; | |
| 370 | + | ||
| 371 | + | return is_file($file) ? $file : null; | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | /** URL of a plugin's public page. */ | |
| 375 | + | function znote_plugin_url(string $plugin, string $page): string { | |
| 376 | + | return 'page.php?' . http_build_query(array('plugin' => $plugin, 'p' => $page)); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | /** URL of a file in a plugin's assets/ folder. */ | |
| 380 | + | function znote_plugin_asset(string $plugin, string $file): string { | |
| 381 | + | $plugin = znote_plugin_sanitize($plugin); | |
| 382 | + | $file = znote_extension_relative_path($file); | |
| 383 | + | return $plugin !== '' && $file !== '' ? 'plugins/' . $plugin . '/assets/' . znote_extension_url_path($file) : ''; | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | function plugin_repository_config(): array { | |
| 387 | + | global $config; | |
| 388 | + | $cfg = $config['plugin_repository'] ?? array(); | |
| 389 | + | ||
| 390 | + | return array( | |
| 391 | + | 'enabled' => !empty($cfg['enabled']), | |
| 392 | + | 'index' => trim((string)($cfg['index'] ?? '')), | |
| 393 | + | 'allowed_hosts' => array_map('strtolower', (array)($cfg['allowed_hosts'] ?? array())), | |
| 394 | + | 'cache_time' => max(60, (int)($cfg['cache_time'] ?? 3600)), | |
| 395 | + | 'max_size' => max(1, (int)($cfg['max_size_mb'] ?? 64)) * 1024 * 1024, | |
| 396 | + | ); | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | function plugin_repository_cache_path(): string { | |
| 400 | + | return 'engine/cache/plugin_repository' . Cache::EXT; | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | function plugin_repository_clear_cache(): bool { | |
| 404 | + | $file = plugin_repository_cache_path(); | |
| 405 | + | ||
| 406 | + | if (!is_file($file)) { | |
| 407 | + | return true; | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | return @unlink($file); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | function plugin_repository_url_allowed(string $url): bool { | |
| 414 | + | $cfg = plugin_repository_config(); | |
| 415 | + | $parts = parse_url($url); | |
| 416 | + | ||
| 417 | + | if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) { | |
| 418 | + | return false; | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | return in_array(strtolower($parts['host']), $cfg['allowed_hosts'], true); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | function plugin_repository_get(string $url, ?string $toFile = null, ?string &$error = null) { | |
| 425 | + | $cfg = plugin_repository_config(); | |
| 426 | + | ||
| 427 | + | if (!plugin_repository_url_allowed($url)) { | |
| 428 | + | $error = 'Refused: the URL must be https and its host must be listed in $config[\'plugin_repository\'][\'allowed_hosts\'].'; | |
| 429 | + | return false; | |
| 430 | + | } | |
| 431 | + | if (!function_exists('curl_init')) { | |
| 432 | + | $error = 'The curl extension is not loaded.'; | |
| 433 | + | return false; | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | $ch = curl_init($url); | |
| 437 | + | curl_setopt($ch, CURLOPT_RETURNTRANSFER, $toFile === null); | |
| 438 | + | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); | |
| 439 | + | curl_setopt($ch, CURLOPT_MAXREDIRS, 3); | |
| 440 | + | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8); | |
| 441 | + | curl_setopt($ch, CURLOPT_TIMEOUT, 120); | |
| 442 | + | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |
| 443 | + | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |
| 444 | + | curl_setopt($ch, CURLOPT_USERAGENT, 'ZnoteX/' . ($GLOBALS['version'] ?? '2.0.1')); | |
| 445 | + | ||
| 446 | + | $ca = function_exists('znote_cainfo') ? znote_cainfo() : ''; | |
| 447 | + | if ($ca !== '') { | |
| 448 | + | curl_setopt($ch, CURLOPT_CAINFO, $ca); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | $handle = null; | |
| 452 | + | if ($toFile !== null) { | |
| 453 | + | $handle = @fopen($toFile, 'wb'); | |
| 454 | + | if ($handle === false) { | |
| 455 | + | $error = 'Cannot write to ' . $toFile; | |
| 456 | + | curl_close($ch); | |
| 457 | + | return false; | |
| 458 | + | } | |
| 459 | + | curl_setopt($ch, CURLOPT_FILE, $handle); | |
| 460 | + | curl_setopt($ch, CURLOPT_NOPROGRESS, false); | |
| 461 | + | curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($res, $dlTotal, $dlNow) use ($cfg) { | |
| 462 | + | return ($dlNow > $cfg['max_size'] || $dlTotal > $cfg['max_size']) ? 1 : 0; | |
| 463 | + | }); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | $body = curl_exec($ch); | |
| 467 | + | $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| 468 | + | $errNo = curl_errno($ch); | |
| 469 | + | $errStr = curl_error($ch); | |
| 470 | + | curl_close($ch); | |
| 471 | + | ||
| 472 | + | if ($handle !== null) { | |
| 473 | + | fclose($handle); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | if ($errNo !== 0) { | |
| 477 | + | $error = ($errNo === 42 || $errNo === 23) | |
| 478 | + | ? 'Download aborted: the file is larger than the configured limit.' | |
| 479 | + | : 'Download failed (curl ' . $errNo . '): ' . $errStr; | |
| 480 | + | return false; | |
| 481 | + | } | |
| 482 | + | if ($status < 200 || $status >= 300) { | |
| 483 | + | $error = 'The server answered HTTP ' . $status . '.'; | |
| 484 | + | return false; | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | return $toFile === null ? $body : true; | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | function plugin_repository_notes($value): string { | |
| 491 | + | return function_exists('theme_repository_notes') | |
| 492 | + | ? theme_repository_notes($value) | |
| 493 | + | : (is_string($value) || is_numeric($value) ? trim((string)$value) : ''); | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | /** The on-disk cache, if it's still fresh (or $refresh forces past it). */ | |
| 497 | + | function plugin_repository_cached(Cache $cache, bool $refresh): ?array { | |
| 498 | + | if ($refresh || $cache->hasExpired()) { | |
| 499 | + | return null; | |
| 500 | + | } | |
| 501 | + | $cached = $cache->load(); | |
| 502 | + | return is_array($cached) ? $cached : null; | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | /** | |
| 506 | + | * Fetches and JSON-decodes the catalogue index. Accepts both a bare array | |
| 507 | + | * and {"plugins": [...]}. Returns null (with $error set) on any failure. | |
| 508 | + | */ | |
| 509 | + | function plugin_repository_fetch_raw(string $indexUrl, bool $refresh, ?string &$error): ?array { | |
| 510 | + | if ($refresh) { | |
| 511 | + | $indexUrl .= (strpos($indexUrl, '?') === false ? '?' : '&') . 'nocache=' . time(); | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | $body = plugin_repository_get($indexUrl, null, $error); | |
| 515 | + | if ($body === false) { | |
| 516 | + | return null; | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | $data = json_decode((string)$body, true); | |
| 520 | + | if (!is_array($data)) { | |
| 521 | + | $error = 'The catalogue is not valid JSON: ' . json_last_error_msg() . ' | Response: ' . substr((string)$body, 0, 200); | |
| 522 | + | return null; | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | if (isset($data['plugins']) && is_array($data['plugins'])) { | |
| 526 | + | $data = $data['plugins']; | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | return $data; | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | /** Raw catalogue entries, validated and reshaped into the plugin-list format the rest of the admin panel expects. */ | |
| 533 | + | function plugin_repository_normalize_entries(array $data): array { | |
| 534 | + | $plugins = array(); | |
| 535 | + | ||
| 536 | + | foreach ($data as $entry) { | |
| 537 | + | if (!is_array($entry)) { | |
| 538 | + | continue; | |
| 539 | + | } | |
| 540 | + | $key = znote_plugin_sanitize((string)($entry['key'] ?? '')); | |
| 541 | + | if ($key === '') { | |
| 542 | + | continue; | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | $download = trim((string)($entry['download'] ?? '')); | |
| 546 | + | $screenshot = trim((string)($entry['screenshot'] ?? '')); | |
| 547 | + | $changelog = ''; | |
| 548 | + | foreach (array('changelog', 'changes', 'release_notes', 'update') as $notesKey) { | |
| 549 | + | if (array_key_exists($notesKey, $entry)) { | |
| 550 | + | $changelog = plugin_repository_notes($entry[$notesKey]); | |
| 551 | + | break; | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | $plugins[$key] = array( | |
| 556 | + | 'key' => $key, | |
| 557 | + | 'name' => (string)($entry['name'] ?? ucfirst($key)), | |
| 558 | + | 'author' => (string)($entry['author'] ?? ''), | |
| 559 | + | 'version' => (string)($entry['version'] ?? ''), | |
| 560 | + | 'requires' => $entry['requires'] ?? array(), | |
| 561 | + | 'description' => (string)($entry['description'] ?? ''), | |
| 562 | + | 'changelog' => $changelog, | |
| 563 | + | 'url' => (string)($entry['url'] ?? ''), | |
| 564 | + | 'screenshot' => plugin_repository_url_allowed($screenshot) ? $screenshot : '', | |
| 565 | + | 'download' => $download, | |
| 566 | + | 'installable' => plugin_repository_url_allowed($download), | |
| 567 | + | ); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | ksort($plugins); | |
| 571 | + | ||
| 572 | + | return $plugins; | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | function plugin_repository_list(bool $refresh = false): array { | |
| 576 | + | $cfg = plugin_repository_config(); | |
| 577 | + | ||
| 578 | + | if (!$cfg['enabled'] || $cfg['index'] === '') { | |
| 579 | + | return array('plugins' => array(), 'error' => ''); | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | $cache = new Cache('engine/cache/plugin_repository'); | |
| 583 | + | $cache->useMemory(false); | |
| 584 | + | ||
| 585 | + | $cached = plugin_repository_cached($cache, $refresh); | |
| 586 | + | if ($cached !== null) { | |
| 587 | + | return array('plugins' => $cached, 'error' => '', 'cached' => true); | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | $error = null; | |
| 591 | + | $data = plugin_repository_fetch_raw($cfg['index'], $refresh, $error); | |
| 592 | + | if ($data === null) { | |
| 593 | + | return array('plugins' => array(), 'error' => (string)$error); | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | $plugins = plugin_repository_normalize_entries($data); | |
| 597 | + | ||
| 598 | + | $cache->setContent($plugins); | |
| 599 | + | $cache->save(); | |
| 600 | + | ||
| 601 | + | return array('plugins' => $plugins, 'error' => ''); | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | function plugin_repository_install(string $key, bool $overwrite = false): string { | |
| 605 | + | $key = znote_plugin_sanitize($key); | |
| 606 | + | if ($key === '') { | |
| 607 | + | return 'Invalid plugin name.'; | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | $catalogue = plugin_repository_list(); | |
| 611 | + | if (!isset($catalogue['plugins'][$key])) { | |
| 612 | + | return 'That plugin is not in the catalogue.'; | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | $entry = $catalogue['plugins'][$key]; | |
| 616 | + | if (!$entry['installable']) { | |
| 617 | + | return 'Its download URL is not https, or its host is not on the allow list.'; | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | $target = ZNOTE_PLUGIN_DIR . '/' . $key; | |
| 621 | + | if (is_dir($target) && !$overwrite) { | |
| 622 | + | return 'already-installed'; | |
| 623 | + | } | |
| 624 | + | if (!is_writable(ZNOTE_PLUGIN_DIR)) { | |
| 625 | + | return 'The plugins/ directory is not writable by PHP.'; | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | $tmp = ZNOTE_PLUGIN_DIR . '/.' . $key . '.download.zip'; | |
| 629 | + | $err = null; | |
| 630 | + | if (plugin_repository_get($entry['download'], $tmp, $err) === false) { | |
| 631 | + | @unlink($tmp); | |
| 632 | + | return (string)$err; | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | $result = plugin_archive_install($key, $tmp, $overwrite); | |
| 636 | + | @unlink($tmp); | |
| 637 | + | ||
| 638 | + | return $result; | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | function plugin_archive_install(string $key, string $zipPath, bool $overwrite = false): string { | |
| 642 | + | $key = znote_plugin_sanitize($key); | |
| 643 | + | if ($key === '') { | |
| 644 | + | return 'Invalid plugin name.'; | |
| 645 | + | } | |
| 646 | + | $target = ZNOTE_PLUGIN_DIR . '/' . $key; | |
| 647 | + | if (is_dir($target) && !$overwrite) { | |
| 648 | + | return 'already-installed'; | |
| 649 | + | } | |
| 650 | + | if (!function_exists('theme_archive_open')) { | |
| 651 | + | return 'Archive support is unavailable (engine/function/theme.php not loaded).'; | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | $archive = theme_archive_open($zipPath); | |
| 655 | + | if (is_string($archive)) { | |
| 656 | + | return $archive; | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | $files = array(); | |
| 660 | + | $prefix = null; | |
| 661 | + | ||
| 662 | + | foreach ($archive['names'] as $name) { | |
| 663 | + | if ($name === '') { | |
| 664 | + | continue; | |
| 665 | + | } | |
| 666 | + | if ($name[0] === '/' || strpos($name, '../') !== false || strpos($name, ':') !== false) { | |
| 667 | + | $archive['close'](); | |
| 668 | + | return 'Refused: the archive contains a path that would write outside plugins/ (' . $name . ').'; | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | $files[] = $name; | |
| 672 | + | ||
| 673 | + | $top = explode('/', $name)[0]; | |
| 674 | + | if ($prefix === null) { | |
| 675 | + | $prefix = $top; | |
| 676 | + | } elseif ($prefix !== $top) { | |
| 677 | + | $prefix = ''; | |
| 678 | + | } | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | if (!$files) { | |
| 682 | + | $archive['close'](); | |
| 683 | + | return 'The archive is empty.'; | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | $strip = ($prefix !== null && $prefix !== '') ? strlen($prefix) + 1 : 0; | |
| 687 | + | ||
| 688 | + | $hasManifest = false; | |
| 689 | + | foreach ($files as $name) { | |
| 690 | + | if (substr($name, $strip) === 'plugin.json') { | |
| 691 | + | $hasManifest = true; | |
| 692 | + | break; | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | if (!$hasManifest) { | |
| 696 | + | $archive['close'](); | |
| 697 | + | return 'Refused: no plugin.json in the archive, so this is not a usable plugin.'; | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | $staging = ZNOTE_PLUGIN_DIR . '/.' . $key . '.staging'; | |
| 701 | + | znote_rrmdir($staging); | |
| 702 | + | if (!@mkdir($staging, 0775, true)) { | |
| 703 | + | $archive['close'](); | |
| 704 | + | return 'Could not create a staging directory inside plugins/.'; | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | foreach ($files as $name) { | |
| 708 | + | $relative = substr($name, $strip); | |
| 709 | + | if ($relative === '' || $relative === false) { | |
| 710 | + | continue; | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | $dest = $staging . '/' . $relative; | |
| 714 | + | ||
| 715 | + | if (substr($name, -1) === '/') { | |
| 716 | + | @mkdir($dest, 0775, true); | |
| 717 | + | continue; | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | $dir = dirname($dest); | |
| 721 | + | if (!is_dir($dir) && !@mkdir($dir, 0775, true)) { | |
| 722 | + | continue; | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | $stream = $archive['read']($name); | |
| 726 | + | if ($stream === false) { | |
| 727 | + | continue; | |
| 728 | + | } | |
| 729 | + | $out = @fopen($dest, 'wb'); | |
| 730 | + | if ($out !== false) { | |
| 731 | + | stream_copy_to_stream($stream, $out); | |
| 732 | + | fclose($out); | |
| 733 | + | } | |
| 734 | + | fclose($stream); | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | $archive['close'](); | |
| 738 | + | ||
| 739 | + | if (!is_file($staging . '/plugin.json')) { | |
| 740 | + | znote_rrmdir($staging); | |
| 741 | + | return 'The archive unpacked without a plugin.json. Nothing was installed.'; | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | if (is_dir($target)) { | |
| 745 | + | $backup = ZNOTE_PLUGIN_DIR . '/.' . $key . '.previous'; | |
| 746 | + | znote_rrmdir($backup); | |
| 747 | + | ||
| 748 | + | if (!@rename($target, $backup)) { | |
| 749 | + | znote_rrmdir($staging); | |
| 750 | + | return 'Could not move the existing plugin aside. Check permissions on plugins/' . $key . '.'; | |
| 751 | + | } | |
| 752 | + | if (!@rename($staging, $target)) { | |
| 753 | + | @rename($backup, $target); | |
| 754 | + | znote_rrmdir($staging); | |
| 755 | + | return 'Could not put the new plugin in place. The previous one was restored.'; | |
| 756 | + | } | |
| 757 | + | znote_rrmdir($backup); | |
| 758 | + | } elseif (!@rename($staging, $target)) { | |
| 759 | + | znote_rrmdir($staging); | |
| 760 | + | return 'Could not create plugins/' . $key . '.'; | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | return ''; | |
| 764 | + | } |
| @@ -0,0 +1,185 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Plugin Settings API. | |
| 4 | + | * | |
| 5 | + | * A plugin that ships plugins/<name>/settings.json gets a configuration page | |
| 6 | + | * in the admin panel for free - Admin Panel > Plugins > Settings - instead of | |
| 7 | + | * hand-coding a form. Values are stored under the same "plugin:<name>:setting:<key>" | |
| 8 | + | * namespace $api->setting() already reads from extensions.php, so a plugin.php | |
| 9 | + | * that calls $api->setting('mode') sees exactly what the generated form saved. | |
| 10 | + | * | |
| 11 | + | * settings.json: | |
| 12 | + | * { | |
| 13 | + | * "fields": [ | |
| 14 | + | * {"key": "api_key", "label": "API key", "type": "text", "default": ""}, | |
| 15 | + | * {"key": "enabled", "label": "Enabled", "type": "bool", "default": "1"}, | |
| 16 | + | * {"key": "mode", "label": "Mode", "type": "select", "default": "test", | |
| 17 | + | * "options": {"test": "Test", "live": "Live"}}, | |
| 18 | + | * {"key": "max_items", "label": "Max items", "type": "int", "default": "10", "min": 1, "max": 100}, | |
| 19 | + | * {"key": "notes", "label": "Notes", "type": "textarea", "default": ""}, | |
| 20 | + | * {"key": "webhook_secret", "label": "Webhook secret", "type": "password", "default": ""} | |
| 21 | + | * ] | |
| 22 | + | * } | |
| 23 | + | * | |
| 24 | + | * Supported types: text, textarea, password, bool, int, select, checklist, color. | |
| 25 | + | */ | |
| 26 | + | ||
| 27 | + | const ZNOTE_PLUGIN_SETTINGS_TYPES = array('text', 'textarea', 'password', 'bool', 'int', 'select', 'checklist', 'color'); | |
| 28 | + | ||
| 29 | + | function znote_plugin_settings_file(string $plugin): string { | |
| 30 | + | return ZNOTE_PLUGIN_DIR . '/' . $plugin . '/settings.json'; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | function znote_plugin_settings_has(string $plugin): bool { | |
| 34 | + | $plugin = znote_plugin_sanitize($plugin); | |
| 35 | + | return $plugin !== '' && is_file(znote_plugin_settings_file($plugin)); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | * Reads and normalizes settings.json. A malformed file, an unknown type, or a | |
| 40 | + | * field key that would not survive ZnoteExtensionApi::settingKey() is dropped | |
| 41 | + | * rather than allowed to reach a form or a query. | |
| 42 | + | */ | |
| 43 | + | function znote_plugin_settings_schema(string $plugin): array { | |
| 44 | + | $plugin = znote_plugin_sanitize($plugin); | |
| 45 | + | if ($plugin === '' || !znote_plugin_settings_has($plugin)) { | |
| 46 | + | return array(); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | $data = json_decode((string)file_get_contents(znote_plugin_settings_file($plugin)), true); | |
| 50 | + | if (!is_array($data) || !isset($data['fields']) || !is_array($data['fields'])) { | |
| 51 | + | return array(); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | $fields = array(); | |
| 55 | + | foreach ($data['fields'] as $field) { | |
| 56 | + | if (!is_array($field)) { | |
| 57 | + | continue; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | $key = strtolower(trim((string)($field['key'] ?? ''))); | |
| 61 | + | $type = strtolower(trim((string)($field['type'] ?? 'text'))); | |
| 62 | + | ||
| 63 | + | if ($key === '' || !preg_match('/^[a-z0-9_.-]{1,100}$/', $key) || !in_array($type, ZNOTE_PLUGIN_SETTINGS_TYPES, true)) { | |
| 64 | + | continue; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | $normalized = array( | |
| 68 | + | 'key' => $key, | |
| 69 | + | 'type' => $type, | |
| 70 | + | 'label' => (string)($field['label'] ?? ucwords(str_replace(array('_', '.'), ' ', $key))), | |
| 71 | + | 'help' => (string)($field['help'] ?? ''), | |
| 72 | + | 'default' => (string)($field['default'] ?? ''), | |
| 73 | + | ); | |
| 74 | + | ||
| 75 | + | if (in_array($type, array('select', 'checklist'), true)) { | |
| 76 | + | $options = array(); | |
| 77 | + | foreach ((array)($field['options'] ?? array()) as $value => $label) { | |
| 78 | + | $options[(string)$value] = (string)$label; | |
| 79 | + | } | |
| 80 | + | $normalized['options'] = $options; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if ($type === 'int') { | |
| 84 | + | $normalized['min'] = array_key_exists('min', $field) ? (int)$field['min'] : null; | |
| 85 | + | $normalized['max'] = array_key_exists('max', $field) ? (int)$field['max'] : null; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | $fields[$key] = $normalized; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | return $fields; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | function znote_plugin_settings_storage_key(string $plugin, string $field): string { | |
| 95 | + | return 'plugin:' . $plugin . ':setting:' . $field; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /** Every field's current value: the stored one, or the schema default. */ | |
| 99 | + | function znote_plugin_settings_get(string $plugin): array { | |
| 100 | + | $plugin = znote_plugin_sanitize($plugin); | |
| 101 | + | $values = array(); | |
| 102 | + | ||
| 103 | + | foreach (znote_plugin_settings_schema($plugin) as $key => $field) { | |
| 104 | + | $values[$key] = setting(znote_plugin_settings_storage_key($plugin, $key), $field['default']) ?? $field['default']; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | return $values; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /** | |
| 111 | + | * Sanitizes one submitted value against its field definition. Returns the | |
| 112 | + | * string to store, or null when the input is invalid for its type - the | |
| 113 | + | * caller then leaves the previous value untouched and reports the field. | |
| 114 | + | */ | |
| 115 | + | function znote_plugin_settings_sanitize_field(array $field, $raw): ?string { | |
| 116 | + | switch ($field['type']) { | |
| 117 | + | ||
| 118 | + | case 'bool': | |
| 119 | + | return ($raw !== null && $raw !== '' && $raw !== '0') ? '1' : '0'; | |
| 120 | + | ||
| 121 | + | case 'int': | |
| 122 | + | if (!is_scalar($raw) || !preg_match('/^-?\d+$/', trim((string)$raw))) { | |
| 123 | + | return null; | |
| 124 | + | } | |
| 125 | + | $value = (int)$raw; | |
| 126 | + | if ($field['min'] !== null && $value < $field['min']) { | |
| 127 | + | $value = $field['min']; | |
| 128 | + | } | |
| 129 | + | if ($field['max'] !== null && $value > $field['max']) { | |
| 130 | + | $value = $field['max']; | |
| 131 | + | } | |
| 132 | + | return (string)$value; | |
| 133 | + | ||
| 134 | + | case 'select': | |
| 135 | + | $value = (string)$raw; | |
| 136 | + | return array_key_exists($value, $field['options']) ? $value : null; | |
| 137 | + | ||
| 138 | + | case 'color': | |
| 139 | + | $value = trim((string)$raw); | |
| 140 | + | if ($value === '') { | |
| 141 | + | return ''; | |
| 142 | + | } | |
| 143 | + | return preg_match('/^#[0-9a-f]{6}$/i', $value) ? $value : null; | |
| 144 | + | ||
| 145 | + | case 'checklist': | |
| 146 | + | $chosen = is_array($raw) ? $raw : array(); | |
| 147 | + | $valid = array_values(array_intersect(array_map('strval', $chosen), array_keys($field['options']))); | |
| 148 | + | return implode(',', $valid); | |
| 149 | + | ||
| 150 | + | case 'textarea': | |
| 151 | + | case 'password': | |
| 152 | + | case 'text': | |
| 153 | + | default: | |
| 154 | + | return is_scalar($raw) ? (string)$raw : null; | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** | |
| 159 | + | * Validates and stores every field present in $input against the plugin's | |
| 160 | + | * schema. A field that fails validation keeps its previous value and its key | |
| 161 | + | * is returned in the 'errors' list; everything else is saved. | |
| 162 | + | */ | |
| 163 | + | function znote_plugin_settings_save(string $plugin, array $input): array { | |
| 164 | + | $plugin = znote_plugin_sanitize($plugin); | |
| 165 | + | $schema = znote_plugin_settings_schema($plugin); | |
| 166 | + | $errors = array(); | |
| 167 | + | ||
| 168 | + | foreach ($schema as $key => $field) { | |
| 169 | + | // A checkbox that is off submits nothing at all - treat absence as | |
| 170 | + | // false for bool fields, but as "leave alone" for everything else. | |
| 171 | + | if (!array_key_exists($key, $input) && $field['type'] !== 'bool' && $field['type'] !== 'checklist') { | |
| 172 | + | continue; | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | $sanitized = znote_plugin_settings_sanitize_field($field, $input[$key] ?? null); | |
| 176 | + | if ($sanitized === null) { | |
| 177 | + | $errors[] = $key; | |
| 178 | + | continue; | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | setting_set(znote_plugin_settings_storage_key($plugin, $key), $sanitized); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | return $errors; | |
| 185 | + | } |
| @@ -0,0 +1,287 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** https://github.com/Voronenko/PHPOTP/blob/08cda9cb9c30b7242cf0b3a9100a6244a2874927/code/base32static.php | |
| 3 | + | * Encode in Base32 based on RFC 4648. | |
| 4 | + | * Requires 20% more space than base64 | |
| 5 | + | * Great for case-insensitive filesystems like Windows and URL's (except for = char which can be excluded using the pad option for urls) | |
| 6 | + | * | |
| 7 | + | * @package default | |
| 8 | + | * @author Bryan Ruiz | |
| 9 | + | **/ | |
| 10 | + | class Base32Static { | |
| 11 | + | ||
| 12 | + | private static $map = array( | |
| 13 | + | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7 | |
| 14 | + | 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15 | |
| 15 | + | 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23 | |
| 16 | + | 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31 | |
| 17 | + | '=' // padding character | |
| 18 | + | ); | |
| 19 | + | ||
| 20 | + | private static $flippedMap = array( | |
| 21 | + | 'A'=>'0', 'B'=>'1', 'C'=>'2', 'D'=>'3', 'E'=>'4', 'F'=>'5', 'G'=>'6', 'H'=>'7', | |
| 22 | + | 'I'=>'8', 'J'=>'9', 'K'=>'10', 'L'=>'11', 'M'=>'12', 'N'=>'13', 'O'=>'14', 'P'=>'15', | |
| 23 | + | 'Q'=>'16', 'R'=>'17', 'S'=>'18', 'T'=>'19', 'U'=>'20', 'V'=>'21', 'W'=>'22', 'X'=>'23', | |
| 24 | + | 'Y'=>'24', 'Z'=>'25', '2'=>'26', '3'=>'27', '4'=>'28', '5'=>'29', '6'=>'30', '7'=>'31' | |
| 25 | + | ); | |
| 26 | + | ||
| 27 | + | /** | |
| 28 | + | * Use padding false when encoding for urls | |
| 29 | + | * | |
| 30 | + | * @return string base32 encoded string | |
| 31 | + | * @author Bryan Ruiz | |
| 32 | + | **/ | |
| 33 | + | public static function encode($input, $padding = true) { | |
| 34 | + | if(empty($input)) return ""; | |
| 35 | + | ||
| 36 | + | $input = str_split($input); | |
| 37 | + | $binaryString = ""; | |
| 38 | + | ||
| 39 | + | for($i = 0; $i < count($input); $i++) { | |
| 40 | + | $binaryString .= str_pad(base_convert(ord($input[$i]), 10, 2), 8, '0', STR_PAD_LEFT); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | $fiveBitBinaryArray = str_split($binaryString, 5); | |
| 44 | + | $base32 = ""; | |
| 45 | + | $i=0; | |
| 46 | + | ||
| 47 | + | while($i < count($fiveBitBinaryArray)) { | |
| 48 | + | $base32 .= self::$map[base_convert(str_pad($fiveBitBinaryArray[$i], 5,'0'), 2, 10)]; | |
| 49 | + | $i++; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | if($padding && ($x = strlen($binaryString) % 40) != 0) { | |
| 53 | + | if($x == 8) $base32 .= str_repeat(self::$map[32], 6); | |
| 54 | + | else if($x == 16) $base32 .= str_repeat(self::$map[32], 4); | |
| 55 | + | else if($x == 24) $base32 .= str_repeat(self::$map[32], 3); | |
| 56 | + | else if($x == 32) $base32 .= self::$map[32]; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | return $base32; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | public static function decode($input) { | |
| 63 | + | if (!is_string($input) || $input === '') return false; | |
| 64 | + | ||
| 65 | + | $input = strtoupper($input); | |
| 66 | + | if (!preg_match('/^[A-Z2-7]+={0,6}$/', $input)) return false; | |
| 67 | + | ||
| 68 | + | $paddingCharCount = substr_count($input, self::$map[32]); | |
| 69 | + | if (!in_array($paddingCharCount, array(6, 4, 3, 1, 0), true)) return false; | |
| 70 | + | if ($paddingCharCount > 0 && strlen($input) % 8 !== 0) return false; | |
| 71 | + | ||
| 72 | + | $input = rtrim($input, self::$map[32]); | |
| 73 | + | if (!in_array(strlen($input) % 8, array(0, 2, 4, 5, 7), true)) return false; | |
| 74 | + | ||
| 75 | + | $binaryString = ''; | |
| 76 | + | $buffer = 0; | |
| 77 | + | $bufferBits = 0; | |
| 78 | + | ||
| 79 | + | foreach (str_split($input) as $character) { | |
| 80 | + | $buffer = ($buffer << 5) | (int)self::$flippedMap[$character]; | |
| 81 | + | $bufferBits += 5; | |
| 82 | + | ||
| 83 | + | if ($bufferBits >= 8) { | |
| 84 | + | $bufferBits -= 8; | |
| 85 | + | $binaryString .= chr(($buffer >> $bufferBits) & 0xff); | |
| 86 | + | $buffer &= $bufferBits > 0 ? (1 << $bufferBits) - 1 : 0; | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | return $binaryString; | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // http://www.faqs.org/rfcs/rfc6238.html | |
| 95 | + | // https://github.com/Voronenko/PHPOTP/blob/08cda9cb9c30b7242cf0b3a9100a6244a2874927/code/rfc6238.php | |
| 96 | + | // Local changes: http -> https, consistent indentation, 200x200 -> 300x300 QR image size, PHP end tag | |
| 97 | + | class TokenAuth6238 { | |
| 98 | + | ||
| 99 | + | /** | |
| 100 | + | * verify | |
| 101 | + | * | |
| 102 | + | * @param string $secretkey Secret clue (base 32). | |
| 103 | + | * @return bool True if success, false if failure | |
| 104 | + | */ | |
| 105 | + | public static function verify($secretkey, $code, $rangein30s = 3) { | |
| 106 | + | $key = Base32Static::decode($secretkey); | |
| 107 | + | $unixtimestamp = intdiv(time(), 30); | |
| 108 | + | ||
| 109 | + | for($i=-($rangein30s); $i<=$rangein30s; $i++) { | |
| 110 | + | $checktime = (int)($unixtimestamp+$i); | |
| 111 | + | $thiskey = self::oath_hotp($key, $checktime); | |
| 112 | + | ||
| 113 | + | if (hash_equals( | |
| 114 | + | str_pad((string)$code, 6, '0', STR_PAD_LEFT), | |
| 115 | + | str_pad((string)self::oath_truncate($thiskey, 6), 6, '0', STR_PAD_LEFT) | |
| 116 | + | )) { | |
| 117 | + | return true; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | } | |
| 121 | + | return false; | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | ||
| 125 | + | public static function getTokenCode($secretkey,$rangein30s = 3) { | |
| 126 | + | $result = ""; | |
| 127 | + | $key = Base32Static::decode($secretkey); | |
| 128 | + | $unixtimestamp = intdiv(time(), 30); | |
| 129 | + | ||
| 130 | + | for($i=-($rangein30s); $i<=$rangein30s; $i++) { | |
| 131 | + | $checktime = (int)($unixtimestamp+$i); | |
| 132 | + | $thiskey = self::oath_hotp($key, $checktime); | |
| 133 | + | $result = $result." # ".self::oath_truncate($thiskey,6); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | return $result; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | public static function getTokenCodeDebug($secretkey,$rangein30s = 3) { | |
| 140 | + | $result = ""; | |
| 141 | + | print "<br/>SecretKey: $secretkey <br/>"; | |
| 142 | + | ||
| 143 | + | $key = Base32Static::decode($secretkey); | |
| 144 | + | print "Key(base 32 decode): $key <br/>"; | |
| 145 | + | ||
| 146 | + | $unixtimestamp = intdiv(time(), 30); | |
| 147 | + | print "UnixTimeStamp (time()/30): $unixtimestamp <br/>"; | |
| 148 | + | ||
| 149 | + | for($i=-($rangein30s); $i<=$rangein30s; $i++) { | |
| 150 | + | $checktime = (int)($unixtimestamp+$i); | |
| 151 | + | print "Calculating oath_hotp from (int)(unixtimestamp +- 30sec offset): $checktime basing on secret key<br/>"; | |
| 152 | + | ||
| 153 | + | $thiskey = self::oath_hotp($key, $checktime, true); | |
| 154 | + | print "======================================================<br/>"; | |
| 155 | + | print "CheckTime: $checktime oath_hotp:".$thiskey."<br/>"; | |
| 156 | + | ||
| 157 | + | $result = $result." # ".self::oath_truncate($thiskey,6,true); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | return $result; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | public static function getBarCodeUrl($username, $domain, $secretkey, $issuer) { | |
| 164 | + | $label = rawurlencode($username . '@' . $domain); | |
| 165 | + | $issuer = rawurlencode($issuer); | |
| 166 | + | ||
| 167 | + | $otpauth = "otpauth://totp/{$label}?secret={$secretkey}&issuer={$issuer}&algorithm=SHA1&digits=6&period=30"; | |
| 168 | + | ||
| 169 | + | return 'https://api.qrserver.com/v1/create-qr-code/?' . http_build_query([ | |
| 170 | + | 'size' => '300x300', | |
| 171 | + | 'data' => $otpauth | |
| 172 | + | ]); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | public static function generateRandomClue($length = 16) { | |
| 176 | + | $b32 = "234567QWERTYUIOPASDFGHJKLZXCVBNM"; | |
| 177 | + | $s = ""; | |
| 178 | + | for ($i = 0; $i < $length; $i++) { | |
| 179 | + | $s .= $b32[random_int(0, 31)]; | |
| 180 | + | } | |
| 181 | + | return $s; | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | private static function hotp_tobytestream($key) { | |
| 185 | + | $result = array(); | |
| 186 | + | $last = strlen($key); | |
| 187 | + | for ($i = 0; $i < $last; $i = $i + 2) { | |
| 188 | + | $x = $key[$i] + $key[$i + 1]; | |
| 189 | + | $x = strtoupper($x); | |
| 190 | + | $x = hexdec($x); | |
| 191 | + | $result = $result.chr($x); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | return $result; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | private static function oath_hotp ($key, $counter, $debug=false) { | |
| 198 | + | $result = ""; | |
| 199 | + | $orgcounter = $counter; | |
| 200 | + | $cur_counter = array(0,0,0,0,0,0,0,0); | |
| 201 | + | ||
| 202 | + | if ($debug) { | |
| 203 | + | print "Packing counter $counter (".dechex($counter).")into binary string - pay attention to hex representation of key and binary representation<br/>"; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | for($i=7;$i>=0;$i--) { // C for unsigned char, * for repeating to the end of the input data | |
| 207 | + | $cur_counter[$i] = pack ('C*', $counter); | |
| 208 | + | ||
| 209 | + | if ($debug) { | |
| 210 | + | print $cur_counter[$i]."(".dechex(ord($cur_counter[$i])).")"." from $counter <br/>"; | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | $counter = $counter >> 8; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | if ($debug) { | |
| 217 | + | foreach ($cur_counter as $char) { | |
| 218 | + | print ord($char) . " "; | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | print "<br/>"; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | $binary = implode($cur_counter); | |
| 225 | + | ||
| 226 | + | // Pad to 8 characters | |
| 227 | + | str_pad($binary, 8, chr(0), STR_PAD_LEFT); | |
| 228 | + | ||
| 229 | + | if ($debug) { | |
| 230 | + | print "Prior to HMAC calculation pad with zero on the left until 8 characters.<br/>"; | |
| 231 | + | print "Calculate sha1 HMAC(Hash-based Message Authentication Code http://en.wikipedia.org/wiki/HMAC).<br/>"; | |
| 232 | + | print "hash_hmac ('sha1', $binary, $key)<br/>"; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | $result = hash_hmac ('sha1', $binary, $key); | |
| 236 | + | ||
| 237 | + | if ($debug) { | |
| 238 | + | print "Result: $result <br/>"; | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | return $result; | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | private static function oath_truncate($hash, $length = 6, $debug=false) { | |
| 245 | + | $result=""; | |
| 246 | + | ||
| 247 | + | // Convert to dec | |
| 248 | + | if($debug) { | |
| 249 | + | print "converting hex hash into characters<br/>"; | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | $hashcharacters = str_split($hash,2); | |
| 253 | + | ||
| 254 | + | if($debug) { | |
| 255 | + | print_r($hashcharacters); | |
| 256 | + | print "<br/>and convert to decimals:<br/>"; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | for ($j=0; $j<count($hashcharacters); $j++) { | |
| 260 | + | $hmac_result[]=hexdec($hashcharacters[$j]); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | if($debug) { | |
| 264 | + | print_r($hmac_result); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // http://php.net/manual/ru/function.hash-hmac.php | |
| 268 | + | // adopted from brent at thebrent dot net 21-May-2009 08:17 comment | |
| 269 | + | ||
| 270 | + | $offset = $hmac_result[19] & 0xf; | |
| 271 | + | ||
| 272 | + | if($debug) { | |
| 273 | + | print "Calculating offset as 19th element of hmac:".$hmac_result[19]."<br/>"; | |
| 274 | + | print "offset:".$offset; | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | $result = ( | |
| 278 | + | (($hmac_result[$offset+0] & 0x7f) << 24 ) | | |
| 279 | + | (($hmac_result[$offset+1] & 0xff) << 16 ) | | |
| 280 | + | (($hmac_result[$offset+2] & 0xff) << 8 ) | | |
| 281 | + | ($hmac_result[$offset+3] & 0xff) | |
| 282 | + | ) % pow(10,$length); | |
| 283 | + | ||
| 284 | + | return $result; | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ?> |
| @@ -0,0 +1,145 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | ||
| 4 | + | function scheduler_tasks(): array | |
| 5 | + | { | |
| 6 | + | return array( | |
| 7 | + | 'backups' => array( | |
| 8 | + | 'label' => 'Automatic backups', | |
| 9 | + | 'default_interval_hours' => 24, | |
| 10 | + | 'run' => 'scheduler_run_backups', | |
| 11 | + | ), | |
| 12 | + | 'purge_admin_log' => array( | |
| 13 | + | 'label' => 'Purge old admin log entries', | |
| 14 | + | 'default_interval_hours' => 24, | |
| 15 | + | 'run' => 'scheduler_run_purge_admin_log', | |
| 16 | + | ), | |
| 17 | + | 'health_check' => array( | |
| 18 | + | 'label' => 'Health check', | |
| 19 | + | 'default_interval_hours' => 6, | |
| 20 | + | 'run' => 'scheduler_run_health_check', | |
| 21 | + | ), | |
| 22 | + | ); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | function scheduler_enabled(string $taskId): bool | |
| 26 | + | { | |
| 27 | + | return setting('scheduler:' . $taskId . ':enabled', '0') === '1'; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function scheduler_interval_hours(string $taskId, int $default): int | |
| 31 | + | { | |
| 32 | + | return max(1, (int) setting('scheduler:' . $taskId . ':interval_hours', (string) $default)); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | function scheduler_last_run(string $taskId): int | |
| 36 | + | { | |
| 37 | + | return (int) setting('scheduler:' . $taskId . ':last_run', '0'); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | function scheduler_last_result(string $taskId): string | |
| 41 | + | { | |
| 42 | + | return (string) setting('scheduler:' . $taskId . ':last_result', ''); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | function scheduler_mark_run(string $taskId, string $resultSummary): void | |
| 46 | + | { | |
| 47 | + | setting_set('scheduler:' . $taskId . ':last_run', (string) time()); | |
| 48 | + | setting_set('scheduler:' . $taskId . ':last_result', substr($resultSummary, 0, 255)); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | function scheduler_due(string $taskId, int $defaultIntervalHours): bool | |
| 52 | + | { | |
| 53 | + | if (!scheduler_enabled($taskId)) { | |
| 54 | + | return false; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | $interval = scheduler_interval_hours($taskId, $defaultIntervalHours); | |
| 58 | + | ||
| 59 | + | return (scheduler_last_run($taskId) + ($interval * 3600)) <= time(); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | function scheduler_tick(): void | |
| 63 | + | { | |
| 64 | + | static $ran = false; | |
| 65 | + | if ($ran || !function_exists('setting') || !function_exists('db')) { | |
| 66 | + | return; | |
| 67 | + | } | |
| 68 | + | $ran = true; | |
| 69 | + | ||
| 70 | + | foreach (scheduler_tasks() as $id => $task) { | |
| 71 | + | if (!scheduler_due($id, (int) $task['default_interval_hours'])) { | |
| 72 | + | continue; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | scheduler_mark_run($id, 'running'); | |
| 76 | + | ||
| 77 | + | try { | |
| 78 | + | $summary = (string) call_user_func($task['run']); | |
| 79 | + | } catch (Throwable $e) { | |
| 80 | + | $summary = 'error: ' . $e->getMessage(); | |
| 81 | + | error_log('[scheduler] task ' . $id . ' failed: ' . $e->getMessage()); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | scheduler_mark_run($id, $summary); | |
| 85 | + | ||
| 86 | + | if (function_exists('acp_log')) { | |
| 87 | + | acp_log('scheduler.' . $id, '', array('summary' => $summary)); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | function scheduler_run_backups(): string | |
| 93 | + | { | |
| 94 | + | if (!function_exists('znote_backups_create')) { | |
| 95 | + | return 'backups module unavailable'; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | list($ok, $result) = znote_backups_create(); | |
| 99 | + | if (!$ok) { | |
| 100 | + | return 'backup failed: ' . $result; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | $retention = max(1, (int) setting('config:backups.retention', '10')); | |
| 104 | + | $removed = function_exists('znote_backups_prune') ? znote_backups_prune($retention) : 0; | |
| 105 | + | ||
| 106 | + | return 'created ' . $result . ($removed > 0 ? ', pruned ' . $removed : ''); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | function scheduler_run_purge_admin_log(): string | |
| 110 | + | { | |
| 111 | + | if (!function_exists('znote_table_exists') || !znote_table_exists('znote_admin_log')) { | |
| 112 | + | return 'no admin log table'; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | $days = max(1, (int) setting('scheduler:purge_admin_log:keep_days', '90')); | |
| 116 | + | $cutoff = time() - ($days * 86400); | |
| 117 | + | ||
| 118 | + | db()->execute("DELETE FROM `znote_admin_log` WHERE `created` < ?;", array($cutoff)); | |
| 119 | + | ||
| 120 | + | return 'purged rows older than ' . $days . 'd'; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | function scheduler_run_health_check(): string | |
| 124 | + | { | |
| 125 | + | if (!function_exists('znote_health_issues')) { | |
| 126 | + | return 'health module unavailable'; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | $issues = znote_health_issues(); | |
| 130 | + | if ($issues) { | |
| 131 | + | $details = array(); | |
| 132 | + | foreach ($issues as $issue) { | |
| 133 | + | $details[] = (string) ($issue['label'] ?? '') . ': ' . (string) ($issue['detail'] ?? ''); | |
| 134 | + | } | |
| 135 | + | error_log('[scheduler] health check found ' . count($issues) . ' issue(s): ' . implode('; ', $details)); | |
| 136 | + | ||
| 137 | + | return count($issues) . ' issue(s) found'; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | return 'ok'; | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | if (function_exists('znote_hook_register')) { | |
| 144 | + | znote_hook_register('page.footer', 'scheduler_tick'); | |
| 145 | + | } |
| @@ -0,0 +1,970 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | const ZNOTE_SERVERDATA_DIR = 'engine/XML'; | |
| 4 | + | const ZNOTE_SERVERDATA_MONSTERS = 'engine/XML/monster'; | |
| 5 | + | const ZNOTE_SERVERDATA_MAX_XML = 33554432; | |
| 6 | + | const ZNOTE_SERVERDATA_MAX_LUA = 1048576; | |
| 7 | + | const ZNOTE_SERVERDATA_MAX_ZIP = 33554432; | |
| 8 | + | const ZNOTE_SERVERDATA_MAX_UNZIP = 134217728; | |
| 9 | + | const ZNOTE_SERVERDATA_MAX_FILES = 4000; | |
| 10 | + | ||
| 11 | + | function serverdata_root(): string | |
| 12 | + | { | |
| 13 | + | return dirname(__DIR__, 2); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** "8M" and friends, as php.ini writes them, in bytes. */ | |
| 17 | + | function serverdata_ini_bytes(string $value): int | |
| 18 | + | { | |
| 19 | + | $value = trim($value); | |
| 20 | + | if ($value === '') { | |
| 21 | + | return 0; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | $number = (int)$value; | |
| 25 | + | ||
| 26 | + | switch (strtolower(substr($value, -1))) { | |
| 27 | + | case 'g': return $number * 1073741824; | |
| 28 | + | case 'm': return $number * 1048576; | |
| 29 | + | case 'k': return $number * 1024; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | return $number; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | function serverdata_human_size(int $bytes): string | |
| 36 | + | { | |
| 37 | + | if ($bytes >= 1048576) { | |
| 38 | + | return number_format($bytes / 1048576, 1) . ' MB'; | |
| 39 | + | } | |
| 40 | + | if ($bytes >= 1024) { | |
| 41 | + | return number_format($bytes / 1024, 1) . ' KB'; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | return $bytes . ' B'; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | function serverdata_dir(): string | |
| 48 | + | { | |
| 49 | + | return serverdata_root() . '/' . ZNOTE_SERVERDATA_DIR; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | function serverdata_monster_dir(): string | |
| 53 | + | { | |
| 54 | + | return serverdata_root() . '/' . ZNOTE_SERVERDATA_MONSTERS; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | function serverdata_file(string $name): string | |
| 58 | + | { | |
| 59 | + | return serverdata_dir() . '/' . $name; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | function serverdata_sources(): array | |
| 63 | + | { | |
| 64 | + | return array( | |
| 65 | + | // A TFS config.lua carries the MySQL password, and engine/XML is served | |
| 66 | + | // by the web server, so the file itself is never kept: it is read once | |
| 67 | + | // from the upload and only the whitelisted values survive. | |
| 68 | + | 'config' => array( | |
| 69 | + | 'label' => t_default('acp.src.config.label', 'Server information'), | |
| 70 | + | 'file' => 'config.lua', | |
| 71 | + | 'cache' => 'engine/cache/luaconfig', | |
| 72 | + | 'accept' => '.lua', | |
| 73 | + | 'page' => 'serverinfo.php', | |
| 74 | + | 'store' => false, | |
| 75 | + | 'help' => t_default('acp.src.config.help', 'Your server config.lua. Only the whitelisted settings are kept - the file itself is parsed and discarded, never stored where it could be downloaded.') | |
| 76 | + | ), | |
| 77 | + | 'stages' => array( | |
| 78 | + | 'label' => t_default('acp.src.stages.label', 'Experience stages'), | |
| 79 | + | 'file' => 'stages.xml', | |
| 80 | + | 'cache' => 'engine/cache/stages', | |
| 81 | + | 'accept' => '.xml', | |
| 82 | + | 'page' => 'serverinfo.php', | |
| 83 | + | 'help' => t_default('acp.src.stages.help', 'data/XML/stages.xml. Only used when stages are enabled in it or in config.lua.') | |
| 84 | + | ), | |
| 85 | + | 'items' => array( | |
| 86 | + | 'label' => t_default('acp.src.items.label', 'Items'), | |
| 87 | + | 'file' => 'items.xml', | |
| 88 | + | 'cache' => 'engine/cache/items', | |
| 89 | + | 'accept' => '.xml', | |
| 90 | + | 'page' => 'items.php', | |
| 91 | + | 'help' => t_default('acp.src.items.help', 'data/items/items.xml. Only equipable items (slotType or weaponType) are published.') | |
| 92 | + | ), | |
| 93 | + | 'spells' => array( | |
| 94 | + | 'label' => t_default('acp.src.spells.label', 'Spells'), | |
| 95 | + | 'file' => 'spells.xml', | |
| 96 | + | 'cache' => 'engine/cache/spells', | |
| 97 | + | 'accept' => '.xml', | |
| 98 | + | 'page' => 'spells.php', | |
| 99 | + | 'help' => t_default('acp.src.spells.help', 'data/spells/spells.xml. Monster spells and house spells are filtered out.') | |
| 100 | + | ), | |
| 101 | + | 'creatures' => array( | |
| 102 | + | 'label' => t_default('acp.src.creatures.label', 'Monsters'), | |
| 103 | + | 'file' => 'monster/monsters.xml', | |
| 104 | + | 'cache' => 'engine/cache/creatures', | |
| 105 | + | 'accept' => '.xml,.zip', | |
| 106 | + | 'page' => 'creatures.php', | |
| 107 | + | 'help' => t_default('acp.src.creatures.help', 'monsters.xml only lists names and file paths. Upload a .zip of data/monster/ to get health, experience, speed and race.') | |
| 108 | + | ) | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | function serverdata_cache(string $key): ?Cache | |
| 113 | + | { | |
| 114 | + | $sources = serverdata_sources(); | |
| 115 | + | if (!isset($sources[$key])) { | |
| 116 | + | return null; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | $cache = new Cache($sources[$key]['cache']); | |
| 120 | + | $cache->useMemory(false); | |
| 121 | + | $cache->setExpiration(PHP_INT_MAX); | |
| 122 | + | ||
| 123 | + | return $cache; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | function serverdata_load(string $key) | |
| 127 | + | { | |
| 128 | + | $cache = serverdata_cache($key); | |
| 129 | + | if ($cache === null) { | |
| 130 | + | return false; | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | $loaded = $cache->load(); | |
| 134 | + | $loaded = is_array($loaded) ? $loaded : false; | |
| 135 | + | ||
| 136 | + | if (function_exists('serverdata_apply_overrides')) { | |
| 137 | + | $loaded = serverdata_apply_overrides($key, $loaded); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | return $loaded; | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | function serverdata_store(string $key, array $value): bool | |
| 144 | + | { | |
| 145 | + | $cache = serverdata_cache($key); | |
| 146 | + | if ($cache === null) { | |
| 147 | + | return false; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | $cache->setContent($value); | |
| 151 | + | serverdata_drop_derived($key); | |
| 152 | + | ||
| 153 | + | return $cache->save(); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | function serverdata_forget(string $key): void | |
| 157 | + | { | |
| 158 | + | $sources = serverdata_sources(); | |
| 159 | + | if (!isset($sources[$key])) { | |
| 160 | + | return; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | @unlink(serverdata_root() . '/' . $sources[$key]['cache'] . Cache::EXT); | |
| 164 | + | serverdata_drop_derived($key); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /** | |
| 168 | + | * monster_loot.php builds its own cache out of items.xml and the monster files, | |
| 169 | + | * so it goes stale the moment either of those is written or removed. | |
| 170 | + | */ | |
| 171 | + | function serverdata_drop_derived(string $key): void | |
| 172 | + | { | |
| 173 | + | if ($key === 'items' || $key === 'creatures') { | |
| 174 | + | @unlink(serverdata_root() . '/engine/cache/monster_loot' . Cache::EXT); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | function serverdata_parse_items(string $path) | |
| 179 | + | { | |
| 180 | + | $xml = @simplexml_load_file($path); | |
| 181 | + | if ($xml === false) { | |
| 182 | + | return false; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | $typeAttributes = array(); | |
| 186 | + | $items = array(); | |
| 187 | + | ||
| 188 | + | foreach ($xml as $type => $item) { | |
| 189 | + | if (!isset($typeAttributes[$type])) { | |
| 190 | + | $typeAttributes[$type] = array(); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | $attributes = array(); | |
| 194 | + | foreach ($item->attributes() as $name => $value) { | |
| 195 | + | $attributes["$name"] = "$value"; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | unset($attributes['plural'], $attributes['editorsuffix'], $attributes['article']); | |
| 199 | + | ||
| 200 | + | foreach (array_keys($attributes) as $attribute) { | |
| 201 | + | if (!in_array($attribute, $typeAttributes[$type], true)) { | |
| 202 | + | $typeAttributes[$type][] = $attribute; | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | $keys = array(); | |
| 207 | + | $itemAttributes = array(); | |
| 208 | + | ||
| 209 | + | foreach ($item as $node) { | |
| 210 | + | foreach ($node->attributes() as $name => $value) { | |
| 211 | + | if ($name === 'key') { | |
| 212 | + | $keys[] = "$value"; | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | $current = null; | |
| 218 | + | foreach ($item as $node) { | |
| 219 | + | foreach ($node->attributes() as $name => $value) { | |
| 220 | + | $value = "$value"; | |
| 221 | + | if (in_array($value, $keys, true)) { | |
| 222 | + | $current = $value; | |
| 223 | + | } else if ($current !== null) { | |
| 224 | + | $itemAttributes[$current] = $value; | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | if (!isset($itemAttributes['slotType']) && !isset($itemAttributes['weaponType'])) { | |
| 230 | + | continue; | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | $id = $attributes['id'] ?? ($attributes['name'] ?? null); | |
| 234 | + | if ($id === null) { | |
| 235 | + | continue; | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | $items[$type][$id] = array('attributes' => $itemAttributes); | |
| 239 | + | foreach ($typeAttributes[$type] as $attribute) { | |
| 240 | + | $items[$type][$id][$attribute] = $attributes[$attribute] ?? false; | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | return $items; | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | function serverdata_parse_spells(string $path) | |
| 248 | + | { | |
| 249 | + | $xml = @simplexml_load_file($path); | |
| 250 | + | if ($xml === false) { | |
| 251 | + | return false; | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | $typeAttributes = array(); | |
| 255 | + | $spells = array(); | |
| 256 | + | ||
| 257 | + | foreach ($xml as $type => $spell) { | |
| 258 | + | if (!isset($typeAttributes[$type])) { | |
| 259 | + | $typeAttributes[$type] = array(); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | $attributes = array(); | |
| 263 | + | foreach ($spell->attributes() as $name => $value) { | |
| 264 | + | $attributes["$name"] = "$value"; | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | unset($attributes['script'], $attributes['spellid'], $attributes['function']); | |
| 268 | + | ||
| 269 | + | if (isset($attributes['level'])) { | |
| 270 | + | $attributes['lvl'] = $attributes['level']; | |
| 271 | + | } | |
| 272 | + | if (isset($attributes['magiclevel'])) { | |
| 273 | + | $attributes['maglv'] = $attributes['magiclevel']; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | foreach (array_keys($attributes) as $attribute) { | |
| 277 | + | if (!in_array($attribute, $typeAttributes[$type], true)) { | |
| 278 | + | $typeAttributes[$type][] = $attribute; | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | $vocations = array(); | |
| 283 | + | foreach ($spell->vocation as $vocation) { | |
| 284 | + | foreach ($vocation->attributes() as $name => $value) { | |
| 285 | + | if ("$name" === 'name') { | |
| 286 | + | $id = vocation_name_to_id("$value"); | |
| 287 | + | $vocations[] = ($id !== false) ? $id : "$value"; | |
| 288 | + | } else if ("$name" === 'id') { | |
| 289 | + | $vocations[] = (int)"$value"; | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | $words = $attributes['words'] ?? ''; | |
| 295 | + | $name = $attributes['name'] ?? ''; | |
| 296 | + | ||
| 297 | + | if (substr($words, 0, 3) === '###' || substr($name, 0, 5) === 'House' || $name === '') { | |
| 298 | + | continue; | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | $spells[$type][$name] = array('vocations' => $vocations); | |
| 302 | + | foreach ($typeAttributes[$type] as $attribute) { | |
| 303 | + | $spells[$type][$name][$attribute] = $attributes[$attribute] ?? false; | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | foreach (array_keys($spells) as $type) { | |
| 308 | + | usort($spells[$type], static function (array $a, array $b): int { | |
| 309 | + | if (isset($a['lvl'], $b['lvl'])) { | |
| 310 | + | return (int)$a['lvl'] - (int)$b['lvl']; | |
| 311 | + | } | |
| 312 | + | if (isset($a['maglv'], $b['maglv'])) { | |
| 313 | + | return (int)$a['maglv'] - (int)$b['maglv']; | |
| 314 | + | } | |
| 315 | + | return -1; | |
| 316 | + | }); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | return $spells; | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | function serverdata_parse_stages(string $path) | |
| 323 | + | { | |
| 324 | + | $xml = @simplexml_load_file($path); | |
| 325 | + | if ($xml === false) { | |
| 326 | + | return false; | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | $stages = array(); | |
| 330 | + | foreach ($xml->config->attributes() as $name => $value) { | |
| 331 | + | $stages["$name"] = "$value"; | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | $stages['stages'] = array(); | |
| 335 | + | foreach ($xml->stage as $stage) { | |
| 336 | + | $row = array(); | |
| 337 | + | foreach ($stage->attributes() as $name => $value) { | |
| 338 | + | $row["$name"] = "$value"; | |
| 339 | + | } | |
| 340 | + | $stages['stages'][] = $row; | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | return $stages; | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | function serverdata_config_whitelist(): array | |
| 347 | + | { | |
| 348 | + | return array( | |
| 349 | + | 'worldType', 'hotkeyAimbotEnabled', 'protectionLevel', 'killsToRedSkull', 'killsToBlackSkull', | |
| 350 | + | 'pzLocked', 'removeChargesFromRunes', 'timeToDecreaseFrags', 'whiteSkullTime', | |
| 351 | + | 'stairJumpExhaustion', 'experienceByKillingPlayers', 'expFromPlayersLevelRange', | |
| 352 | + | 'loginProtocolPort', 'maxPlayers', 'motd', 'onePlayerOnlinePerAccount', 'deathLosePercent', | |
| 353 | + | 'housePriceEachSQM', 'houseRentPeriod', 'marketOfferDuration', 'premiumToCreateMarketOffer', | |
| 354 | + | 'maxMarketOffersAtATimePerPlayer', 'allowChangeOutfit', 'freePremium', | |
| 355 | + | 'kickIdlePlayerAfterMinutes', 'rateExp', 'rateSkill', 'rateLoot', 'rateMagic', 'rateSpawn', | |
| 356 | + | 'staminaSystem', 'experienceStages' | |
| 357 | + | ); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | /** | |
| 361 | + | * Arithmetic on a config.lua right-hand side, without eval(). Only digits and | |
| 362 | + | * the four operators are accepted, so nothing from the file can ever run as | |
| 363 | + | * code - the previous implementation eval()'d whatever it found here. | |
| 364 | + | */ | |
| 365 | + | function serverdata_eval_number(string $expression) | |
| 366 | + | { | |
| 367 | + | $expression = trim($expression); | |
| 368 | + | if ($expression === '' || !preg_match('/^[0-9+\-*\/(). ]+$/', $expression)) { | |
| 369 | + | return null; | |
| 370 | + | } | |
| 371 | + | if (preg_match('/^-?\d+$/', $expression)) { | |
| 372 | + | return (int)$expression; | |
| 373 | + | } | |
| 374 | + | if (preg_match('/^-?\d*\.\d+$/', $expression)) { | |
| 375 | + | return (float)$expression; | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | $position = 0; | |
| 379 | + | $chars = str_split(str_replace(' ', '', $expression)); | |
| 380 | + | $length = count($chars); | |
| 381 | + | ||
| 382 | + | $parseExpression = null; | |
| 383 | + | ||
| 384 | + | $parsePrimary = static function () use (&$chars, &$position, $length, &$parseExpression) { | |
| 385 | + | if ($position < $length && $chars[$position] === '(') { | |
| 386 | + | $position++; | |
| 387 | + | $value = $parseExpression(); | |
| 388 | + | if ($position >= $length || $chars[$position] !== ')') { | |
| 389 | + | throw new RuntimeException('Unbalanced brackets.'); | |
| 390 | + | } | |
| 391 | + | $position++; | |
| 392 | + | return $value; | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | $sign = 1; | |
| 396 | + | while ($position < $length && ($chars[$position] === '-' || $chars[$position] === '+')) { | |
| 397 | + | if ($chars[$position] === '-') { | |
| 398 | + | $sign = -$sign; | |
| 399 | + | } | |
| 400 | + | $position++; | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | $number = ''; | |
| 404 | + | while ($position < $length && (ctype_digit($chars[$position]) || $chars[$position] === '.')) { | |
| 405 | + | $number .= $chars[$position++]; | |
| 406 | + | } | |
| 407 | + | if ($number === '' || !is_numeric($number)) { | |
| 408 | + | throw new RuntimeException('Not a number.'); | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | return $sign * (strpos($number, '.') !== false ? (float)$number : (int)$number); | |
| 412 | + | }; | |
| 413 | + | ||
| 414 | + | $parseTerm = static function () use (&$chars, &$position, $length, $parsePrimary) { | |
| 415 | + | $value = $parsePrimary(); | |
| 416 | + | while ($position < $length && ($chars[$position] === '*' || $chars[$position] === '/')) { | |
| 417 | + | $operator = $chars[$position++]; | |
| 418 | + | $right = $parsePrimary(); | |
| 419 | + | if ($operator === '/') { | |
| 420 | + | if ((float)$right === 0.0) { | |
| 421 | + | throw new RuntimeException('Division by zero.'); | |
| 422 | + | } | |
| 423 | + | $value /= $right; | |
| 424 | + | } else { | |
| 425 | + | $value *= $right; | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | return $value; | |
| 429 | + | }; | |
| 430 | + | ||
| 431 | + | $parseExpression = static function () use (&$chars, &$position, $length, $parseTerm) { | |
| 432 | + | $value = $parseTerm(); | |
| 433 | + | while ($position < $length && ($chars[$position] === '+' || $chars[$position] === '-')) { | |
| 434 | + | $operator = $chars[$position++]; | |
| 435 | + | $right = $parseTerm(); | |
| 436 | + | $value = ($operator === '+') ? $value + $right : $value - $right; | |
| 437 | + | } | |
| 438 | + | return $value; | |
| 439 | + | }; | |
| 440 | + | ||
| 441 | + | try { | |
| 442 | + | $value = $parseExpression(); | |
| 443 | + | } catch (Throwable $e) { | |
| 444 | + | return null; | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | return ($position === $length) ? $value : null; | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | function serverdata_parse_config(string $content, ?string &$error = null) | |
| 451 | + | { | |
| 452 | + | $error = null; | |
| 453 | + | ||
| 454 | + | if (trim($content) === '') { | |
| 455 | + | $error = 'The config.lua is empty.'; | |
| 456 | + | return false; | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | $first = strpos($content, '{'); | |
| 460 | + | if ($first !== false) { | |
| 461 | + | $last = strripos($content, '}'); | |
| 462 | + | if ($last === false) { | |
| 463 | + | $error = 'Syntax error in config.lua: an opening { has no matching }.'; | |
| 464 | + | return false; | |
| 465 | + | } | |
| 466 | + | $content = substr($content, 0, $first) . substr($content, $last + 1); | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | $whitelist = array_fill_keys(serverdata_config_whitelist(), true); | |
| 470 | + | $values = array(); | |
| 471 | + | ||
| 472 | + | foreach (preg_split('/\R/', $content) ?: array() as $line) { | |
| 473 | + | if (strpos($line, '=') === false) { | |
| 474 | + | continue; | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | $comment = strpos($line, '--'); | |
| 478 | + | if ($comment !== false) { | |
| 479 | + | $line = substr($line, 0, $comment); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | $line = trim($line); | |
| 483 | + | if ($line === '') { | |
| 484 | + | continue; | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | $parts = explode('=', $line, 2); | |
| 488 | + | if (count($parts) < 2) { | |
| 489 | + | continue; | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | $key = trim($parts[0]); | |
| 493 | + | $raw = trim($parts[1]); | |
| 494 | + | ||
| 495 | + | if (!isset($whitelist[$key])) { | |
| 496 | + | continue; | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | $lower = strtolower($raw); | |
| 500 | + | if ($lower === 'true' || $lower === 'false') { | |
| 501 | + | $values[$key] = ($lower === 'true'); | |
| 502 | + | continue; | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | if (strpos($raw, '"') !== false || strpos($raw, "'") !== false) { | |
| 506 | + | $values[$key] = trim(str_replace(array('"', "'"), '', $raw)); | |
| 507 | + | continue; | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | if (array_key_exists($raw, $values)) { | |
| 511 | + | $values[$key] = $values[$raw]; | |
| 512 | + | continue; | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | $number = serverdata_eval_number($raw); | |
| 516 | + | if ($number !== null) { | |
| 517 | + | $values[$key] = $number; | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | if (!$values) { | |
| 522 | + | $error = 'No recognised settings were found. Is this really a config.lua?'; | |
| 523 | + | return false; | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | return $values; | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | function serverdata_creature_source(): array | |
| 530 | + | { | |
| 531 | + | $local = serverdata_monster_dir(); | |
| 532 | + | if (is_file($local . '/monsters.xml')) { | |
| 533 | + | return array( | |
| 534 | + | 'index' => $local . '/monsters.xml', | |
| 535 | + | 'dir' => $local, | |
| 536 | + | 'label' => ZNOTE_SERVERDATA_MONSTERS | |
| 537 | + | ); | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | $path = rtrim((string)($GLOBALS['config']['server_path'] ?? ''), '/\\'); | |
| 541 | + | if ($path === '') { | |
| 542 | + | $path = 'misc'; | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | return array( | |
| 546 | + | 'index' => $path . '/data/monster/monsters.xml', | |
| 547 | + | 'dir' => $path . '/data/monster', | |
| 548 | + | 'label' => $path . '/data/monster' | |
| 549 | + | ); | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | function serverdata_parse_monsters(string $indexPath, string $dir, ?string &$error = null) | |
| 553 | + | { | |
| 554 | + | $error = null; | |
| 555 | + | ||
| 556 | + | $index = @simplexml_load_file($indexPath); | |
| 557 | + | if ($index === false) { | |
| 558 | + | $error = 'Could not read ' . basename($indexPath) . '.'; | |
| 559 | + | return false; | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | $creatures = array(); | |
| 563 | + | $missing = 0; | |
| 564 | + | ||
| 565 | + | foreach ($index->monster as $entry) { | |
| 566 | + | $file = (string)$entry['file']; | |
| 567 | + | if ($file === '' || strpos($file, '..') !== false) { | |
| 568 | + | continue; | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | $monster = @simplexml_load_file($dir . '/' . $file); | |
| 572 | + | if ($monster === false) { | |
| 573 | + | $missing++; | |
| 574 | + | $name = trim((string)$entry['name']); | |
| 575 | + | if ($name !== '') { | |
| 576 | + | $creatures[] = array( | |
| 577 | + | 'name' => $name, | |
| 578 | + | 'health' => 0, | |
| 579 | + | 'experience' => 0, | |
| 580 | + | 'speed' => 0, | |
| 581 | + | 'race' => '', | |
| 582 | + | 'looktype' => 0 | |
| 583 | + | ); | |
| 584 | + | } | |
| 585 | + | continue; | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | $creatures[] = array( | |
| 589 | + | 'name' => (string)($entry['name'] ?? $monster['name']), | |
| 590 | + | 'health' => isset($monster->health) ? (int)$monster->health['max'] : 0, | |
| 591 | + | 'experience' => (int)$monster['experience'], | |
| 592 | + | 'speed' => (int)$monster['speed'], | |
| 593 | + | 'race' => (string)$monster['race'], | |
| 594 | + | 'looktype' => isset($monster->look) ? (int)$monster->look['type'] : 0 | |
| 595 | + | ); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | if (!$creatures) { | |
| 599 | + | $error = 'The index lists no monsters.'; | |
| 600 | + | return false; | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | usort($creatures, static function (array $a, array $b): int { | |
| 604 | + | return strcasecmp($a['name'], $b['name']); | |
| 605 | + | }); | |
| 606 | + | ||
| 607 | + | if ($missing) { | |
| 608 | + | $error = $missing . ' of ' . count($creatures) . ' monster files were missing, so those rows have no stats. ' | |
| 609 | + | . 'Upload a .zip of data/monster/ to fill them in.'; | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | return $creatures; | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | function serverdata_rebuild(string $key, ?string &$error = null): bool | |
| 616 | + | { | |
| 617 | + | $error = null; | |
| 618 | + | $sources = serverdata_sources(); | |
| 619 | + | ||
| 620 | + | if (!isset($sources[$key])) { | |
| 621 | + | $error = 'Unknown data source.'; | |
| 622 | + | return false; | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | if ($key === 'creatures') { | |
| 626 | + | $source = serverdata_creature_source(); | |
| 627 | + | if (!is_file($source['index'])) { | |
| 628 | + | $error = 'No monsters.xml at ' . $source['label'] . '.'; | |
| 629 | + | return false; | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | $warning = null; | |
| 633 | + | $creatures = serverdata_parse_monsters($source['index'], $source['dir'], $warning); | |
| 634 | + | if ($creatures === false) { | |
| 635 | + | $error = $warning; | |
| 636 | + | return false; | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | if (!serverdata_store($key, $creatures)) { | |
| 640 | + | $error = 'Could not write ' . $sources[$key]['cache'] . Cache::EXT . '.'; | |
| 641 | + | return false; | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | $error = $warning; | |
| 645 | + | return true; | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | if (($sources[$key]['store'] ?? true) === false) { | |
| 649 | + | $error = 'The ' . $sources[$key]['file'] . ' is not kept on disk, so there is nothing to re-read. Upload it again.'; | |
| 650 | + | return false; | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | $path = serverdata_file($sources[$key]['file']); | |
| 654 | + | if (!is_file($path)) { | |
| 655 | + | $error = 'No ' . ZNOTE_SERVERDATA_DIR . '/' . $sources[$key]['file'] . ' to read.'; | |
| 656 | + | return false; | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | $parser = 'serverdata_parse_' . $key; | |
| 660 | + | $parsed = $parser($path); | |
| 661 | + | if ($parsed === false) { | |
| 662 | + | $error = ZNOTE_SERVERDATA_DIR . '/' . $sources[$key]['file'] . ' is not valid XML.'; | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | if ($parsed === false) { | |
| 666 | + | return false; | |
| 667 | + | } | |
| 668 | + | ||
| 669 | + | if (!serverdata_store($key, $parsed)) { | |
| 670 | + | $error = 'Could not write ' . $sources[$key]['cache'] . Cache::EXT . '.'; | |
| 671 | + | return false; | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | return true; | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | function serverdata_count(string $key, $data): int | |
| 678 | + | { | |
| 679 | + | if (!is_array($data)) { | |
| 680 | + | return 0; | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | if ($key === 'creatures' || $key === 'config') { | |
| 684 | + | return count($data); | |
| 685 | + | } | |
| 686 | + | if ($key === 'stages') { | |
| 687 | + | return count($data['stages'] ?? array()); | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | $total = 0; | |
| 691 | + | foreach ($data as $group) { | |
| 692 | + | $total += is_array($group) ? count($group) : 1; | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | return $total; | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | function serverdata_status(): array | |
| 699 | + | { | |
| 700 | + | $status = array(); | |
| 701 | + | ||
| 702 | + | foreach (serverdata_sources() as $key => $source) { | |
| 703 | + | $keeps = ($source['store'] ?? true) !== false; | |
| 704 | + | ||
| 705 | + | if ($key === 'creatures') { | |
| 706 | + | $origin = serverdata_creature_source(); | |
| 707 | + | $path = $origin['index']; | |
| 708 | + | $label = $origin['label'] . '/monsters.xml'; | |
| 709 | + | $extra = count(glob(serverdata_monster_dir() . '/{,*/,*/*/}*.xml', GLOB_BRACE) ?: array()); | |
| 710 | + | } else { | |
| 711 | + | $path = serverdata_file($source['file']); | |
| 712 | + | $label = ZNOTE_SERVERDATA_DIR . '/' . $source['file']; | |
| 713 | + | $extra = 0; | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | $data = serverdata_load($key); | |
| 717 | + | $cache = serverdata_root() . '/' . $source['cache'] . Cache::EXT; | |
| 718 | + | $onDisk = $keeps && is_file($path); | |
| 719 | + | ||
| 720 | + | $status[$key] = $source + array( | |
| 721 | + | 'key' => $key, | |
| 722 | + | 'path' => $path, | |
| 723 | + | 'path_label' => $keeps ? $label : 'parsed on upload, not stored', | |
| 724 | + | 'keeps_file' => $keeps, | |
| 725 | + | 'uploaded' => $keeps ? $onDisk : ($data !== false), | |
| 726 | + | 'upload_date' => $onDisk ? (int)filemtime($path) : 0, | |
| 727 | + | 'upload_size' => $onDisk ? (int)filesize($path) : 0, | |
| 728 | + | 'cached' => ($data !== false), | |
| 729 | + | 'cache_date' => is_file($cache) ? (int)filemtime($cache) : 0, | |
| 730 | + | 'count' => serverdata_count($key, $data), | |
| 731 | + | 'files' => $extra | |
| 732 | + | ); | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | return $status; | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | function serverdata_store_upload(string $key, string $tmpFile, string $originalName, ?string &$error = null): bool | |
| 739 | + | { | |
| 740 | + | $error = null; | |
| 741 | + | $sources = serverdata_sources(); | |
| 742 | + | ||
| 743 | + | if (!isset($sources[$key])) { | |
| 744 | + | $error = 'Unknown data source.'; | |
| 745 | + | return false; | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | $extension = strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION)); | |
| 749 | + | $allowed = array_map(static function (string $e): string { | |
| 750 | + | return ltrim(trim($e), '.'); | |
| 751 | + | }, explode(',', $sources[$key]['accept'])); | |
| 752 | + | ||
| 753 | + | if (!in_array($extension, $allowed, true)) { | |
| 754 | + | $error = 'Expected a ' . implode(' or ', array_map(static function (string $e): string { | |
| 755 | + | return '.' . $e; | |
| 756 | + | }, $allowed)) . ' file.'; | |
| 757 | + | return false; | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | $size = (int)filesize($tmpFile); | |
| 761 | + | if ($size < 1) { | |
| 762 | + | $error = 'The uploaded file is empty.'; | |
| 763 | + | return false; | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | $limit = ($extension === 'lua') ? ZNOTE_SERVERDATA_MAX_LUA | |
| 767 | + | : (($extension === 'zip') ? ZNOTE_SERVERDATA_MAX_ZIP : ZNOTE_SERVERDATA_MAX_XML); | |
| 768 | + | ||
| 769 | + | if ($size > $limit) { | |
| 770 | + | $error = 'That file is larger than the ' . (int)($limit / 1048576) . ' MB limit for .' . $extension . ' uploads.'; | |
| 771 | + | return false; | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | if (($sources[$key]['store'] ?? true) === false) { | |
| 775 | + | $parsed = serverdata_parse_config((string)file_get_contents($tmpFile), $error); | |
| 776 | + | if ($parsed === false) { | |
| 777 | + | return false; | |
| 778 | + | } | |
| 779 | + | if (!serverdata_store($key, $parsed)) { | |
| 780 | + | $error = 'Could not write ' . $sources[$key]['cache'] . Cache::EXT . '.'; | |
| 781 | + | return false; | |
| 782 | + | } | |
| 783 | + | return true; | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | $dir = ($key === 'creatures') ? serverdata_monster_dir() : serverdata_dir(); | |
| 787 | + | if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { | |
| 788 | + | $error = 'Could not create ' . $dir . '.'; | |
| 789 | + | return false; | |
| 790 | + | } | |
| 791 | + | if (!is_writable($dir)) { | |
| 792 | + | $error = str_replace(serverdata_root() . '/', '', $dir) . '/ is not writable by the web server.'; | |
| 793 | + | return false; | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | if ($extension === 'zip') { | |
| 797 | + | return serverdata_extract_monsters($tmpFile, $error); | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | $target = ($key === 'creatures') | |
| 801 | + | ? $dir . '/monsters.xml' | |
| 802 | + | : serverdata_file($sources[$key]['file']); | |
| 803 | + | ||
| 804 | + | if ($extension === 'xml' && @simplexml_load_file($tmpFile) === false) { | |
| 805 | + | $error = 'That file is not valid XML.'; | |
| 806 | + | return false; | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | if (!@copy($tmpFile, $target)) { | |
| 810 | + | $error = 'Could not write ' . str_replace(serverdata_root() . '/', '', $target) . '.'; | |
| 811 | + | return false; | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | return true; | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | /** | |
| 818 | + | * Take an upload and make it live in one step. A source that keeps its file | |
| 819 | + | * still has to be parsed afterwards; one that does not - config.lua - was | |
| 820 | + | * already published by the upload itself, so re-reading it would fail. | |
| 821 | + | * | |
| 822 | + | * $error is a warning, not a failure, when this returns true. | |
| 823 | + | */ | |
| 824 | + | function serverdata_publish_upload(string $key, string $tmpFile, string $originalName, ?string &$error = null): bool | |
| 825 | + | { | |
| 826 | + | if (!serverdata_store_upload($key, $tmpFile, $originalName, $error)) { | |
| 827 | + | return false; | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | $sources = serverdata_sources(); | |
| 831 | + | if (($sources[$key]['store'] ?? true) === false) { | |
| 832 | + | $error = null; | |
| 833 | + | return true; | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | return serverdata_rebuild($key, $error); | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | /** | |
| 840 | + | * Unpack a data/monster/ archive. Only .xml entries are taken, paths are | |
| 841 | + | * rebased on wherever monsters.xml sits inside the zip, and anything trying to | |
| 842 | + | * escape the target folder is dropped. | |
| 843 | + | */ | |
| 844 | + | function serverdata_extract_monsters(string $zipFile, ?string &$error = null): bool | |
| 845 | + | { | |
| 846 | + | $error = null; | |
| 847 | + | ||
| 848 | + | if (!class_exists('ZipArchive')) { | |
| 849 | + | $error = 'PHP needs the zip extension to unpack a monster archive.'; | |
| 850 | + | return false; | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | $zip = new ZipArchive(); | |
| 854 | + | if ($zip->open($zipFile) !== true) { | |
| 855 | + | $error = 'That .zip could not be opened.'; | |
| 856 | + | return false; | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | $index = null; | |
| 860 | + | for ($i = 0; $i < $zip->numFiles; $i++) { | |
| 861 | + | $name = str_replace('\\', '/', (string)$zip->getNameIndex($i)); | |
| 862 | + | if (strtolower(basename($name)) === 'monsters.xml') { | |
| 863 | + | if ($index === null || substr_count($name, '/') < substr_count($index, '/')) { | |
| 864 | + | $index = $name; | |
| 865 | + | } | |
| 866 | + | } | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | if ($index === null) { | |
| 870 | + | $zip->close(); | |
| 871 | + | $error = 'That .zip has no monsters.xml. Zip the contents of your data/monster/ folder.'; | |
| 872 | + | return false; | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | $prefix = (strpos($index, '/') === false) ? '' : substr($index, 0, strrpos($index, '/') + 1); | |
| 876 | + | $dir = serverdata_monster_dir(); | |
| 877 | + | ||
| 878 | + | $total = 0; | |
| 879 | + | $written = 0; | |
| 880 | + | ||
| 881 | + | for ($i = 0; $i < $zip->numFiles; $i++) { | |
| 882 | + | $stat = $zip->statIndex($i); | |
| 883 | + | if (!$stat) { | |
| 884 | + | continue; | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | $name = str_replace('\\', '/', (string)$stat['name']); | |
| 888 | + | if (substr($name, -1) === '/' || strtolower((string)pathinfo($name, PATHINFO_EXTENSION)) !== 'xml') { | |
| 889 | + | continue; | |
| 890 | + | } | |
| 891 | + | if ($prefix !== '' && strpos($name, $prefix) !== 0) { | |
| 892 | + | continue; | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | $relative = ($prefix === '') ? $name : substr($name, strlen($prefix)); | |
| 896 | + | if ($relative === '' || strpos($relative, '..') !== false || $relative[0] === '/') { | |
| 897 | + | continue; | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | $total += (int)$stat['size']; | |
| 901 | + | if ($total > ZNOTE_SERVERDATA_MAX_UNZIP) { | |
| 902 | + | $zip->close(); | |
| 903 | + | $error = 'That archive unpacks to more than ' . (int)(ZNOTE_SERVERDATA_MAX_UNZIP / 1048576) . ' MB.'; | |
| 904 | + | return false; | |
| 905 | + | } | |
| 906 | + | if (++$written > ZNOTE_SERVERDATA_MAX_FILES) { | |
| 907 | + | $zip->close(); | |
| 908 | + | $error = 'That archive holds more than ' . ZNOTE_SERVERDATA_MAX_FILES . ' XML files.'; | |
| 909 | + | return false; | |
| 910 | + | } | |
| 911 | + | ||
| 912 | + | $target = $dir . '/' . $relative; | |
| 913 | + | $parent = dirname($target); | |
| 914 | + | ||
| 915 | + | if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) { | |
| 916 | + | continue; | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | $contents = $zip->getFromIndex($i); | |
| 920 | + | if ($contents === false) { | |
| 921 | + | continue; | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | @file_put_contents($target, $contents); | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | $zip->close(); | |
| 928 | + | ||
| 929 | + | if (!$written) { | |
| 930 | + | $error = 'No XML files were extracted from that archive.'; | |
| 931 | + | return false; | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | return true; | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | function serverdata_clear(string $key): void | |
| 938 | + | { | |
| 939 | + | $sources = serverdata_sources(); | |
| 940 | + | if (!isset($sources[$key])) { | |
| 941 | + | return; | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | serverdata_forget($key); | |
| 945 | + | ||
| 946 | + | if ($key === 'creatures') { | |
| 947 | + | serverdata_rmdir(serverdata_monster_dir()); | |
| 948 | + | return; | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | @unlink(serverdata_file($sources[$key]['file'])); | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | function serverdata_rmdir(string $dir): void | |
| 955 | + | { | |
| 956 | + | if (!is_dir($dir)) { | |
| 957 | + | return; | |
| 958 | + | } | |
| 959 | + | ||
| 960 | + | $items = new RecursiveIteratorIterator( | |
| 961 | + | new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), | |
| 962 | + | RecursiveIteratorIterator::CHILD_FIRST | |
| 963 | + | ); | |
| 964 | + | ||
| 965 | + | foreach ($items as $item) { | |
| 966 | + | $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | @rmdir($dir); | |
| 970 | + | } |
| @@ -0,0 +1,180 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Single-record edits layered on top of the bulk-uploaded server data handled | |
| 5 | + | * by serverdata.php (config.lua / items.xml / monster files). Overrides live | |
| 6 | + | * in their own table instead of being written into the parsed cache blob, so | |
| 7 | + | * re-uploading a source file (serverdata_rebuild()/serverdata_store_upload()) | |
| 8 | + | * never discards a manual correction - serverdata_apply_overrides() merges | |
| 9 | + | * this table over the parsed array every time serverdata_load() is called. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | function serverdata_override_sources(): array | |
| 13 | + | { | |
| 14 | + | return array('config', 'items', 'creatures'); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | function serverdata_override_table_exists(): bool | |
| 18 | + | { | |
| 19 | + | return znote_table_exists('znote_serverdata_overrides'); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** record_key => ['data' => array, 'deleted' => bool] for one source. */ | |
| 23 | + | function serverdata_override_all(string $source): array | |
| 24 | + | { | |
| 25 | + | if (!serverdata_override_table_exists()) { | |
| 26 | + | return array(); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | $rows = db()->fetchAll( | |
| 30 | + | "SELECT `record_key`, `data`, `deleted` FROM `znote_serverdata_overrides` WHERE `source` = ? ORDER BY `record_key` ASC;", | |
| 31 | + | array($source) | |
| 32 | + | ); | |
| 33 | + | ||
| 34 | + | $out = array(); | |
| 35 | + | if (is_array($rows)) { | |
| 36 | + | foreach ($rows as $row) { | |
| 37 | + | $decoded = json_decode((string)($row['data'] ?? ''), true); | |
| 38 | + | $out[(string)$row['record_key']] = array( | |
| 39 | + | 'data' => is_array($decoded) ? $decoded : array(), | |
| 40 | + | 'deleted' => !empty($row['deleted']), | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | return $out; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function serverdata_override_get(string $source, string $key) | |
| 49 | + | { | |
| 50 | + | $all = serverdata_override_all($source); | |
| 51 | + | ||
| 52 | + | return $all[$key] ?? null; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** Edit an existing record or add a brand-new one. Also un-deletes a previously removed key. */ | |
| 56 | + | function serverdata_override_set(string $source, string $key, array $data, string $updatedBy = ''): bool | |
| 57 | + | { | |
| 58 | + | if (!serverdata_override_table_exists() || !in_array($source, serverdata_override_sources(), true) || $key === '') { | |
| 59 | + | return false; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | $json = (string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); | |
| 63 | + | ||
| 64 | + | return db()->execute(" | |
| 65 | + | INSERT INTO `znote_serverdata_overrides` (`source`, `record_key`, `data`, `deleted`, `updated_by`, `updated_at`) | |
| 66 | + | VALUES (?, ?, ?, 0, ?, ?) | |
| 67 | + | ON DUPLICATE KEY UPDATE `data` = VALUES(`data`), `deleted` = 0, `updated_by` = VALUES(`updated_by`), `updated_at` = VALUES(`updated_at`); | |
| 68 | + | ", array($source, $key, $json, $updatedBy, time())); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** | |
| 72 | + | * Tombstones a record instead of deleting the row, so it keeps winning over | |
| 73 | + | * whatever the base cache still has for that key (e.g. it came from the | |
| 74 | + | * uploaded XML too). serverdata_override_set() on the same key clears the | |
| 75 | + | * tombstone again. | |
| 76 | + | */ | |
| 77 | + | function serverdata_override_delete(string $source, string $key, string $updatedBy = ''): bool | |
| 78 | + | { | |
| 79 | + | if (!serverdata_override_table_exists() || $key === '') { | |
| 80 | + | return false; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | return db()->execute(" | |
| 84 | + | INSERT INTO `znote_serverdata_overrides` (`source`, `record_key`, `data`, `deleted`, `updated_by`, `updated_at`) | |
| 85 | + | VALUES (?, ?, '{}', 1, ?, ?) | |
| 86 | + | ON DUPLICATE KEY UPDATE `deleted` = 1, `updated_by` = VALUES(`updated_by`), `updated_at` = VALUES(`updated_at`); | |
| 87 | + | ", array($source, $key, $updatedBy, time())); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | function serverdata_apply_overrides_config(array $baseData, array $overrides): array | |
| 91 | + | { | |
| 92 | + | foreach ($overrides as $key => $row) { | |
| 93 | + | if ($row['deleted'] || !array_key_exists('value', $row['data'])) { | |
| 94 | + | continue; | |
| 95 | + | } | |
| 96 | + | $baseData[$key] = $row['data']['value']; | |
| 97 | + | } | |
| 98 | + | return $baseData; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | function serverdata_apply_overrides_creatures(array $baseData, array $overrides): array | |
| 102 | + | { | |
| 103 | + | $byName = array(); | |
| 104 | + | foreach ($baseData as $i => $row) { | |
| 105 | + | if (isset($row['name'])) { | |
| 106 | + | $byName[$row['name']] = $i; | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | foreach ($overrides as $key => $row) { | |
| 111 | + | if ($row['deleted']) { | |
| 112 | + | if (isset($byName[$key])) { | |
| 113 | + | unset($baseData[$byName[$key]]); | |
| 114 | + | } | |
| 115 | + | continue; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | $record = $row['data']; | |
| 119 | + | $record['name'] = $key; | |
| 120 | + | ||
| 121 | + | if (isset($byName[$key])) { | |
| 122 | + | $baseData[$byName[$key]] = $record; | |
| 123 | + | } else { | |
| 124 | + | $baseData[] = $record; | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | $baseData = array_values($baseData); | |
| 129 | + | usort($baseData, static function (array $a, array $b): int { | |
| 130 | + | return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? '')); | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | return $baseData; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | function serverdata_apply_overrides_items(array $baseData, array $overrides): array | |
| 137 | + | { | |
| 138 | + | foreach ($overrides as $key => $row) { | |
| 139 | + | $parts = explode(':', $key, 2); | |
| 140 | + | if (count($parts) !== 2) { | |
| 141 | + | continue; | |
| 142 | + | } | |
| 143 | + | [$type, $id] = $parts; | |
| 144 | + | ||
| 145 | + | if ($row['deleted']) { | |
| 146 | + | unset($baseData[$type][$id]); | |
| 147 | + | continue; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | $baseData[$type][$id] = $row['data']; | |
| 151 | + | } | |
| 152 | + | return $baseData; | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /** | |
| 156 | + | * Merges the overrides table over a parsed serverdata array. Called from | |
| 157 | + | * serverdata_load() so every existing reader (items.php, creatures.php, the | |
| 158 | + | * public serverinfo.php, monster_loot.php's derived cache, ...) sees edits | |
| 159 | + | * for free. | |
| 160 | + | */ | |
| 161 | + | function serverdata_apply_overrides(string $source, $baseData) | |
| 162 | + | { | |
| 163 | + | if (!in_array($source, serverdata_override_sources(), true)) { | |
| 164 | + | return $baseData; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | $overrides = serverdata_override_all($source); | |
| 168 | + | if (!$overrides) { | |
| 169 | + | return $baseData; | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | $baseData = is_array($baseData) ? $baseData : array(); | |
| 173 | + | ||
| 174 | + | switch ($source) { | |
| 175 | + | case 'config': return serverdata_apply_overrides_config($baseData, $overrides); | |
| 176 | + | case 'creatures': return serverdata_apply_overrides_creatures($baseData, $overrides); | |
| 177 | + | case 'items': return serverdata_apply_overrides_items($baseData, $overrides); | |
| 178 | + | default: return $baseData; | |
| 179 | + | } | |
| 180 | + | } |
| @@ -0,0 +1,223 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Settings stored in the database (znote_config), so the admin panel can | |
| 4 | + | * change them without config.php having to be writable. | |
| 5 | + | * | |
| 6 | + | * config.php stays the place for things an admin edits by hand (database | |
| 7 | + | * credentials, server engine, vocations). This is for things the panel writes. | |
| 8 | + | * | |
| 9 | + | * The whole table is read once per request and kept in memory - it is a | |
| 10 | + | * handful of short rows, so one query covers every lookup on the page. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * Does this table exist? | |
| 15 | + | * | |
| 16 | + | * SHOW TABLES is the only honest answer. Selecting a row and treating an empty | |
| 17 | + | * result as "missing" gets it wrong on every fresh install, where the table is | |
| 18 | + | * there and simply has nothing in it yet. | |
| 19 | + | */ | |
| 20 | + | function znote_table_exists(string $table): bool { | |
| 21 | + | static $known = array(); | |
| 22 | + | ||
| 23 | + | if (isset($known[$table])) { | |
| 24 | + | return $known[$table]; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | $db = db(); | |
| 28 | + | $escaped = $db->connection()->real_escape_string($table); | |
| 29 | + | return $known[$table] = ($db->rawFetchAll("SHOW TABLES LIKE '{$escaped}';") !== false); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | function znote_column_exists(string $table, string $column): bool { | |
| 33 | + | static $known = array(); | |
| 34 | + | ||
| 35 | + | $cacheKey = $table . '.' . $column; | |
| 36 | + | ||
| 37 | + | if (isset($known[$cacheKey])) { | |
| 38 | + | return $known[$cacheKey]; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | if (!znote_table_exists($table)) { | |
| 42 | + | return $known[$cacheKey] = false; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) { | |
| 46 | + | return $known[$cacheKey] = false; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | $escaped = db()->connection()->real_escape_string($column); | |
| 50 | + | return $known[$cacheKey] = (db()->rawFetchOne("SHOW COLUMNS FROM `{$table}` LIKE '{$escaped}';") !== false); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | function znote_settings_all(bool $refresh = false): array { | |
| 54 | + | static $settings = null; | |
| 55 | + | ||
| 56 | + | if ($settings !== null && !$refresh) { | |
| 57 | + | return $settings; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | $settings = array(); | |
| 61 | + | ||
| 62 | + | $rows = db()->fetchAll("SELECT `key`, `value` FROM `znote_config`;"); | |
| 63 | + | if (is_array($rows)) { | |
| 64 | + | foreach ($rows as $row) { | |
| 65 | + | $settings[(string)$row['key']] = (string)$row['value']; | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | // A false result means the table is missing (migration not run yet). | |
| 69 | + | // Callers fall back to their defaults, so the site keeps working. | |
| 70 | + | ||
| 71 | + | return $settings; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | function setting(string $key, ?string $default = null): ?string { | |
| 75 | + | $settings = znote_settings_all(); | |
| 76 | + | return array_key_exists($key, $settings) ? $settings[$key] : $default; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | function setting_set(string $key, string $value): bool { | |
| 80 | + | $ok = db()->execute(" | |
| 81 | + | INSERT INTO `znote_config` (`key`, `value`) | |
| 82 | + | VALUES (?, ?) | |
| 83 | + | ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); | |
| 84 | + | ", [$key, $value]); | |
| 85 | + | ||
| 86 | + | if ($ok !== false) { | |
| 87 | + | znote_settings_all(true); // drop the in-memory copy | |
| 88 | + | return true; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | return false; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** | |
| 95 | + | * Apply the settings saved from the admin panel over $config. | |
| 96 | + | * | |
| 97 | + | * config.php keeps every default; anything an admin has changed in | |
| 98 | + | * Admin Panel > Settings is stored as "config:<key>" and wins here. That way | |
| 99 | + | * updating config.php never loses a setting, and a key nobody has touched in | |
| 100 | + | * the panel keeps following the file. | |
| 101 | + | * | |
| 102 | + | * Booleans and integers are cast back, because znote_config stores strings. | |
| 103 | + | */ | |
| 104 | + | function znote_apply_settings(): void { | |
| 105 | + | global $config; | |
| 106 | + | ||
| 107 | + | $settings = znote_settings_all(); | |
| 108 | + | if (!$settings) { | |
| 109 | + | return; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | foreach ($settings as $key => $value) { | |
| 113 | + | if (strpos($key, 'config:') !== 0) { | |
| 114 | + | continue; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | $name = substr($key, 7); | |
| 118 | + | if ($name === '') { | |
| 119 | + | continue; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | znote_setting_apply($config, explode('.', $name), $value); | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /** | |
| 127 | + | * Write one value into $config at $path, keeping the type config.php declared. | |
| 128 | + | * | |
| 129 | + | * Nested paths are stored dotted - "config:shop.enabled" reaches | |
| 130 | + | * $config['shop']['enabled'] - because most of what the panel edits lives one | |
| 131 | + | * or two levels down. A path is only followed while it already exists, so a | |
| 132 | + | * stale row from a removed setting can never invent a key. | |
| 133 | + | */ | |
| 134 | + | function znote_setting_apply(array &$config, array $path, string $value): void { | |
| 135 | + | $leaf = array_pop($path); | |
| 136 | + | $node = &$config; | |
| 137 | + | ||
| 138 | + | foreach ($path as $step) { | |
| 139 | + | if (!is_array($node) || !array_key_exists($step, $node) || !is_array($node[$step])) { | |
| 140 | + | return; | |
| 141 | + | } | |
| 142 | + | $node = &$node[$step]; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | if (!is_array($node) || !array_key_exists($leaf, $node)) { | |
| 146 | + | return; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | if (is_array($node[$leaf])) { | |
| 150 | + | // Whole lists - the shop price tiers, for instance - are stored as JSON, | |
| 151 | + | // since one znote_config row holds a string. A malformed row is ignored | |
| 152 | + | // so config.php keeps providing the list. | |
| 153 | + | $decoded = json_decode($value, true); | |
| 154 | + | if (is_array($decoded)) { | |
| 155 | + | $node[$leaf] = $decoded; | |
| 156 | + | } | |
| 157 | + | return; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | if (is_bool($node[$leaf])) { | |
| 161 | + | $node[$leaf] = ($value !== '' && $value !== '0'); | |
| 162 | + | } elseif (is_int($node[$leaf])) { | |
| 163 | + | $node[$leaf] = (int)$value; | |
| 164 | + | } elseif (is_float($node[$leaf])) { | |
| 165 | + | $node[$leaf] = (float)$value; | |
| 166 | + | } else { | |
| 167 | + | $node[$leaf] = $value; | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /** Read one dotted path out of $config, or $default when it is not there. */ | |
| 172 | + | function znote_config_path(array $config, string $path, $default = null) { | |
| 173 | + | $node = $config; | |
| 174 | + | ||
| 175 | + | foreach (explode('.', $path) as $step) { | |
| 176 | + | if (!is_array($node) || !array_key_exists($step, $node)) { | |
| 177 | + | return $default; | |
| 178 | + | } | |
| 179 | + | $node = $node[$step]; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | return $node; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /** | |
| 186 | + | * Players-online record. | |
| 187 | + | * | |
| 188 | + | * Kept in znote_config rather than a table of its own: it is two integers, and | |
| 189 | + | * znote_config already exists for exactly this kind of thing. | |
| 190 | + | * | |
| 191 | + | * znote_record_update() is called from the pages that already know the current | |
| 192 | + | * online count, so this costs no extra query on a normal page load. | |
| 193 | + | */ | |
| 194 | + | function znote_record_get(): array { | |
| 195 | + | return array( | |
| 196 | + | 'players' => (int)setting('record:players', '0'), | |
| 197 | + | 'time' => (int)setting('record:time', '0'), | |
| 198 | + | ); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /** | |
| 202 | + | * Store $online as the new record if it beats the stored one. | |
| 203 | + | * Returns true when a new record was set. | |
| 204 | + | */ | |
| 205 | + | function znote_record_update(int $online): bool { | |
| 206 | + | if ($online <= 0) { | |
| 207 | + | return false; | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | $record = znote_record_get(); | |
| 211 | + | if ($online <= $record['players']) { | |
| 212 | + | return false; | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | setting_set('record:players', (string)$online); | |
| 216 | + | setting_set('record:time', (string)time()); | |
| 217 | + | ||
| 218 | + | if (function_exists('znote_hook')) { | |
| 219 | + | znote_hook('server.online_record', array('players' => $online)); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | return true; | |
| 223 | + | } |