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/loginwebservice.php +440-0 View file
@@ -0,0 +1,440 @@
1+<?php
2+
3+function lws_is_request(): bool {
4+ if (!config('login_web_service') || config('ServerEngine') !== 'TFS_10') {
5+ return false;
6+ }
7+
8+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
9+ return false;
10+ }
11+
12+ $body = file_get_contents('php://input');
13+ if ($body === false || $body === '') {
14+ return false;
15+ }
16+
17+ $decoded = json_decode($body);
18+ if (!is_object($decoded) || !isset($decoded->type)) {
19+ return false;
20+ }
21+
22+ $GLOBALS['lws_request'] = $decoded;
23+ return true;
24+}
25+
26+function lws_field($value, bool $raw = false) {
27+ if (!is_scalar($value)) {
28+ return false;
29+ }
30+
31+ return $raw ? (string)$value : sanitize((string)$value);
32+}
33+
34+function lws_send($message): void {
35+ die(json_encode($message));
36+}
37+
38+function lws_error(string $message, int $code = 3): void {
39+ die(json_encode(array('errorCode' => $code, 'errorMessage' => $message)));
40+}
41+
42+function lws_tier(): int {
43+ $forced = strtolower(trim((string)(config('login_protocol') ?? 'auto')));
44+
45+ if (in_array($forced, array('11', '12', '13', '15'), true)) {
46+ return (int)$forced;
47+ }
48+
49+ $client = (int)(config('client') ?? 0);
50+
51+ if ($client >= 1500) return 15;
52+ if ($client >= 1300) return 13;
53+ if ($client >= 1200) return 12;
54+
55+ return engineIsCanary() ? 12 : 11;
56+}
57+
58+function lws_lua(string $key, $fallback = null) {
59+ static $lua = null;
60+
61+ if ($lua === null) {
62+ $loaded = function_exists('serverdata_load') ? serverdata_load('config') : false;
63+ $lua = is_array($loaded) ? $loaded : array();
64+ }
65+
66+ return $lua[$key] ?? $fallback;
67+}
68+
69+function lws_gameserver(): array {
70+ $gameserver = config('gameserver');
71+ if (!is_array($gameserver)) {
72+ $gameserver = array();
73+ }
74+
75+ $gameserver += array('ip' => '127.0.0.1', 'port' => 7172, 'name' => 'ZnoteX');
76+
77+ $rows = db()->fetchAll("
78+ SELECT `key`, `value`
79+ FROM `znote_global_storage`
80+ WHERE `key` IN('SERVER_NAME', 'IP', 'GAME_PORT')
81+ ");
82+
83+ if ($rows !== false) {
84+ foreach ($rows as $row) {
85+ switch ($row['key']) {
86+ case 'SERVER_NAME': $gameserver['name'] = $row['value']; break;
87+ case 'IP': $gameserver['ip'] = $row['value']; break;
88+ case 'GAME_PORT': $gameserver['port'] = (int)$row['value']; break;
89+ }
90+ }
91+ }
92+
93+ return $gameserver;
94+}
95+
96+function lws_pvptype(): int {
97+ $worldType = strtolower(trim((string)lws_lua('worldType', 'pvp')));
98+
99+ switch ($worldType) {
100+ case 'no-pvp': return 1;
101+ case 'pvp-enforced': return 2;
102+ case 'retro-pvp': return 3;
103+ case 'expert-pvp': return 0;
104+ case 'pvp': return engineIsCanary() ? 3 : 0;
105+ }
106+
107+ return 0;
108+}
109+
110+function lws_boosted(int $tier): array {
111+ $creature = znote_table_exists('boosted_creature')
112+ ? db()->fetchOne("SELECT `raceid` FROM `boosted_creature` LIMIT 1;")
113+ : false;
114+
115+ $creatureRace = ($creature !== false) ? (int)$creature['raceid'] : 0;
116+
117+ if ($tier < 12) {
118+ return array('raceid' => $creatureRace);
119+ }
120+
121+ $boss = znote_table_exists('boosted_boss')
122+ ? db()->fetchOne("SELECT `raceid` FROM `boosted_boss` LIMIT 1;")
123+ : false;
124+
125+ return array(
126+ 'boostedcreature' => ($creatureRace > 0),
127+ 'creatureraceid' => $creatureRace,
128+ 'bossraceid' => ($boss !== false) ? (int)$boss['raceid'] : 0,
129+ 'raceid' => $creatureRace
130+ );
131+}
132+
133+function lws_player_columns(int $tier): array {
134+ $columns = array(
135+ 'name', 'sex', 'level', 'vocation', 'lookbody', 'looktype',
136+ 'lookhead', 'looklegs', 'lookfeet', 'lookaddons', 'deletion'
137+ );
138+
139+ if ($tier >= 12) {
140+ foreach (array('isreward', 'istutorial') as $optional) {
141+ if (znote_column_exists('players', $optional)) {
142+ $columns[] = $optional;
143+ }
144+ }
145+ }
146+
147+ return $columns;
148+}
149+
150+function lws_character(array $player, int $tier): array {
151+ $character = array(
152+ 'worldid' => 0,
153+ 'name' => $player['name'],
154+ 'ismale' => ((int)$player['sex'] === 1),
155+ 'tutorial' => false,
156+ 'level' => (int)$player['level'],
157+ 'vocation' => vocation_id_to_name($player['vocation']),
158+ 'outfitid' => (int)$player['looktype'],
159+ 'headcolor' => (int)$player['lookhead'],
160+ 'torsocolor' => (int)$player['lookbody'],
161+ 'legscolor' => (int)$player['looklegs'],
162+ 'detailcolor' => (int)$player['lookfeet'],
163+ 'addonsflags' => (int)$player['lookaddons'],
164+ 'ishidden' => ((int)$player['deletion'] === 1),
165+ 'istournamentparticipant' => false,
166+ 'remainingdailytournamentplaytime' => 0
167+ );
168+
169+ if ($tier >= 12) {
170+ $character['tutorial'] = isset($player['istutorial']) && (int)$player['istutorial'] === 1;
171+ $character['dailyrewardstate'] = isset($player['isreward']) ? (int)$player['isreward'] : 0;
172+ $character['ismaincharacter'] = false;
173+ }
174+
175+ return $character;
176+}
177+
178+function lws_session_key(string $descriptor, string $password, $token, bool $hasSecret): string {
179+ $key = $descriptor . "\n" . $password;
180+
181+ if (engineIsCanary()) {
182+ return $key;
183+ }
184+
185+ $key .= ($hasSecret && $token !== false) ? "\n" . $token : "\n";
186+ $key .= "\n" . floor(time() / 30);
187+
188+ return $key;
189+}
190+
191+function lws_register_session(int $accountId, string $sessionKey): void {
192+ if (strtolower(trim((string)(config('login_auth_type') ?? 'password'))) !== 'session') {
193+ return;
194+ }
195+
196+ if (!znote_table_exists('account_sessions')) {
197+ return;
198+ }
199+
200+ $ttl = (int)(config('login_session_ttl') ?? 86400);
201+ if ($ttl < 60) {
202+ $ttl = 86400;
203+ }
204+
205+ $id = hash('sha256', $sessionKey);
206+ $now = time();
207+ $expires = $now + $ttl;
208+
209+ db()->execute("
210+ INSERT INTO `account_sessions` (`id`, `account_id`, `expires`)
211+ VALUES (?, ?, ?)
212+ ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`), `expires` = VALUES(`expires`);
213+ ", [$id, $accountId, $expires]);
214+
215+ db()->execute("DELETE FROM `account_sessions` WHERE `expires` < ?;", [$now]);
216+}
217+
218+function lws_premium(array $account): array {
219+ $free = (bool)(config('freePremium') ?? lws_lua('freePremium', false));
220+ $ends = (int)($account['premium_ends_at'] ?? 0);
221+
222+ $cap = time() + (365 * 86400);
223+ if ($ends > $cap) {
224+ $ends = $cap;
225+ }
226+
227+ return array(
228+ 'ispremium' => ($free || $ends > time()),
229+ 'premiumuntil' => max(0, $ends)
230+ );
231+}
232+
233+function lws_handle_login($client, int $tier): void {
234+ $email = isset($client->email) ? lws_field($client->email) : false;
235+ $username = isset($client->accountname) ? lws_field($client->accountname) : false;
236+ $token = isset($client->token) ? lws_field($client->token) : false;
237+ $plain = isset($client->password) ? lws_field($client->password, true) : '';
238+
239+ if ($plain === false) {
240+ lws_error('Wrong username and/or password.');
241+ }
242+
243+ $password = SHA1($plain);
244+
245+ $fieldList = array('id', 'premium_ends_at');
246+ if (config('twoFactorAuthenticator')) {
247+ $fieldList[] = 'secret';
248+ }
249+ $fields = accountFieldList($fieldList);
250+
251+ $account = false;
252+
253+ if ($email !== false) {
254+ $fields .= ', `name`';
255+ $account = db()->fetchOne("SELECT {$fields} FROM `accounts` WHERE `email` = ? AND `password` = ? LIMIT 1;", [$email, $password]);
256+ if ($account !== false) {
257+ $username = $account['name'];
258+ }
259+ } elseif ($username !== false) {
260+ $account = db()->fetchOne("SELECT {$fields} FROM `accounts` WHERE `name` = ? AND `password` = ? LIMIT 1;", [$username, $password]);
261+ }
262+
263+ if ($account === false) {
264+ lws_error('Wrong username and/or password.');
265+ }
266+
267+ $hasSecret = isset($account['secret']) && $account['secret'] !== null && strlen((string)$account['secret']) > 5;
268+
269+ if (config('twoFactorAuthenticator') === true && $hasSecret) {
270+ if ($token === false) {
271+ lws_error('Submit a valid two-factor authentication token.', 6);
272+ }
273+
274+ require_once(__DIR__ . '/rfc6238.php');
275+ if (TokenAuth6238::verify($account['secret'], $token) !== true) {
276+ lws_error('Two-factor authentication failed, token is wrong.', 6);
277+ }
278+ }
279+
280+ $columns = '`' . implode('`, `', lws_player_columns($tier)) . '`';
281+ $players = db()->fetchAll("SELECT {$columns} FROM `players` WHERE `account_id` = ? AND `deletion` = 0;", [(int)$account['id']]);
282+
283+ if ($players === false) {
284+ lws_error('Character list is empty.');
285+ }
286+
287+ $gameserver = lws_gameserver();
288+ $descriptor = ($email !== false) ? $email : $username;
289+ $sessionKey = lws_session_key((string)$descriptor, $plain, $token, $hasSecret);
290+
291+ lws_register_session((int)$account['id'], $sessionKey);
292+
293+ $premium = lws_premium($account);
294+ $port = (int)$gameserver['port'];
295+
296+ $response = array(
297+ 'session' => array(
298+ 'fpstracking' => false,
299+ 'optiontracking' => false,
300+ 'isreturner' => true,
301+ 'returnernotification' => false,
302+ 'showrewardnews' => true,
303+ 'tournamentticketpurchasestate' => 0,
304+ 'emailcoderequest' => false,
305+ 'sessionkey' => $sessionKey,
306+ 'lastlogintime' => 0,
307+ 'ispremium' => $premium['ispremium'],
308+ 'premiumuntil' => $premium['premiumuntil'],
309+ 'status' => 'active'
310+ ),
311+ 'playdata' => array(
312+ 'worlds' => array(
313+ array(
314+ 'id' => 0,
315+ 'name' => $gameserver['name'],
316+ 'externaladdress' => $gameserver['ip'],
317+ 'externalport' => $port,
318+ 'previewstate' => 0,
319+ 'location' => 'ALL',
320+ 'pvptype' => lws_pvptype(),
321+ 'externaladdressunprotected' => $gameserver['ip'],
322+ 'externaladdressprotected' => $gameserver['ip'],
323+ 'externalportunprotected' => $port,
324+ 'externalportprotected' => $port,
325+ 'istournamentworld' => false,
326+ 'restrictedstore' => false,
327+ 'currenttournamentphase' => 2,
328+ 'anticheatprotection' => false
329+ )
330+ ),
331+ 'characters' => array()
332+ )
333+ );
334+
335+ foreach ($players as $player) {
336+ $response['playdata']['characters'][] = lws_character($player, $tier);
337+ }
338+
339+ if ($tier >= 12 && !empty($response['playdata']['characters'])) {
340+ $response['playdata']['characters'][0]['ismaincharacter'] = true;
341+ }
342+
343+ lws_send($response);
344+}
345+
346+function lws_handle_eventschedule(): void {
347+ $path = rtrim((string)config('server_path'), '/\\') . '/data/XML/events.xml';
348+
349+ if (!is_file($path)) {
350+ lws_send(array('eventlist' => array(), 'lastupdatetimestamp' => time()));
351+ }
352+
353+ $xml = new DOMDocument;
354+ if (@$xml->load($path) === false) {
355+ lws_send(array('eventlist' => array(), 'lastupdatetimestamp' => time()));
356+ }
357+
358+ $attr = function ($nodes, string $name) {
359+ foreach ($nodes as $node) {
360+ return $node->getAttribute($name);
361+ }
362+ return '';
363+ };
364+
365+ $stamp = function (string $date): int {
366+ $parsed = date_create($date);
367+ return ($parsed === false) ? 0 : (int)$parsed->format('U');
368+ };
369+
370+ $eventlist = array();
371+
372+ foreach ($xml->getElementsByTagName('event') as $event) {
373+ $eventlist[] = array(
374+ 'colorlight' => $attr($event->getElementsByTagName('colors'), 'colorlight'),
375+ 'colordark' => $attr($event->getElementsByTagName('colors'), 'colordark'),
376+ 'description' => $attr($event->getElementsByTagName('description'), 'description'),
377+ 'displaypriority' => (int)$attr($event->getElementsByTagName('details'), 'displaypriority'),
378+ 'enddate' => $stamp($event->getAttribute('enddate')),
379+ 'isseasonal' => ((int)$attr($event->getElementsByTagName('details'), 'isseasonal') === 1),
380+ 'name' => $event->getAttribute('name'),
381+ 'startdate' => $stamp($event->getAttribute('startdate')),
382+ 'specialevent' => (int)$attr($event->getElementsByTagName('details'), 'specialevent')
383+ );
384+ }
385+
386+ lws_send(array('eventlist' => $eventlist, 'lastupdatetimestamp' => time()));
387+}
388+
389+function lws_handle(): void {
390+ header('Content-Type: application/json');
391+
392+ $client = $GLOBALS['lws_request'] ?? null;
393+ if (!is_object($client)) {
394+ lws_error('Type missing.');
395+ }
396+
397+ $tier = lws_tier();
398+ $type = lws_field($client->type);
399+
400+ switch ($type) {
401+ case 'cacheinfo':
402+ lws_send(array(
403+ 'playersonline' => (int)user_count_online(),
404+ 'twitchstreams' => 0,
405+ 'twitchviewer' => 0,
406+ 'gamingyoutubestreams' => 0,
407+ 'gamingyoutubeviewer' => 0
408+ ));
409+ break;
410+
411+ case 'eventschedule':
412+ lws_handle_eventschedule();
413+ break;
414+
415+ case 'boostedcreature':
416+ lws_send(lws_boosted($tier));
417+ break;
418+
419+ case 'news':
420+ lws_send(array(
421+ 'gamenews' => array(),
422+ 'categorycounts' => array(
423+ 'support' => 1,
424+ 'game contents' => 2,
425+ 'useful info' => 3,
426+ 'major updates' => 4,
427+ 'client features' => 5
428+ ),
429+ 'maxeditdate' => time()
430+ ));
431+ break;
432+
433+ case 'login':
434+ lws_handle_login($client, $tier);
435+ break;
436+
437+ default:
438+ lws_error('Unsupported type: ' . (($type === false) ? '?' : $type));
439+ }
440+}
A engine/function/login_guard.php +61-0 View file
@@ -0,0 +1,61 @@
1+<?php
2+
3+function znote_login_guard_config(): array {
4+ global $config;
5+ $cfg = (array)($config['login_guard'] ?? array());
6+
7+ return array(
8+ 'enabled' => !empty($cfg['enabled']),
9+ 'threshold' => max(1, (int)($cfg['threshold'] ?? 5)),
10+ 'window_seconds' => max(60, (int)($cfg['window_minutes'] ?? 15) * 60),
11+ 'lockout_seconds' => max(60, (int)($cfg['lockout_minutes'] ?? 15) * 60),
12+ );
13+}
14+
15+function znote_login_guard_ip(): string {
16+ $ip = (string)(function_exists('getIP') ? getIP() : ($_SERVER['REMOTE_ADDR'] ?? ''));
17+ return substr(trim($ip), 0, 45);
18+}
19+
20+function znote_login_guard_record(string $ip, string $username, bool $success): void {
21+ if (!function_exists('znote_table_exists') || !znote_table_exists('znote_login_attempts')) {
22+ return;
23+ }
24+
25+ $now = time();
26+ db()->execute("
27+ INSERT INTO `znote_login_attempts` (`ip`, `username`, `success`, `created_at`)
28+ VALUES (?, ?, ?, ?);
29+ ", [$ip, substr($username, 0, 32), $success ? 1 : 0, $now]);
30+
31+ if (random_int(1, 20) === 1) {
32+ db()->execute("DELETE FROM `znote_login_attempts` WHERE `created_at` < ?;", [$now - 86400]);
33+ }
34+}
35+
36+function znote_login_guard_lockout_remaining(string $ip): int {
37+ $cfg = znote_login_guard_config();
38+ if (!$cfg['enabled'] || !function_exists('znote_table_exists') || !znote_table_exists('znote_login_attempts')) {
39+ return 0;
40+ }
41+
42+ $now = time();
43+ $windowStart = $now - $cfg['window_seconds'];
44+
45+ $row = db()->fetchOne("
46+ SELECT COUNT(*) AS `failures`, MAX(`created_at`) AS `last_failure`
47+ FROM `znote_login_attempts`
48+ WHERE `ip` = ? AND `success` = 0 AND `created_at` >= ?;
49+ ", [$ip, $windowStart]);
50+
51+ if (!is_array($row) || (int)$row['failures'] < $cfg['threshold']) {
52+ return 0;
53+ }
54+
55+ $unlocksAt = (int)$row['last_failure'] + $cfg['lockout_seconds'];
56+ return max(0, $unlocksAt - $now);
57+}
58+
59+function znote_login_guard_is_locked(string $ip): bool {
60+ return znote_login_guard_lockout_remaining($ip) > 0;
61+}
A engine/function/mail.php +56-0 View file
@@ -0,0 +1,56 @@
1+<?php
2+
3+use PHPMailer\PHPMailer\PHPMailer;
4+use PHPMailer\PHPMailer\Exception;
5+
6+class Mail {
7+ protected array $_config;
8+
9+ public function __construct(array $config) {
10+ $this->_config = $config;
11+ }
12+
13+ public function sendMail(string $to, string $title, string $html, string $accname = ''): bool
14+ {
15+ try {
16+ $mail = new PHPMailer(true);
17+
18+ // SMTP
19+ $mail->isSMTP();
20+ $mail->SMTPDebug = !empty($this->_config['debug']) ? 2 : 0;
21+ $mail->Host = $this->_config['host'];
22+ $mail->Port = (int)$this->_config['port'];
23+ $mail->SMTPAuth = ($this->_config['username'] ?? '') !== '';
24+ $mail->Username = $this->_config['username'];
25+ $mail->Password = $this->_config['password'];
26+ $mail->CharSet = 'UTF-8';
27+
28+ // Security
29+ $secure = $this->_config['securityType'] ?? '';
30+ if (in_array($secure, ['ssl', 'tls'], true)) {
31+ $mail->SMTPSecure = $secure;
32+ }
33+
34+ // Sender / receiver
35+ $mail->setFrom($this->_config['email'], $this->_config['fromName']);
36+ $mail->addAddress($to, $accname);
37+
38+ // Content
39+ $mail->isHTML(true);
40+ $mail->Subject = $title;
41+ $mail->Body = $html;
42+
43+ // AltBody
44+ $plain = str_replace(['<br>', '<br/>', '<br />'], "\n", $html);
45+ $plain = strip_tags($plain);
46+ $mail->AltBody = $plain;
47+
48+ return $mail->send();
49+
50+ } catch (Exception $e) {
51+ // Log plutôt qu'echo
52+ error_log('Mail error: ' . $e->getMessage());
53+ return false;
54+ }
55+ }
56+}
A engine/function/menus.php +274-0 View file
@@ -0,0 +1,274 @@
1+<?php
2+/**
3+ * Navigation menus.
4+ *
5+ * Menu links used to be hardcoded in each theme, so adding one meant editing
6+ * PHP, and every theme carried its own copy. They live in `znote_menu` now and
7+ * are edited from Admin Panel > Menus.
8+ *
9+ * A theme declares the slots it renders in theme.json:
10+ *
11+ * "menus": {
12+ * "main": "Top navigation",
13+ * "sidebar": "Left column",
14+ * "footer": "Footer links"
15+ * }
16+ *
17+ * and renders one with:
18+ *
19+ * <?php foreach (theme_menu_items('main') as $item): ?>
20+ * <a href="<?= h($item['url']) ?>"><?= h($item['label']) ?></a>
21+ * <?php endforeach; ?>
22+ *
23+ * The theme keeps full control of the markup - this only supplies the data,
24+ * already filtered for the current visitor and nested by parent.
25+ */
26+
27+/**
28+ * Entries for one location, filtered by visibility and nested.
29+ *
30+ * Each item: id, label, url, icon, target, children[].
31+ * A parent whose children are all hidden still shows; a child of a hidden
32+ * parent does not, because it is unreachable.
33+ */
34+function theme_menu_items(string $location): array {
35+ static $cache = array();
36+
37+ $location = preg_replace('/[^a-z0-9_-]/', '', strtolower($location));
38+ if ($location === '') {
39+ return array();
40+ }
41+ if (isset($cache[$location])) {
42+ return $cache[$location];
43+ }
44+
45+ $rows = db()->fetchAll("
46+ SELECT `id`, `parent_id`, `label`, `url`, `icon`, `target`, `visibility`
47+ FROM `znote_menu`
48+ WHERE `location` = ?
49+ AND `active` = 1
50+ ORDER BY `sort_order` ASC, `id` ASC;
51+ ", [$location]);
52+
53+ if (!is_array($rows)) {
54+ // No table yet (migration not run) or nothing defined: the theme falls
55+ // back to whatever it hardcodes.
56+ return $cache[$location] = array();
57+ }
58+
59+ $loggedIn = (function_exists('user_logged_in') && user_logged_in() === true);
60+ $isAdmin = $loggedIn && isset($GLOBALS['user_data']) && has_admin_panel_access($GLOBALS['user_data']);
61+
62+ $visible = array();
63+ foreach ($rows as $row) {
64+ switch ($row['visibility']) {
65+ case 'guest': $show = !$loggedIn; break;
66+ case 'user': $show = $loggedIn; break;
67+ case 'admin': $show = $isAdmin; break;
68+ default: $show = true;
69+ }
70+ if ($show && !menu_url_available((string)$row['url'])) {
71+ $show = false;
72+ }
73+ if ($show) {
74+ $visible[(int)$row['id']] = array(
75+ 'id' => (int)$row['id'],
76+ 'parent' => (int)$row['parent_id'],
77+ 'label' => (string)$row['label'],
78+ 'url' => (string)$row['url'],
79+ 'icon' => (string)$row['icon'],
80+ 'target' => (string)$row['target'],
81+ 'children' => array(),
82+ );
83+ }
84+ }
85+
86+ // Nest. A child whose parent was filtered out disappears with it.
87+ $tree = array();
88+ foreach ($visible as $id => $item) {
89+ if ($item['parent'] > 0 && isset($visible[$item['parent']])) {
90+ continue;
91+ }
92+ if ($item['parent'] > 0) {
93+ continue; // parent hidden: so is this
94+ }
95+ $tree[$id] = $item;
96+ }
97+ foreach ($visible as $id => $item) {
98+ if ($item['parent'] > 0 && isset($tree[$item['parent']])) {
99+ $tree[$item['parent']]['children'][] = $item;
100+ }
101+ }
102+
103+ return $cache[$location] = array_values($tree);
104+}
105+
106+function menu_url_available(string $url): bool {
107+ global $config;
108+
109+ $url = trim($url);
110+ if ($url === '' || $url === '#') {
111+ return true;
112+ }
113+
114+ $path = parse_url($url, PHP_URL_PATH);
115+ $page = strtolower(basename($path !== null && $path !== false ? $path : $url));
116+
117+ if ($page === 'page.php') {
118+ parse_str((string)(parse_url($url, PHP_URL_QUERY) ?: ''), $mq);
119+
120+ if (function_exists('setting')) {
121+ $mp = isset($mq['plugin']) ? preg_replace('/[^a-z0-9_-]/i', '', (string)$mq['plugin']) : '';
122+ if ($mp !== '') {
123+ return setting('plugin:' . $mp . ':enabled', '0') === '1'
124+ && (string)setting('plugin:' . $mp . ':version', '') !== '';
125+ }
126+ }
127+
128+ if (function_exists('theme_file')) {
129+ $mp = isset($mq['p']) ? preg_replace('/[^a-z0-9_-]/i', '', (string)$mq['p']) : '';
130+ if ($mp !== '') {
131+ return theme_file('pages/' . $mp . '.php') !== null;
132+ }
133+ }
134+ }
135+
136+ switch ($page) {
137+ case 'shop.php':
138+ return !empty($config['shop']['enabled']);
139+ case 'buypoints.php':
140+ return !empty($config['buypoints_enabled']);
141+ case 'guildwar.php':
142+ case 'guildwars.php':
143+ return !empty($config['guildwar_enabled']);
144+ case 'forum.php':
145+ return !empty($config['forum']['enabled']);
146+ case 'powergamers.php':
147+ return !empty($config['powergamers']['enabled']);
148+ case 'toponline.php':
149+ return !empty($config['toponline']['enabled']);
150+ case 'achievements.php':
151+ return !empty($config['Ach']);
152+ case 'items.php':
153+ return !empty($config['items']);
154+ case 'credits.php':
155+ return $config['credits_enabled'] ?? true;
156+ case 'queststatus.php':
157+ return !empty($config['queststatus_enabled']);
158+ default:
159+ return true;
160+ }
161+}
162+
163+function theme_menu_label(string $label): string {
164+ $raw = trim($label);
165+ if ($raw === '') {
166+ return '';
167+ }
168+
169+ if (function_exists('t') && preg_match('/^[a-z0-9_.-]+$/i', $raw)) {
170+ $translated = t($raw);
171+ if ($translated !== $raw) {
172+ return $translated;
173+ }
174+ }
175+
176+ $keyByLabel = array(
177+ 'account' => 'nav.account_section',
178+ 'account management' => 'nav.account_management',
179+ 'admin panel' => 'widget.admin.panel',
180+ 'bans' => 'bans.title',
181+ 'buy points' => 'shop.buy_points',
182+ 'changelog' => 'changelog.title',
183+ 'community' => 'nav.community',
184+ 'contact' => 'nav.contact',
185+ 'create account' => 'nav.register',
186+ 'create character' => 'account.create_character',
187+ 'credits' => 'nav.credits',
188+ 'creatures' => 'creatures.title',
189+ 'donate' => 'shop.buy_points',
190+ 'download' => 'downloads.download',
191+ 'download client' => 'nav.download_client',
192+ 'download game' => 'nav.downloads',
193+ 'downloads' => 'nav.downloads',
194+ 'forum' => 'nav.forum',
195+ 'guilds' => 'nav.guilds',
196+ 'helpdesk' => 'helpdesk.title',
197+ 'highscores' => 'nav.highscores',
198+ 'home' => 'nav.home',
199+ 'houses' => 'nav.houses',
200+ 'information' => 'front.server_information',
201+ 'item market' => 'nav.item_market',
202+ 'kill statistics' => 'nav.kill_statistics',
203+ 'kills statistics' => 'nav.kill_statistics',
204+ 'latest deaths' => 'deaths.latest',
205+ 'latest news' => 'nav.latest_news',
206+ 'library' => 'nav.library',
207+ 'log in' => 'nav.login',
208+ 'login' => 'nav.login',
209+ 'logout' => 'nav.logout',
210+ 'lost account' => 'recovery.lost_account_title',
211+ 'lost account?' => 'nav.lost_account',
212+ 'my account' => 'nav.account',
213+ 'news' => 'nav.news',
214+ 'register' => 'nav.register',
215+ 'server info' => 'nav.serverinfo',
216+ 'server information' => 'front.server_information',
217+ 'settings' => 'settings.title',
218+ 'shop' => 'nav.shop',
219+ 'spells' => 'spells.title',
220+ 'store' => 'nav.shop',
221+ 'support' => 'nav.support',
222+ 'vote for us' => 'nav.vote_for_us',
223+ 'vote for us!' => 'nav.vote_for_us',
224+ 'wheel of destiny' => 'tco.nav.wheelofdestiny',
225+ 'wheel of destiny planner' => 'tco.nav.wheelofdestiny',
226+ 'who is online' => 'nav.online',
227+ 'wikipedia' => 'nav.wikipedia',
228+ 'wiki search' => 'nav.wiki_search',
229+ );
230+
231+ $normalized = strtolower(preg_replace('/\s+/', ' ', str_replace(array('_', '-'), ' ', $raw)));
232+ if (isset($keyByLabel[$normalized]) && function_exists('t_default')) {
233+ return t_default($keyByLabel[$normalized], $label);
234+ }
235+
236+ return $label;
237+}
238+
239+/**
240+ * The menu slots the active theme declares, as slug => label.
241+ * Falls back to a single "main" slot so the admin page is never empty.
242+ */
243+function theme_menu_locations(?string $theme = null): array {
244+ $manifest = theme_manifest($theme ?? theme_active());
245+ $declared = $manifest['menus'] ?? null;
246+
247+ if (!is_array($declared) || !$declared) {
248+ return array('main' => t_default('acp.menu.default_location', 'Main navigation'));
249+ }
250+
251+ $out = array();
252+ foreach ($declared as $slug => $label) {
253+ // Accept both {"main":"Top"} and ["main","sidebar"].
254+ if (is_int($slug)) {
255+ $slug = (string)$label;
256+ $label = ucfirst(str_replace(array('-', '_'), ' ', $slug));
257+ }
258+ $slug = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)$slug));
259+ if ($slug !== '') {
260+ $out[$slug] = (string)$label;
261+ }
262+ }
263+
264+ return $out ?: array('main' => 'Main navigation');
265+}
266+
267+/**
268+ * True when the menu table exists and holds at least one entry.
269+ * A theme can use it to decide between the managed menu and its own fallback.
270+ */
271+function theme_menu_available(): bool {
272+ $row = db()->fetchOne("SELECT `id` FROM `znote_menu` LIMIT 1;");
273+ return is_array($row) && $row;
274+}
A engine/function/migrations.php +306-0 View file
@@ -0,0 +1,306 @@
1+<?php
2+/**
3+ * ZnoteX database migrations.
4+ *
5+ * Runs SQL files from SQL/migrations and records successful executions in
6+ * znote_migrations, so updates no longer require manual phpMyAdmin imports.
7+ */
8+
9+function znote_migrations_dir(): string {
10+ return dirname(__DIR__, 2) . '/SQL/migrations';
11+}
12+
13+function znote_migrations_table_ensure(): bool {
14+ return db()->rawExecute("
15+ CREATE TABLE IF NOT EXISTS `znote_migrations` (
16+ `id` int NOT NULL AUTO_INCREMENT,
17+ `migration` varchar(191) NOT NULL,
18+ `checksum` char(64) NOT NULL,
19+ `executed_at` int NOT NULL,
20+ `execution_time_ms` int NOT NULL DEFAULT '0',
21+ PRIMARY KEY (`id`),
22+ UNIQUE KEY `migration` (`migration`)
23+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
24+ ");
25+}
26+
27+function znote_migrations_applied(): array {
28+ if (!znote_migrations_table_ensure()) {
29+ return array();
30+ }
31+
32+ $rows = db()->fetchAll("SELECT `migration`, `checksum`, `executed_at`, `execution_time_ms` FROM `znote_migrations` ORDER BY `migration` ASC;");
33+ $out = array();
34+
35+ if (is_array($rows)) {
36+ foreach ($rows as $row) {
37+ $out[(string)$row['migration']] = $row;
38+ }
39+ }
40+
41+ return $out;
42+}
43+
44+function znote_migrations_files(): array {
45+ $dir = znote_migrations_dir();
46+ $files = is_dir($dir) ? glob($dir . '/*.sql') : array();
47+ $files = is_array($files) ? $files : array();
48+ sort($files, SORT_NATURAL | SORT_FLAG_CASE);
49+
50+ $out = array();
51+ foreach ($files as $file) {
52+ $name = basename($file);
53+ $out[$name] = array(
54+ 'name' => $name,
55+ 'path' => $file,
56+ 'checksum' => hash_file('sha256', $file) ?: '',
57+ 'size' => filesize($file) ?: 0,
58+ );
59+ }
60+
61+ return $out;
62+}
63+
64+function znote_migrations_status(): array {
65+ $applied = znote_migrations_applied();
66+ $files = znote_migrations_files();
67+ $status = array();
68+
69+ foreach ($files as $name => $file) {
70+ $row = $applied[$name] ?? null;
71+ $state = 'pending';
72+ if (is_array($row)) {
73+ $state = hash_equals((string)$row['checksum'], (string)$file['checksum']) ? 'applied' : 'changed';
74+ }
75+
76+ $status[$name] = $file + array(
77+ 'state' => $state,
78+ 'applied' => $row,
79+ );
80+ }
81+
82+ foreach ($applied as $name => $row) {
83+ if (!isset($status[$name])) {
84+ $status[$name] = array(
85+ 'name' => $name,
86+ 'path' => '',
87+ 'checksum' => (string)$row['checksum'],
88+ 'size' => 0,
89+ 'state' => 'missing',
90+ 'applied' => $row,
91+ );
92+ }
93+ }
94+
95+ ksort($status, SORT_NATURAL | SORT_FLAG_CASE);
96+ return $status;
97+}
98+
99+function znote_migrations_pending(): array {
100+ return array_filter(znote_migrations_status(), static function (array $migration): bool {
101+ return $migration['state'] === 'pending';
102+ });
103+}
104+
105+final class ZnoteMigrationSqlSplitter
106+{
107+ private string $sql;
108+ private int $len;
109+ private int $i = 0;
110+ private string $current = '';
111+ private ?string $quote = null;
112+ private bool $lineComment = false;
113+ private bool $blockComment = false;
114+ private array $statements = array();
115+
116+ public function __construct(string $sql)
117+ {
118+ $this->sql = $sql;
119+ $this->len = strlen($sql);
120+ }
121+
122+ public function split(): array
123+ {
124+ for ($this->i = 0; $this->i < $this->len; $this->i++) {
125+ $this->step();
126+ }
127+ $this->flush();
128+
129+ return $this->statements;
130+ }
131+
132+ private function step(): void
133+ {
134+ if ($this->lineComment) {
135+ $this->stepLineComment();
136+ return;
137+ }
138+ if ($this->blockComment) {
139+ $this->stepBlockComment();
140+ return;
141+ }
142+ if ($this->quote !== null) {
143+ $this->stepQuote();
144+ return;
145+ }
146+ $this->stepDefault();
147+ }
148+
149+ private function char(): string
150+ {
151+ return $this->sql[$this->i];
152+ }
153+
154+ private function next(): string
155+ {
156+ return ($this->i + 1 < $this->len) ? $this->sql[$this->i + 1] : '';
157+ }
158+
159+ private function stepLineComment(): void
160+ {
161+ $char = $this->char();
162+ $this->current .= $char;
163+ if ($char === "\n") {
164+ $this->lineComment = false;
165+ }
166+ }
167+
168+ private function stepBlockComment(): void
169+ {
170+ $char = $this->char();
171+ $next = $this->next();
172+ $this->current .= $char;
173+ if ($char === '*' && $next === '/') {
174+ $this->current .= $next;
175+ $this->i++;
176+ $this->blockComment = false;
177+ }
178+ }
179+
180+ private function stepQuote(): void
181+ {
182+ $char = $this->char();
183+ $next = $this->next();
184+ $this->current .= $char;
185+ if ($char === '\\' && $next !== '') {
186+ $this->current .= $next;
187+ $this->i++;
188+ return;
189+ }
190+ if ($char === $this->quote) {
191+ $this->quote = null;
192+ }
193+ }
194+
195+ private function stepDefault(): void
196+ {
197+ $char = $this->char();
198+ $next = $this->next();
199+
200+ if ($this->startsLineComment()) {
201+ $this->lineComment = true;
202+ $this->current .= $char;
203+ return;
204+ }
205+ if ($char === '/' && $next === '*') {
206+ $this->blockComment = true;
207+ $this->current .= $char . $next;
208+ $this->i++;
209+ return;
210+ }
211+ if ($char === '\'' || $char === '"' || $char === '`') {
212+ $this->quote = $char;
213+ $this->current .= $char;
214+ return;
215+ }
216+ if ($char === ';') {
217+ $this->flush();
218+ return;
219+ }
220+
221+ $this->current .= $char;
222+ }
223+
224+ private function startsLineComment(): bool
225+ {
226+ $char = $this->char();
227+ $next = $this->next();
228+ return ($char === '-' && $next === '-' && ($this->i + 2 >= $this->len || preg_match('/\s/', $this->sql[$this->i + 2])))
229+ || $char === '#';
230+ }
231+
232+ private function flush(): void
233+ {
234+ $trimmed = trim($this->current);
235+ if ($trimmed !== '') {
236+ $this->statements[] = $trimmed;
237+ }
238+ $this->current = '';
239+ }
240+}
241+
242+function znote_migration_split_sql(string $sql): array {
243+ return (new ZnoteMigrationSqlSplitter($sql))->split();
244+}
245+
246+function znote_migration_run(string $migration): array {
247+ $files = znote_migrations_files();
248+ if (!isset($files[$migration])) {
249+ return array('ok' => false, 'message' => 'Migration file not found.', 'statements' => 0, 'time_ms' => 0);
250+ }
251+ if (!znote_migrations_table_ensure()) {
252+ return array('ok' => false, 'message' => 'Could not create znote_migrations table.', 'statements' => 0, 'time_ms' => 0);
253+ }
254+
255+ $applied = znote_migrations_applied();
256+ if (isset($applied[$migration]) && hash_equals((string)$applied[$migration]['checksum'], (string)$files[$migration]['checksum'])) {
257+ return array('ok' => true, 'message' => 'Already applied.', 'statements' => 0, 'time_ms' => 0);
258+ }
259+ if (isset($applied[$migration])) {
260+ return array('ok' => false, 'message' => 'Migration was already applied but the file checksum changed.', 'statements' => 0, 'time_ms' => 0);
261+ }
262+
263+ $sql = (string)file_get_contents($files[$migration]['path']);
264+ $statements = znote_migration_split_sql($sql);
265+ $started = microtime(true);
266+ $done = 0;
267+
268+ foreach ($statements as $index => $statement) {
269+ if (!db()->rawExecute($statement)) {
270+ return array(
271+ 'ok' => false,
272+ 'message' => 'Statement ' . ($index + 1) . ' failed. Check Admin Panel > Error Log for the database reference.',
273+ 'statements' => $done,
274+ 'time_ms' => (int)round((microtime(true) - $started) * 1000),
275+ );
276+ }
277+ $done++;
278+ }
279+
280+ $timeMs = (int)round((microtime(true) - $started) * 1000);
281+ $recorded = db()->execute("
282+ INSERT INTO `znote_migrations` (`migration`, `checksum`, `executed_at`, `execution_time_ms`)
283+ VALUES (?, ?, ?, ?);
284+ ", [$migration, $files[$migration]['checksum'], time(), $timeMs]);
285+
286+ if (!$recorded) {
287+ return array('ok' => false, 'message' => 'Migration ran but could not be recorded.', 'statements' => $done, 'time_ms' => $timeMs);
288+ }
289+
290+ return array('ok' => true, 'message' => 'Applied successfully.', 'statements' => $done, 'time_ms' => $timeMs);
291+}
292+
293+function znote_migrations_run_pending(): array {
294+ $results = array();
295+
296+ foreach (array_keys(znote_migrations_pending()) as $migration) {
297+ $result = znote_migration_run($migration);
298+ $results[$migration] = $result;
299+ if (empty($result['ok'])) {
300+ break;
301+ }
302+ }
303+
304+ return $results;
305+}
306+?>
Top