Initial commit

ZnoteX / Commit #5

Commit Initial commit

Alex Alex committed 01/10/2026 09:20 main Full upload
481 files +128,311 -0
A engine/function/plugin_settings.php +185-0 View file
@@ -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+}
A engine/function/rfc6238.php +287-0 View file
@@ -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+?>
A engine/function/scheduler.php +145-0 View file
@@ -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+}
A engine/function/serverdata_overrides.php +180-0 View file
@@ -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+}
A engine/function/settings.php +223-0 View file
@@ -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+}
Top