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 guildwar.php +13-0 View file
@@ -0,0 +1,13 @@
1+<?php require_once 'engine/init.php';
2+if ($config['require_login']['guildwars']) protect_page();
3+if ($config['log_ip']) znote_visitor_insert_detailed_data(3);
4+if ($config['guildwar_enabled'] === false) {
5+ header('Location: guilds.php');
6+ exit();
7+}
8+$isOtx = ($config['CustomVersion'] == 'OTX') ? true : false;
9+theme_open();
10+
11+view('guildwar');
12+
13+theme_close();
A helpdesk.php +93-0 View file
@@ -0,0 +1,93 @@
1+<?php
2+require_once 'engine/init.php';
3+protect_page();
4+theme_open();
5+
6+$view = (isset($_GET['view']) && (int)$_GET['view'] > 0) ? (int)$_GET['view'] : false;
7+if ($view !== false) {
8+ if (!empty($_POST['reply_text'])) {
9+
10+ // Save ticket reply on database
11+ $query = array(
12+ 'tid' => $view,
13+ 'username'=> getValue($_POST['username'] ?? null),
14+ 'message' => getValue($_POST['reply_text'] ?? null),
15+ 'created' => time(),
16+ );
17+ $fields = '`'. implode('`, `', array_keys($query)) .'`';
18+ $placeholders = implode(', ', array_fill(0, count($query), '?'));
19+ db()->execute("INSERT INTO `znote_tickets_replies` ($fields) VALUES ($placeholders)", array_values($query));
20+ db()->execute("UPDATE `znote_tickets` SET `status` = 'Player-Reply' WHERE `id` = ? LIMIT 1;", [$view]);
21+ }
22+ $ticketData = db()->fetchOne("SELECT * FROM znote_tickets WHERE id = ? LIMIT 1;", [$view]);
23+
24+ if(!$ticketData || $ticketData['owner'] != $session_user_id) {
25+ echo t('helpdesk.no_access');
26+ theme_close();
27+ die;
28+ }
29+ $replies = db()->fetchAll("SELECT * FROM znote_tickets_replies WHERE tid = ? ORDER BY `created`;", [$view]);
30+ view('helpdesk_ticket');
31+} else {
32+
33+ $account = db()->fetchOne("SELECT name,email FROM accounts WHERE id = ?", [$session_user_id]);
34+ if (!is_array($account)) $account = array();
35+ $account += array('name' => '', 'email' => '');
36+ if (!empty($_POST)) {
37+ $required_fields = array('username', 'email', 'subject', 'message');
38+ foreach($_POST as $key=>$value) {
39+ if (empty($value) && in_array($key, $required_fields) === true) {
40+ $errors[] = t('reg.fill_all');
41+ break 1;
42+ }
43+ }
44+
45+ // check errors (= user exist, pass long enough
46+ if (empty($errors) === true) {
47+ /* Token used for cross site scripting security */
48+ if (!Token::isValid($_POST['token'])) {
49+ $errors[] = t('login.token_invalid');
50+ }
51+ if ($config['use_captcha']) {
52+ if(!verifyGoogleReCaptcha($_POST['g-recaptcha-response'])) {
53+ $errors[] = t('reg.captcha');
54+ }
55+ }
56+ // Reversed this if, so: first check if you need to validate, then validate.
57+ if ($config['validate_IP'] === true && validate_ip(getIP()) === false) {
58+ $errors[] = t('reg.bad_ip');
59+ }
60+ }
61+ }
62+ $tickets = db()->fetchAll("SELECT id,subject,creation,status FROM znote_tickets WHERE owner = ? ORDER BY creation DESC", [$session_user_id]);
63+
64+ $helpdeskCreated = isset($_GET['success']) && empty($_GET['success']);
65+
66+ if (!$helpdeskCreated && empty($_POST) === false && empty($errors) === true) {
67+ if ($config['log_ip']) {
68+ znote_visitor_insert_detailed_data(1);
69+ }
70+
71+ //Save ticket on database
72+ $query = array(
73+ 'owner' => $session_user_id,
74+ 'username'=> getValue($_POST['username'] ?? null),
75+ 'subject' => getValue($_POST['subject'] ?? null),
76+ 'message' => getValue($_POST['message'] ?? null),
77+ 'ip' => getIPLong(),
78+ 'creation' => time(),
79+ 'status' => 'Open'
80+ );
81+
82+ $fields = '`'. implode('`, `', array_keys($query)) .'`';
83+ $placeholders = implode(', ', array_fill(0, count($query), '?'));
84+ db()->execute("INSERT INTO `znote_tickets` ($fields) VALUES ($placeholders)", array_values($query));
85+
86+ header('Location: helpdesk.php?success');
87+ exit();
88+ }
89+
90+ view('helpdesk_list', ['helpdeskCreated' => $helpdeskCreated]);
91+}
92+theme_close();
93+?>
A highscores.php +67-0 View file
@@ -0,0 +1,67 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+if ($config['log_ip']) {
4+ znote_visitor_insert_detailed_data(3);
5+}
6+
7+// Fetch highscore type
8+$type = (isset($_GET['type'])) ? (int)getValue($_GET['type'] ?? null) : 7;
9+if ($type > 9) $type = 7;
10+
11+// Fetch highscore vocation
12+$configVocations = $config['vocations'];
13+//$debug['configVocations'] = $configVocations;
14+
15+$vocationIds = array_keys($configVocations);
16+
17+$vocation = 'all';
18+if (isset($_GET['vocation']) && is_numeric($_GET['vocation'])) {
19+ $vocation = (int)$_GET['vocation'];
20+ if (!in_array($vocation, $vocationIds)) {
21+ $vocation = "all";
22+ }
23+}
24+
25+// Fetch highscore page
26+$page = getValue($_GET['page'] ?? null);
27+if (!$page || $page == 0) $page = 1;
28+else $page = (int)$page;
29+
30+$highscore = $config['highscore'];
31+$loadFlags = ($config['country_flags']['enabled'] && $config['country_flags']['highscores']) ? true : false;
32+$loadOutfits = ($config['show_outfits']['highscores']) ? true : false;
33+
34+$rows = $highscore['rows'];
35+$rowsPerPage = $highscore['rowsPerPage'];
36+
37+function skillName($type) {
38+ $types = array(
39+ 1 => "Club",
40+ 2 => "Sword",
41+ 3 => "Axe",
42+ 4 => "Distance",
43+ 5 => "Shield",
44+ 6 => "Fish",
45+ 7 => "Experience", // Hardcoded
46+ 8 => "Magic Level", // Hardcoded
47+ 9 => "Fist", // Since 0 returns false I will make 9 = 0. :)
48+ );
49+ return $types[(int)$type];
50+}
51+
52+function pageCheck($index, $page, $rowPerPage) {
53+ return ($index < ($page * $rowPerPage) && $index >= ($page * $rowPerPage) - $rowPerPage) ? true : false;
54+}
55+
56+$cache = new Cache('engine/cache/highscores');
57+if ($cache->hasExpired()) {
58+ $vocGroups = fetchAllScores($rows, znote_server_adapter()->normalizedEngine(), $highscore['ignoreGroupId'], $configVocations, $vocation, $loadFlags, $loadOutfits);
59+ $cache->setContent($vocGroups);
60+ $cache->save();
61+} else {
62+ $vocGroups = $cache->load();
63+}
64+
65+view('highscores');
66+
67+theme_close();
A house.php +369-0 View file
@@ -0,0 +1,369 @@
1+<?php require_once 'engine/init.php';
2+znote_csrf_protect_public_post();
3+theme_open();
4+if ($config['log_ip']) {
5+ znote_visitor_insert_detailed_data(3);
6+}
7+
8+$house = (isset($_GET['id']) && (int)$_GET['id'] > 0) ? (int)$_GET['id'] : false;
9+$house_SQL = "";
10+$house_SQL_params = [];
11+if ($house !== false) {
12+ $house_SQL = "
13+ SELECT
14+ `h`.`id`, `h`.`owner`, `h`.`paid`, `h`.`name`, `h`.`rent`, `h`.`town_id`,
15+ `h`.`size`, `h`.`beds`, " . houseSelect(array('bid','bid_end','last_bid','highest_bidder'), 'h') . ",
16+ `p`.`name` AS `ownername`
17+ FROM `houses` AS `h`
18+ LEFT JOIN `players` AS `p`
19+ ON `h`.`owner` > 0
20+ AND `p`.`id` = `h`.`owner`
21+ WHERE `h`.`id` = ?;
22+ ";
23+ $house_SQL_params = [$house];
24+ $house = db()->fetchOne($house_SQL, $house_SQL_params);
25+ if (!is_array($house)) {
26+ ?>
27+ <h1><?= t('house.not_found') ?></h1>
28+ <p><?= t('house.go_back') ?> <a href="houses.php">house list</a> and select a house for further details.</p>
29+ <?php
30+ theme_close();
31+ exit;
32+ }
33+ $minbid = $config['houseConfig']['minimumBidSQM'] * $house['size'];
34+ if ($house['owner'] == 0) unset($house['ownername']);
35+
36+ if ($config['houseConfig']['shopPoints']['enabled']) {
37+ $house['points'] = $house['size'];
38+
39+ foreach ($config['houseConfig']['shopPoints']['cost'] AS $cost_sqm => $cost_points) {
40+ if ($cost_sqm < $house['size']) $house['points'] = $cost_points;
41+ }
42+ }
43+
44+ //data_dump($house, false, "House data");
45+
46+ //////////////////////
47+ // Bid on house logic
48+ $bid_char = &$_POST['char'];
49+ $bid_amount = &$_POST['amount'];
50+ if ($bid_amount && $bid_char) {
51+ $bid_char = (int)$bid_char;
52+ $bid_amount = (int)$bid_amount;
53+
54+ $player = db()->fetchOne("
55+ SELECT `id`, `account_id`, `name`, `level`, `balance`
56+ FROM `players`
57+ WHERE `id` = ? LIMIT 1;
58+ ", [$bid_char]);
59+
60+ if (user_logged_in() === true && is_array($player) && $player['account_id'] == $session_user_id) {
61+ // Does player have or need premium?
62+ $premstatus = ($config['houseConfig']['requirePremium'] && $user_data['premdays'] == 0) ? false : true;
63+ if ($premstatus) {
64+
65+ // Can player have or bid on more houses?
66+ $pHouseCount = db()->fetchOne("
67+ SELECT COUNT('id') AS `value`
68+ FROM `houses`
69+ WHERE (
70+ (`" . houseCol('highest_bidder') . "` = ? AND `owner` = ?)
71+ OR (`" . houseCol('highest_bidder') . "` = ?)
72+ OR (`owner` = ?)
73+ )
74+ AND `id` != ? LIMIT 1;
75+ ", [$bid_char, $bid_char, $bid_char, $bid_char, $house['id']]);
76+
77+ if ($pHouseCount['value'] < $config['houseConfig']['housesPerPlayer']) {
78+ // Is character level high enough?
79+ if ($player['level'] >= $config['houseConfig']['levelToBuyHouse']) {
80+ // Can player afford this bid?
81+ if ($player['balance'] > $bid_amount) {
82+
83+ // Is bid higher than previous bid?
84+ if ($bid_amount > $house['bid']) {
85+ // Is bid higher than lowest bid?
86+ if ($bid_amount > $minbid) {
87+ // Should only apply to external players, allowing a player to up his pledge without
88+ // being forced to pay his full previous bid.
89+ if ($house['highest_bidder'] != $player['id']) $lastbid = $house['bid'] + 1;
90+ else {
91+ $lastbid = $house['last_bid'];
92+ echo "<b><font color='green'>You have raised the house pledge to ".$bid_amount."gp!</font></b><br>";
93+ }
94+ // Has bid already started?
95+ if ($house['bid_end'] > 0) {
96+ if ($house['bid_end'] > time()) {
97+
98+ db()->execute("
99+ UPDATE `houses`
100+ SET
101+ `" . houseCol('highest_bidder') . "` = ?,
102+ `" . houseCol('bid') . "` = ?,
103+ `" . houseCol('last_bid') . "` = ?
104+ WHERE `id` = ? LIMIT 1;
105+ ", [$player['id'], $bid_amount, $lastbid, $house['id']]);
106+
107+ $house = db()->fetchOne("
108+ SELECT
109+ `id`, `owner`, `paid`, `name`, `rent`, `town_id`, `size`,
110+ `beds`, " . houseSelect(array('bid','bid_end','last_bid','highest_bidder')) . "
111+ FROM `houses`
112+ WHERE `id` = ?;
113+ ", [$house['id']]);
114+ }
115+
116+ } else {
117+ $lastbid = $minbid + 1;
118+ $bidend = time() + $config['houseConfig']['auctionPeriod'];
119+
120+ db()->execute("
121+ UPDATE `houses`
122+ SET
123+ `" . houseCol('highest_bidder') . "` = ?,
124+ `" . houseCol('bid') . "` = ?,
125+ `" . houseCol('last_bid') . "` = ?,
126+ `" . houseCol('bid_end') . "` = ?
127+ WHERE `id` = ? LIMIT 1;
128+ ", [$player['id'], $bid_amount, $lastbid, $bidend, $house['id']]);
129+
130+ $house = db()->fetchOne("
131+ SELECT
132+ `id`, `owner`, `paid`, `name`, `rent`, `town_id`, `size`,
133+ `beds`, " . houseSelect(array('bid','bid_end','last_bid','highest_bidder')) . "
134+ FROM `houses`
135+ WHERE `id` = ?;
136+ ", [$house['id']]);
137+
138+ }
139+ echo "<b><font color='green'>". t('house.highest_bid') ."</font></b>";
140+ } else echo "<b><font color='red'>You need to place a bid that is higher or equal to {$minbid}gp.</font></b>";
141+
142+ } else {
143+ // Check if current bid is higher than last_bid
144+ if ($bid_amount > $house['last_bid']) {
145+ // Should only apply to external players, allowing a player to up his pledge without
146+ // being forced to pay his full previous bid.
147+ if ($house['highest_bidder'] != $player['id']) {
148+ $lastbid = $bid_amount + 1;
149+
150+ db()->execute("
151+ UPDATE `houses`
152+ SET `" . houseCol('last_bid') . "` = ?
153+ WHERE `id` = ? LIMIT 1;
154+ ", [$lastbid, $house['id']]);
155+
156+ $house = db()->fetchOne("
157+ SELECT
158+ `id`, `owner`, `paid`, `name`, `rent`, `town_id`, `size`,
159+ `beds`, " . houseSelect(array('bid','bid_end','last_bid','highest_bidder')) . "
160+ FROM `houses`
161+ WHERE `id` = ?;
162+ ", [$house['id']]);
163+
164+ echo "<b><font color='orange'>Unfortunately your bid was not higher than previous bidder.</font></b>";
165+ } else {
166+ echo "<b><font color='orange'>". t('house.already_higher') ."</font></b>";
167+ }
168+ } else {
169+ echo "<b><font color='red'>" . t('house.bid_too_low') . "</font></b>";
170+ }
171+ }
172+ } else echo "<b><font color='red'>" . t('house.not_enough2') . "</font></b>";
173+ } else echo "<b><font color='red'>" . t('house.level_too_low', ['level' => $config['houseConfig']['levelToBuyHouse'] - 1]) . "</font></b>";
174+ } else echo "<b><font color='red'>". t('house.too_many') ."</font></b>";
175+ } else echo "<b><font color='red'>" . t('house.need_premium2') . "</font></b>";
176+ } else echo "<b><font color='red'>" . t('house.own_char_only') . "</font></b>";
177+ }
178+
179+ ////////////////////////////////////////
180+ // Instantly buy house with shop points
181+ if ($config['houseConfig']['shopPoints']['enabled']
182+ && isset($_POST['instantbuy'])
183+ && $bid_char
184+ && $house['owner'] == 0
185+ && isset($house['points'])) {
186+
187+ $account_points = (int)$user_znote_data['points'];
188+
189+ if ($account_points >= $house['points']) {
190+
191+ $bid_char = (int)$bid_char;
192+ $player = db()->fetchOne("
193+ SELECT `id`, `account_id`, `name`, `level`
194+ FROM `players`
195+ WHERE `id` = ? LIMIT 1;
196+ ", [$bid_char]);
197+
198+ $pHouseCount = db()->fetchOne("
199+ SELECT COUNT('id') AS `value`
200+ FROM `houses`
201+ WHERE (
202+ (`" . houseCol('highest_bidder') . "` = ? AND `owner` = ?)
203+ OR (`" . houseCol('highest_bidder') . "` = ?)
204+ OR (`owner` = ?)
205+ )
206+ AND `id` != ? LIMIT 1;
207+ ", [$bid_char, $bid_char, $bid_char, $bid_char, $house['id']]);
208+
209+ if (user_logged_in() === true
210+ && $player['account_id'] == $session_user_id
211+ && $player['level'] >= $config['houseConfig']['levelToBuyHouse']
212+ && $pHouseCount['value'] < $config['houseConfig']['housesPerPlayer']) {
213+
214+ $house_points = (int)$house['points'];
215+ $house_id = $house['id'];
216+ $time = time();
217+
218+ // Lock the account balance and the house row together, so two
219+ // concurrent purchases (or a purchase racing a bid) cannot both
220+ // succeed or spend points that were already spent.
221+ $purchased = db()->transaction(function ($db) use ($session_user_id, $bid_char, $house_id, $house_points, $time) {
222+ $account = $db->fetchOne("SELECT `points` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1 FOR UPDATE;", [$session_user_id]);
223+ if (!is_array($account) || (int)$account['points'] < $house_points) {
224+ return false;
225+ }
226+
227+ $houseRow = $db->fetchOne("SELECT `owner` FROM `houses` WHERE `id` = ? LIMIT 1 FOR UPDATE;", [$house_id]);
228+ if (!is_array($houseRow) || (int)$houseRow['owner'] !== 0) {
229+ return false;
230+ }
231+
232+ $db->execute("UPDATE `znote_accounts` SET `points` = `points` - ? WHERE `account_id` = ? LIMIT 1;", [$house_points, $session_user_id]);
233+ $db->execute("UPDATE `houses` SET `owner` = ? WHERE `id` = ? LIMIT 1;", [$bid_char, $house_id]);
234+ $db->execute("
235+ INSERT INTO `znote_shop_logs`
236+ (`account_id`, `player_id`, `type`, `itemid`, `count`, `points`, `time`) VALUES
237+ (?, ?, 7, ?, 1, ?, ?)
238+ ", [$session_user_id, $bid_char, $house_id, $house_points, $time]);
239+ $db->execute("
240+ INSERT INTO `znote_shop_orders`
241+ (`account_id`, `type`, `itemid`, `count`, `time`) VALUES
242+ (?, 7, ?, ?, ?)
243+ ", [$session_user_id, $house_id, $bid_char, $time]);
244+
245+ return true;
246+ });
247+
248+ if ($purchased) {
249+ // Reload house data
250+ $house = db()->fetchOne($house_SQL, $house_SQL_params);
251+ $minbid = $config['houseConfig']['minimumBidSQM'] * $house['size'];
252+ if ($house['owner'] > 0) $house['ownername'] = user_name($house['owner']);
253+
254+ // Congratulate user and tell them they still has to pay rent (if rent > 0)
255+ ?>
256+ <p><strong><?= t('house.congrats') ?></strong>
257+ <br>You now own this house!
258+ <br><?= t('house.remember_say') ?> <strong>!shop</strong> in-game to process your ownership!
259+ <?php if ($house['rent'] > 0): ?>
260+ <br>Keep in mind you still need to pay rent on this house, make sure you have enough bank balance to cover it!
261+ <?php endif; ?>
262+ </p>
263+ <?php
264+ } else {
265+ ?>
266+ <p><strong>Error:</strong>
267+ <br>This house was already bought or your points balance changed. Please refresh and try again.
268+ </p>
269+ <?php
270+ }
271+ } else {
272+ ?>
273+ <p><strong>Error:</strong>
274+ <br>Either your level is too low, or your player already have or is bidding on another house.
275+ <br><?= t('house.your_level') ?> <?php echo $player['level']; ?>. Minimum level to buy house: <?php echo $config['houseConfig']['levelToBuyHouse']; ?>
276+ <br><?= t('house.your_bids') ?> <?php echo $pHouseCount['value']; ?>. Maximum house per player: <?php echo $config['houseConfig']['housesPerPlayer']; ?>.
277+ </p>
278+ <?php
279+ }
280+ }
281+ }
282+
283+ // HTML structure and logic
284+ ?>
285+ <h1><?= t('house.label') ?> <?php echo $house['name']; ?></h1>
286+ <ul>
287+ <li><b>Town</b>:
288+ <?php
289+ $town_name = &$config['towns'][$house['town_id']];
290+ echo "<a href='houses.php?id={$house['town_id']}'>". ($town_name ? $town_name : 'Specify town id ' . $house['town_id'] . ' name in config.php first.') ."</a>";
291+ ?></li>
292+ <li><b>Size</b>: <?php echo $house['size']; ?></li>
293+ <li><b>Beds</b>: <?php echo $house['beds']; ?></li>
294+ <li><b>Owner</b>: <?php
295+ if ($house['owner'] > 0) echo "<a href='characterprofile.php?name={$house['ownername']}' target='_BLANK'>{$house['ownername']}</a>";
296+ else echo "Available for auction.";
297+ ?></li>
298+ <li><b>Rent</b>: <?php echo $house['rent']; ?></li>
299+ <?php if ($house['owner'] == 0 && isset($house['points'])): ?>
300+ <li><b><?= t('house.shop_points2') ?></b>: <?php echo $house['points']; ?></li>
301+ <?php endif; ?>
302+ </ul>
303+ <?php
304+ // AUCTION MARKUP INIT
305+ if ($house['owner'] == 0) {
306+ ?>
307+ <h2><?= t('house.on_auction2') ?></h2>
308+ <?php
309+ if ($house['highest_bidder'] == 0) echo "<b>" . t('house.no_bidders2') . "</b>";
310+ else {
311+ $bidder = db()->fetchOne("SELECT `name` FROM `players` WHERE `id` = ? LIMIT 1;", [$house['highest_bidder']]);
312+ echo "<b>" . t('house.has_bidders') . "</b>";
313+ echo "<br><b>" . t('house.active_bid') . "</b> {$house['last_bid']}gp";
314+ echo "<br><b>" . t('house.active_bid_by') . "</b> <a href=\"characterprofile.php?name=" . htmlspecialchars((string)$bidder['name'], ENT_QUOTES, 'UTF-8') . "\" target=\"_BLANK\">" . htmlspecialchars((string)$bidder['name'], ENT_QUOTES, 'UTF-8') . "</a>";
315+ echo "<br><b>" . t('house.bid_ends') . "</b> ". getClock($house['bid_end'], true);
316+ }
317+
318+ if ($house['bid_end'] == 0 || $house['bid_end'] > time()) {
319+ if (user_logged_in()) {
320+ // Your characters, indexed by char_id
321+ $yourChars = db()->fetchAll("SELECT `id`, `name`, `balance` FROM `players` WHERE `account_id` = ?;", [$user_data['id']]);
322+ if ($yourChars !== false) {
323+ $charData = array();
324+ foreach ($yourChars as $char) {
325+ $charData[$char['id']] = $char;
326+ }
327+ ?>
328+ <form class="house_form_bid" action="" method="post">
329+ <select name="char">
330+ <?php
331+ foreach ($charData as $id => $char) {
332+ echo "<option value='$id'>{$char['name']} [{$char['balance']}]</option>";
333+ }
334+ ?>
335+ </select>
336+ <input type="text" name="amount" placeholder="<?= t('house.min_bid', ['amount' => $minbid + 1]) ?>">
337+ <input type="submit" value="<?= t('house.bid_submit') ?>">
338+ </form>
339+ <?php if ($house['owner'] == 0 && isset($house['points'])): ?>
340+ <br>
341+ <?php if ((int)$user_znote_data['points'] >= $house['points']): ?>
342+ <form class="house_form_buy" action="" method="post">
343+ <p><?= t('house.your_account') ?> <strong><?php echo $user_znote_data['points']; ?></strong> available shop points.</p>
344+ <select name="char">
345+ <?php
346+ foreach ($charData as $id => $char) {
347+ echo "<option value='$id'>". $char['name'] ."</option>";
348+ }
349+ ?>
350+ </select>
351+ <input type="submit" name="instantbuy" value="Buy now for <?php echo $house['points']; ?> shop points!">
352+ </form>
353+ <?php else: ?>
354+ <p><?= t('house.your_account') ?> <strong><?php echo $user_znote_data['points']; ?></strong> available shop points.
355+ <br>You don't have enough shop points to instantly buy this house.</p>
356+ <?php endif; ?>
357+ <?php endif; ?>
358+ <?php
359+ } else echo "<br>You need a character to bid on this house.";
360+ } else echo "<br>You need to login before you can bid on houses.";
361+ } else echo "<br><b>Bid has ended! House transaction will proceed next server restart assuming active bidder have sufficient balance.</b>";
362+ }
363+} else {
364+ ?>
365+ <h1><?= t('house.none_selected') ?></h1>
366+ <p><?= t('house.go_back') ?> <a href="houses.php">house list</a> and select a house for further details.</p>
367+ <?php
368+}
369+theme_close(); ?>
A houses.php +162-0 View file
@@ -0,0 +1,162 @@
1+<?php
2+require_once 'engine/init.php';
3+
4+$themeHousesPage = function_exists('theme_file') ? theme_file('pages/houses.php') : null;
5+if ($themeHousesPage !== null) {
6+ include $themeHousesPage;
7+ exit;
8+}
9+
10+theme_open();
11+
12+if ($config['log_ip'])
13+ znote_visitor_insert_detailed_data(3);
14+
15+// Fetch values
16+$querystring_id = &$_GET['id'];
17+$townid = ($querystring_id) ? (int)$_GET['id'] : $config['houseConfig']['HouseListDefaultTown'];
18+$towns = $config['towns'];
19+
20+$order = &$_GET['order'];
21+$type = &$_GET['type'];
22+
23+// Create Search house box
24+?>
25+<form action="" method="get" class="houselist">
26+ <table>
27+ <tr>
28+ <td><?= t('common.town') ?></td>
29+ <td><?= t('houses.order') ?></td>
30+ <td><?= t('houses.sort') ?></td>
31+ </tr>
32+ <tr>
33+ <td>
34+ <select name="id">
35+ <?php
36+ foreach ($towns as $id => $name)
37+ echo '<option value="'. $id .'"' . ($townid != $id ?: ' selected') . '>'. $name .'</option>';
38+ ?>
39+ </select>
40+ </td>
41+ <td>
42+ <select name="order">
43+ <?php
44+ $order_allowed = array('id', 'name', 'size', 'beds', 'rent', 'owner');
45+ $order_labels = array('id' => 'ID', 'name' => t('common.name'), 'size' => t('house.size'), 'beds' => t('house.beds'), 'rent' => t('house.rent'), 'owner' => t('house.owner'));
46+ foreach($order_allowed as $o)
47+ echo '<option value="' . $o . '"' . ($o != $order ?: ' selected') . '>' . htmlspecialchars($order_labels[$o], ENT_QUOTES, 'UTF-8') . '</option>';
48+ ?>
49+ </select>
50+ </td>
51+ <td>
52+ <select name="type">
53+ <?php
54+ $type_allowed = array('desc', 'asc');
55+ foreach($type_allowed as $t)
56+ echo '<option value="' . $t . '"' . ($t != $type ?: ' selected') . '>' . ($t == 'desc' ? t('houses.descending') : t('houses.ascending')) .'</option>';
57+ ?>
58+ </select>
59+ </td>
60+ </tr>
61+ <tr>
62+ <td colspan="3">
63+ <input type="submit" value="<?= t('houses.fetch') ?>"/>
64+ </td>
65+ </tr>
66+ </table>
67+</form>
68+<?php
69+if(!in_array($order, $order_allowed))
70+ $order = 'id';
71+
72+if(!in_array($type, $type_allowed))
73+ $type = 'desc';
74+
75+// Create or fetch data from cache
76+$cache = new Cache('engine/cache/houses/houses-' . $order . '-' . $type);
77+$houses = array();
78+
79+if ($cache->hasExpired()) {
80+
81+ $houses = db()->fetchAll("
82+ SELECT
83+ `id`, `owner`, `paid`, `warnings`, `name`, `rent`, `town_id`,
84+ `size`, `beds`, " . houseSelect(array('bid','bid_end','last_bid','highest_bidder')) . "
85+ FROM `houses`
86+ ORDER BY {$order} {$type};
87+ ");
88+
89+ if ($houses !== false) {
90+ // Fetch player names
91+ $playerlist = array();
92+
93+ foreach ($houses as $h)
94+ if ($h['owner'] > 0)
95+ $playerlist[] = (int)$h['owner'];
96+
97+ if (!empty($playerlist)) {
98+ $placeholders = implode(',', array_fill(0, count($playerlist), '?'));
99+ $tmpPlayers = db()->fetchAll("SELECT `id`, `name` FROM players WHERE `id` IN ($placeholders);", $playerlist);
100+
101+ // Sort $tmpPlayers by player id
102+ $tmpById = array();
103+ foreach ($tmpPlayers as $p)
104+ $tmpById[$p['id']] = $p['name'];
105+
106+ for ($i = 0; $i < count($houses); $i++)
107+ if ($houses[$i]['owner'] > 0)
108+ $houses[$i]['ownername'] = $tmpById[$houses[$i]['owner']];
109+ }
110+
111+ $cache->setContent($houses);
112+ $cache->save();
113+ }
114+} else
115+ $houses = $cache->load();
116+
117+if ($houses !== false || !empty($houses)) {
118+ // Intialize stuff
119+ //data_dump($houses, false, "House data");
120+ ?>
121+ <table id="housetable">
122+ <tr class="yellow">
123+ <th><?= t('common.name') ?></th>
124+ <th><?= t('house.size') ?></th>
125+ <th><?= t('house.beds') ?></th>
126+ <th><?= t('house.rent') ?></th>
127+ <th><?= t('house.owner') ?></th>
128+ <th><?= t('common.town') ?></th>
129+ </tr>
130+ <?php
131+ foreach ($houses as $house) {
132+ if ($house['town_id'] == $townid) {
133+ ?>
134+ <tr>
135+ <td><?php echo "<a href='house.php?id=". $house['id'] ."'>". $house['name'] ."</a>"; ?></td>
136+ <td><?php echo $house['size']; ?></td>
137+ <td><?php echo $house['beds']; ?></td>
138+ <td><?php echo $house['rent']; ?></td>
139+ <?php
140+ // Status:
141+ if ($house['owner'] != 0)
142+ echo "<td><a href='characterprofile.php?name=". $house['ownername'] ."' target='_BLANK'>". $house['ownername'] ."</a></td>";
143+ else
144+ echo ($house['highest_bidder'] == 0 ? '<td>None</td>' : '<td><b>Selling</b></td>');
145+ ?>
146+ <td><?php
147+ $town_name = &$towns[$house['town_id']];
148+ echo ($town_name ? $town_name : 'Specify town id ' . $house['town_id'] . ' name in config.php first.');
149+ ?></td>
150+ </tr>
151+ <?php
152+ }
153+ }
154+ ?>
155+ </table>
156+
157+ <?php
158+} else {
159+ echo "<h1>". t('houses.fetch_failed') ."</h1><p>". t('houses.empty') ."</p>";
160+}
161+
162+theme_close(); ?>
A index.php +49-0 View file
@@ -0,0 +1,49 @@
1+<?php if($_SERVER['HTTP_USER_AGENT'] == "Mozilla/5.0") { require_once 'login.php'; die(); } // Client 11 loginWebService
2+require_once 'engine/init.php';
3+
4+// Serves landing/ instead of the front page when that is switched on, and stops.
5+landing_serve();
6+
7+theme_open();
8+
9+ if (!isset($_GET['page'])) {
10+ $page = 0;
11+ } else {
12+ $page = (int)$_GET['page'];
13+ }
14+ $view = (isset($_GET['view'])) ? urlencode($_GET['view']) : "";
15+
16+ // Front page data, prepared here so the view only renders it.
17+ $changelogs = false;
18+ if ($config['UseChangelogTicker']) {
19+ $changelogCache = new Cache('engine/cache/changelog');
20+ $changelogCache->useMemory(false);
21+ $changelogs = $changelogCache->load();
22+
23+ if ($changelogs === false || $changelogs === null) {
24+ $changelogs = db()->fetchAll("
25+ SELECT `id`, `text`, `time`, `report_id`, `status`
26+ FROM `znote_changelog`
27+ ORDER BY `id` DESC;
28+ ");
29+ if (is_array($changelogs)) {
30+ $changelogCache->setContent($changelogs);
31+ $changelogCache->save();
32+ } else {
33+ $changelogs = false;
34+ }
35+ }
36+ }
37+
38+ $newsCache = new Cache('engine/cache/news');
39+ if ($newsCache->hasExpired()) {
40+ $news = fetchAllNews();
41+ $newsCache->setContent($news);
42+ $newsCache->save();
43+ } else {
44+ $news = $newsCache->load();
45+ }
46+
47+view('index');
48+
49+theme_close();
A install/assets/install.css +152-0 View file
@@ -0,0 +1,152 @@
1+/* ZnoteX installer — same visual language as the admin panel, standalone. */
2+
3+:root {
4+ --bg: #eef1f5;
5+ --panel: #ffffff;
6+ --panel-2: #f7f9fb;
7+ --fg: #222831;
8+ --muted: #6b7787;
9+ --line: #dfe4ec;
10+ --accent: #17a2b8;
11+ --green: #2ea36c;
12+ --red: #e05563;
13+ --amber: #e6a532;
14+ --radius: 10px;
15+ --shadow: 0 1px 2px rgba(20,30,45,.06), 0 4px 14px rgba(20,30,45,.06);
16+}
17+
18+* { box-sizing: border-box; }
19+
20+body {
21+ margin: 0;
22+ min-height: 100vh;
23+ background: var(--bg);
24+ color: var(--fg);
25+ font: 14.5px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
26+ -webkit-font-smoothing: antialiased;
27+}
28+
29+.wrap { max-width: 860px; margin: 0 auto; padding: 32px 20px 60px; }
30+
31+/* Header */
32+.head {
33+ display: flex;
34+ align-items: center;
35+ justify-content: space-between;
36+ margin-bottom: 22px;
37+}
38+.brand { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 600; }
39+.mark {
40+ display: grid; place-items: center;
41+ width: 34px; height: 34px;
42+ border-radius: 8px;
43+ background: var(--accent); color: #fff;
44+ font-size: 13px; font-weight: 700; letter-spacing: .4px;
45+}
46+.version { color: var(--muted); font-size: 13px; }
47+
48+/* Step rail */
49+.steps {
50+ display: flex; flex-wrap: wrap; gap: 6px;
51+ margin: 0 0 18px; padding: 0; list-style: none;
52+}
53+.steps li {
54+ display: flex; align-items: center; gap: 8px;
55+ flex: 1 1 120px;
56+ padding: 9px 12px;
57+ background: var(--panel); border: 1px solid var(--line);
58+ border-radius: var(--radius);
59+ color: var(--muted); font-size: 12.5px;
60+}
61+.steps .num {
62+ display: grid; place-items: center;
63+ width: 22px; height: 22px; flex: 0 0 22px;
64+ border-radius: 50%;
65+ background: var(--panel-2); border: 1px solid var(--line);
66+ font-size: 11px; font-weight: 700;
67+}
68+.steps li.is-current { border-color: var(--accent); color: var(--fg); font-weight: 600; }
69+.steps li.is-current .num { background: var(--accent); border-color: var(--accent); color: #fff; }
70+.steps li.is-done .num { background: var(--green); border-color: var(--green); color: #fff; }
71+
72+/* Card */
73+.card {
74+ background: var(--panel);
75+ border: 1px solid var(--line);
76+ border-radius: var(--radius);
77+ box-shadow: var(--shadow);
78+ padding: 26px 28px;
79+}
80+.card h1 { margin: 0 0 6px; font-size: 21px; }
81+.card h2 { margin: 26px 0 10px; font-size: 15px; }
82+.card > p:first-of-type { margin-top: 0; }
83+.lead { color: var(--muted); margin: 0 0 22px; }
84+
85+/* Messages */
86+.good, .bad, .warn, .info {
87+ padding: 11px 14px; border-radius: 6px;
88+ border: 1px solid transparent; border-left-width: 4px;
89+ margin: 0 0 16px; font-size: 13.5px;
90+}
91+.good { background:#e8f7ef; border-color:#b8e6cd; border-left-color:var(--green); color:#1c6b45; }
92+.bad { background:#fdecee; border-color:#f6c9ce; border-left-color:var(--red); color:#9e2733; }
93+.warn { background:#fdf3e3; border-color:#f3ddb4; border-left-color:var(--amber); color:#8a5c12; }
94+.info { background:#e9f4fa; border-color:#bfdff0; border-left-color:var(--accent);color:#1b5b7d; }
95+
96+/* Checklist */
97+.checks { margin: 0; padding: 0; list-style: none; }
98+.checks li {
99+ display: flex; align-items: flex-start; gap: 10px;
100+ padding: 10px 0; border-bottom: 1px solid var(--line);
101+}
102+.checks li:last-child { border-bottom: 0; }
103+.checks .state { flex: 0 0 20px; font-weight: 700; line-height: 1.5; }
104+.checks .ok { color: var(--green); }
105+.checks .no { color: var(--red); }
106+.checks .opt { color: var(--amber); }
107+.checks .what { flex: 1 1 auto; }
108+.checks .detail { display: block; color: var(--muted); font-size: 12.5px; }
109+
110+/* Forms */
111+.field { margin-bottom: 15px; }
112+.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); gap: 15px; }
113+label.lbl { display: block; margin-bottom: 5px; font-size: 12.5px; font-weight: 600; }
114+input[type=text], input[type=password], input[type=email], input[type=number], select {
115+ width: 100%; padding: 9px 11px;
116+ border: 1px solid var(--line); border-radius: 6px;
117+ background: var(--panel); color: var(--fg);
118+ font: inherit; font-size: 14px; outline: none;
119+}
120+input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(23,162,184,.16); }
121+.hint { margin: 5px 0 0; color: var(--muted); font-size: 12.5px; }
122+
123+/* Buttons */
124+.actions { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; margin-top: 22px; }
125+.btn {
126+ display: inline-flex; align-items: center; gap: 7px;
127+ padding: 9px 17px; border: 1px solid transparent; border-radius: 6px;
128+ background: var(--accent); color: #fff;
129+ font: inherit; font-size: 14px; font-weight: 600;
130+ text-decoration: none; cursor: pointer;
131+}
132+.btn:hover { filter: brightness(1.08); }
133+.btn.ghost { background: transparent; border-color: var(--line); color: var(--fg); }
134+.btn.ghost:hover { background: var(--panel-2); filter: none; }
135+.btn.green { background: var(--green); }
136+.btn:disabled, .btn[aria-disabled="true"] { opacity: .5; pointer-events: none; }
137+
138+/* Bits */
139+code {
140+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
141+ font-size: 12.5px;
142+ background: var(--panel-2); padding: 1px 5px; border-radius: 4px;
143+}
144+pre {
145+ background:#10161e; color:#c3cede; padding:14px; border-radius:6px;
146+ overflow-x:auto; font-size:12.5px; line-height:1.5;
147+}
148+pre code { background: none; padding: 0; color: inherit; }
149+table { width: 100%; border-collapse: collapse; font-size: 13.5px; margin: 10px 0; }
150+th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--line); }
151+th { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; }
152+.foot { margin-top: 20px; color: var(--muted); font-size: 12.5px; text-align: center; }
A install/bootstrap.php +331-0 View file
@@ -0,0 +1,331 @@
1+<?php
2+/**
3+ * ZnoteX installer - shared runtime.
4+ *
5+ * The installer deliberately does NOT boot engine/init.php: at step 1 there is
6+ * no database, no config, possibly no schema. It talks to mysqli directly and
7+ * only loads the engine once there is something to load.
8+ *
9+ * It is also the most dangerous file in the project - it writes configuration
10+ * and grants admin rights - so it refuses to run once the site is installed.
11+ * See install_is_locked().
12+ */
13+
14+if (!defined('ZNOTE_INSTALL')) {
15+ http_response_code(403);
16+ die('Direct access denied.');
17+}
18+
19+/**
20+ * Since PHP 8.1 mysqli throws on error instead of returning false, and "@" does
21+ * not suppress an exception. The installer queries tables that are expected to
22+ * be missing - that is the whole point of the checks - so it asks for the old
23+ * behaviour explicitly rather than wrapping every call in try/catch.
24+ */
25+mysqli_report(MYSQLI_REPORT_OFF);
26+
27+const INSTALL_LOCK = 'installed.lock';
28+const INSTALL_STEPS = array(
29+ 1 => 'Requirements',
30+ 2 => 'Database',
31+ 3 => 'Server',
32+ 4 => 'Schema',
33+ 5 => 'Administrator',
34+ 6 => 'Finish',
35+);
36+
37+// ---------------------------------------------------------------------------
38+// Paths
39+// ---------------------------------------------------------------------------
40+function install_root(): string {
41+ return dirname(__DIR__);
42+}
43+
44+function install_lock_file(): string {
45+ return __DIR__ . '/' . INSTALL_LOCK;
46+}
47+
48+function install_config_file(): string {
49+ return install_root() . '/config.local.php';
50+}
51+
52+// ---------------------------------------------------------------------------
53+// The lock
54+// ---------------------------------------------------------------------------
55+
56+/**
57+ * Why two conditions rather than one:
58+ *
59+ * The lock file alone is not enough - someone restoring a backup or unpacking
60+ * a fresh copy over an installed site would drop it and the installer would
61+ * happily reset the admin account. The database is the second opinion: if the
62+ * znote table already holds a row, this site is installed regardless of what
63+ * the filesystem says.
64+ *
65+ * Returns '' when the installer may run, or a reason when it may not.
66+ */
67+function install_locked_reason(): string {
68+ if (is_file(install_lock_file())) {
69+ return 'This site is already installed. Delete <code>install/' . INSTALL_LOCK
70+ . '</code> if you really mean to run the installer again.';
71+ }
72+
73+ // A wizard already in progress must not be locked out by its own work:
74+ // step 4 creates the znote table, which is exactly what the check below
75+ // looks for. Step 2 has already warned if the database was not empty.
76+ if (!empty($_SESSION['install']) || install_max_step() > 1) {
77+ return '';
78+ }
79+
80+ $config = install_saved_config();
81+ if (!$config) {
82+ return '';
83+ }
84+
85+ $link = @new mysqli($config['sqlHost'], $config['sqlUser'], $config['sqlPassword'], $config['sqlDatabase']);
86+ if ($link->connect_errno) {
87+ return '';
88+ }
89+
90+ $result = @$link->query('SELECT `id` FROM `znote` LIMIT 1');
91+ $rows = ($result !== false) ? $result->num_rows : 0;
92+ $link->close();
93+
94+ if ($rows > 0) {
95+ return 'The database already contains a ZnoteX installation. The installer will not'
96+ . ' overwrite it. Delete the <code>znote</code> table first if that is really what you want.';
97+ }
98+
99+ return '';
100+}
101+
102+// ---------------------------------------------------------------------------
103+// Wizard state
104+//
105+// Kept in the session, so a refresh does not lose the credentials typed two
106+// steps ago. Nothing is written to disk until the final step.
107+// ---------------------------------------------------------------------------
108+function install_state(?array $merge = null): array {
109+ if (!isset($_SESSION['install'])) {
110+ $_SESSION['install'] = array();
111+ }
112+
113+ if ($merge !== null) {
114+ $_SESSION['install'] = array_merge($_SESSION['install'], $merge);
115+ }
116+
117+ return $_SESSION['install'];
118+}
119+
120+function install_get(string $key, $default = '') {
121+ $state = install_state();
122+ return $state[$key] ?? $default;
123+}
124+
125+function install_reset(): void {
126+ unset($_SESSION['install']);
127+}
128+
129+/** Highest step reached, so someone cannot skip ahead by editing the URL. */
130+function install_max_step(?int $reached = null): int {
131+ if ($reached !== null && $reached > (int)($_SESSION['install_max'] ?? 1)) {
132+ $_SESSION['install_max'] = $reached;
133+ }
134+
135+ return (int)($_SESSION['install_max'] ?? 1);
136+}
137+
138+// ---------------------------------------------------------------------------
139+// Config
140+// ---------------------------------------------------------------------------
141+
142+/** Read config.local.php if it exists, else fall back to config.php values. */
143+function install_saved_config(): array {
144+ $keys = array('sqlHost', 'sqlUser', 'sqlPassword', 'sqlDatabase');
145+ $out = array();
146+
147+ foreach (array(install_config_file(), install_root() . '/config.php') as $file) {
148+ if (!is_file($file)) {
149+ continue;
150+ }
151+
152+ $config = array();
153+ // Included in a function so it cannot pollute anything.
154+ @include $file;
155+
156+ foreach ($keys as $key) {
157+ if (!isset($out[$key]) && isset($config[$key])) {
158+ $out[$key] = (string)$config[$key];
159+ }
160+ }
161+ }
162+
163+ return (count($out) === count($keys)) ? $out : array();
164+}
165+
166+/** A connection using the values collected so far, or null. */
167+function install_connect(?string &$error = null): ?mysqli {
168+ $link = @new mysqli(
169+ (string)install_get('sqlHost', '127.0.0.1'),
170+ (string)install_get('sqlUser'),
171+ (string)install_get('sqlPassword'),
172+ (string)install_get('sqlDatabase')
173+ );
174+
175+ if ($link->connect_errno) {
176+ $error = $link->connect_error;
177+ return null;
178+ }
179+
180+ $link->set_charset('utf8mb4');
181+ $link->query("SET collation_connection = 'utf8mb4_general_ci'");
182+
183+ return $link;
184+}
185+
186+// ---------------------------------------------------------------------------
187+// Checks
188+// ---------------------------------------------------------------------------
189+
190+/** Requirements, as [label, ok, detail, fatal]. */
191+function install_requirements(): array {
192+ $checks = array();
193+
194+ $checks[] = array(
195+ 'PHP 8.1 or newer',
196+ PHP_VERSION_ID >= 80100,
197+ 'You are on PHP ' . PHP_VERSION,
198+ true,
199+ );
200+
201+ foreach (array('mysqli' => true, 'curl' => false, 'openssl' => false, 'gd' => false, 'zip' => false) as $ext => $fatal) {
202+ $checks[] = array(
203+ 'Extension: ' . $ext,
204+ extension_loaded($ext),
205+ $fatal ? 'Required' : 'Optional',
206+ $fatal,
207+ );
208+ }
209+
210+ $cache = install_root() . '/engine/cache';
211+ $checks[] = array(
212+ 'engine/cache/ is writable',
213+ is_dir($cache) && is_writable($cache),
214+ $cache,
215+ true,
216+ );
217+
218+ $checks[] = array(
219+ 'The site root is writable',
220+ is_writable(install_root()),
221+ 'Needed to write config.local.php. You can also create it by hand at the last step.',
222+ false,
223+ );
224+
225+ $schema = install_root() . '/SQL/znote_schema.sql';
226+ $checks[] = array(
227+ 'SQL/znote_schema.sql is present',
228+ is_file($schema),
229+ $schema,
230+ true,
231+ );
232+
233+ return $checks;
234+}
235+
236+/**
237+ * Tables the OT server creates, which ZnoteX reads but never creates itself.
238+ * Their absence is what makes the installer refuse to go on.
239+ */
240+function install_server_tables(mysqli $link): array {
241+ $required = array('accounts', 'players');
242+ $optional = array('guilds', 'houses', 'player_deaths', 'players_online');
243+
244+ $present = array();
245+ $result = @$link->query('SHOW TABLES');
246+ if ($result !== false) {
247+ while ($row = $result->fetch_array()) {
248+ $present[strtolower($row[0])] = true;
249+ }
250+ }
251+
252+ $out = array('required' => array(), 'optional' => array(), 'ok' => true);
253+
254+ foreach ($required as $table) {
255+ $found = isset($present[$table]);
256+ $out['required'][$table] = $found;
257+ if (!$found) {
258+ $out['ok'] = false;
259+ }
260+ }
261+ foreach ($optional as $table) {
262+ $out['optional'][$table] = isset($present[$table]);
263+ }
264+
265+ $out['znote_installed'] = isset($present['znote']);
266+
267+ return $out;
268+}
269+
270+// ---------------------------------------------------------------------------
271+// Small view helpers
272+// ---------------------------------------------------------------------------
273+function ih($value): string {
274+ return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
275+}
276+
277+function install_url(int $step): string {
278+ return 'index.php?step=' . $step;
279+}
280+
281+function install_error(string $message): void {
282+ $_SESSION['install_error'] = $message;
283+}
284+
285+function install_take_error(): string {
286+ $error = (string)($_SESSION['install_error'] ?? '');
287+ unset($_SESSION['install_error']);
288+ return $error;
289+}
290+
291+function install_csrf_token(): string {
292+ if (empty($_SESSION['install_csrf']) || !is_string($_SESSION['install_csrf'])) {
293+ $_SESSION['install_csrf'] = bin2hex(random_bytes(32));
294+ }
295+
296+ return $_SESSION['install_csrf'];
297+}
298+
299+function install_csrf_field(): string {
300+ return '<input type="hidden" name="install_csrf" value="' . ih(install_csrf_token()) . '">';
301+}
302+
303+function install_csrf_validate(): bool {
304+ $posted = $_POST['install_csrf'] ?? null;
305+ $token = $_SESSION['install_csrf'] ?? null;
306+
307+ if (!is_string($posted) || $posted === '' || !is_string($token) || $token === '') {
308+ return false;
309+ }
310+
311+ $valid = hash_equals($token, $posted);
312+ if ($valid) {
313+ $_SESSION['install_csrf'] = bin2hex(random_bytes(32));
314+ }
315+
316+ return $valid;
317+}
318+
319+function install_csrf_inject(string $html): string {
320+ if (stripos($html, '<form') === false || stripos($html, 'method') === false) {
321+ return $html;
322+ }
323+
324+ return preg_replace_callback(
325+ '~<form\b(?=[^>]*\bmethod\s*=\s*["\']?post["\']?)[^>]*>~i',
326+ static function (array $match): string {
327+ return $match[0] . "\n" . install_csrf_field();
328+ },
329+ $html
330+ ) ?? $html;
331+}
A install/index.php +112-0 View file
@@ -0,0 +1,112 @@
1+<?php
2+/**
3+ * ZnoteX installer.
4+ *
5+ * Six steps, one router. Reachable at /install/ until the site is installed,
6+ * after which it refuses to run - see install_locked_reason().
7+ *
8+ * Delete this folder once you are done. Nothing else depends on it.
9+ */
10+
11+define('ZNOTE_INSTALL', true);
12+
13+if (PHP_VERSION_ID < 80100) {
14+ die('ZnoteX needs PHP 8.1 or newer. You are on PHP ' . PHP_VERSION . '.');
15+}
16+
17+require_once __DIR__ . '/../engine/session.php';
18+require_once __DIR__ . '/../engine/security.php';
19+znote_session_start();
20+znote_security_boot();
21+
22+require_once __DIR__ . '/bootstrap.php';
23+
24+$locked = install_locked_reason();
25+
26+$step = (int)($_GET['step'] ?? 1);
27+if ($step < 1 || $step > count(INSTALL_STEPS)) {
28+ $step = 1;
29+}
30+
31+// No jumping ahead: the later steps depend on what the earlier ones collected.
32+if ($locked === '' && $step > install_max_step()) {
33+ $step = install_max_step();
34+}
35+
36+if ($locked === '' && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !install_csrf_validate()) {
37+ install_error('Invalid or expired form token. Please refresh the page and try again.');
38+ header('Location: ' . install_url($step));
39+ exit;
40+}
41+
42+// A step handles its own POST and either advances or sets an error.
43+$stepFile = __DIR__ . '/steps/' . $step . '.php';
44+
45+ob_start();
46+if ($locked === '') {
47+ if (is_file($stepFile)) {
48+ include $stepFile;
49+ } else {
50+ echo '<p>Missing installer step file.</p>';
51+ }
52+}
53+$content = ob_get_clean();
54+if ($locked === '') {
55+ $content = install_csrf_inject($content);
56+}
57+
58+$error = install_take_error();
59+?>
60+<!DOCTYPE html>
61+<html lang="en" dir="ltr">
62+<head>
63+ <meta charset="utf-8">
64+ <meta name="viewport" content="width=device-width, initial-scale=1">
65+ <meta name="robots" content="noindex, nofollow">
66+ <title>Install ZnoteX</title>
67+ <link rel="stylesheet" href="assets/install.css">
68+</head>
69+<body>
70+
71+<div class="wrap">
72+
73+ <header class="head">
74+ <div class="brand"><span class="mark">ZX</span> Install ZnoteX</div>
75+ <span class="version">2.0.1</span>
76+ </header>
77+
78+ <?php if ($locked === ''): ?>
79+ <ol class="steps">
80+ <?php foreach (INSTALL_STEPS as $number => $label): ?>
81+ <li class="<?= $number === $step ? 'is-current' : ($number < $step ? 'is-done' : '') ?>">
82+ <span class="num"><?= $number < $step ? '&#10003;' : $number ?></span>
83+ <span class="lbl"><?= ih($label) ?></span>
84+ </li>
85+ <?php endforeach; ?>
86+ </ol>
87+ <?php endif; ?>
88+
89+ <main class="card">
90+ <?php if ($locked !== ''): ?>
91+ <h1>Already installed</h1>
92+ <p class="bad"><?= $locked ?></p>
93+ <p>
94+ <a class="btn" href="../index.php">Go to the site</a>
95+ <a class="btn ghost" href="../admin/index.php">Admin panel</a>
96+ </p>
97+ <?php else: ?>
98+ <?php if ($error !== ''): ?>
99+ <p class="bad"><?= $error ?></p>
100+ <?php endif; ?>
101+ <?= $content ?>
102+ <?php endif; ?>
103+ </main>
104+
105+ <footer class="foot">
106+ Step <?= (int)$step ?> of <?= count(INSTALL_STEPS) ?> &middot;
107+ Delete the <code>install/</code> folder when you are finished.
108+ </footer>
109+</div>
110+
111+</body>
112+</html>
A install/steps/1.php +52-0 View file
@@ -0,0 +1,52 @@
1+<?php
2+/** Step 1 - requirements. Nothing here touches the database. */
3+
4+if (!defined('ZNOTE_INSTALL')) { http_response_code(403); die('Direct access denied.'); }
5+
6+$checks = install_requirements();
7+
8+$blocking = 0;
9+foreach ($checks as $check) {
10+ if (!$check[1] && $check[3]) { $blocking++; }
11+}
12+
13+if ($_SERVER['REQUEST_METHOD'] === 'POST' && $blocking === 0) {
14+ install_max_step(2);
15+ header('Location: ' . install_url(2));
16+ exit;
17+}
18+?>
19+<h1>Requirements</h1>
20+<p class="lead">What this server needs before ZnoteX can run.</p>
21+
22+<ul class="checks">
23+ <?php foreach ($checks as $check):
24+ list($label, $ok, $detail, $fatal) = $check;
25+ $state = $ok ? 'ok' : ($fatal ? 'no' : 'opt');
26+ ?>
27+ <li>
28+ <span class="state <?= $state ?>"><?= $ok ? '&#10003;' : ($fatal ? '&#10007;' : '!') ?></span>
29+ <span class="what">
30+ <?= ih($label) ?>
31+ <?php if (!$ok && !$fatal): ?><em>(optional)</em><?php endif; ?>
32+ <span class="detail"><?= ih($detail) ?></span>
33+ </span>
34+ </li>
35+ <?php endforeach; ?>
36+</ul>
37+
38+<?php if ($blocking > 0): ?>
39+ <p class="bad">
40+ <?= (int)$blocking ?> requirement<?= $blocking === 1 ? '' : 's' ?> not met. Fix
41+ <?= $blocking === 1 ? 'it' : 'them' ?> and reload this page.
42+ </p>
43+<?php else: ?>
44+ <p class="good">Everything required is in place.</p>
45+<?php endif; ?>
46+
47+<form method="post">
48+ <div class="actions">
49+ <button class="btn" type="submit" <?= $blocking > 0 ? 'disabled' : '' ?>>Continue</button>
50+ <a class="btn ghost" href="index.php?step=1">Re-check</a>
51+ </div>
52+</form>
A install/steps/2.php +136-0 View file
@@ -0,0 +1,136 @@
1+<?php
2+/**
3+ * Step 2 - database.
4+ *
5+ * This is where the installer refuses if the OT server's own schema is not
6+ * there. ZnoteX reads `accounts` and `players`; it has never created them and
7+ * will not pretend to. Importing TFS/Canary's schema.sql is the server owner's
8+ * job, and doing it after ZnoteX would overwrite what we are about to write.
9+ */
10+
11+if (!defined('ZNOTE_INSTALL')) { http_response_code(403); die('Direct access denied.'); }
12+
13+$tested = false;
14+$tables = null;
15+$connect = null;
16+
17+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
18+
19+ install_state(array(
20+ 'sqlHost' => trim((string)($_POST['sqlHost'] ?? '127.0.0.1')),
21+ 'sqlUser' => trim((string)($_POST['sqlUser'] ?? '')),
22+ 'sqlPassword' => (string)($_POST['sqlPassword'] ?? ''),
23+ 'sqlDatabase' => trim((string)($_POST['sqlDatabase'] ?? '')),
24+ ));
25+
26+ $tested = true;
27+ $link = install_connect($connect);
28+
29+ if ($link === null) {
30+ install_error('Could not connect: ' . ih((string)$connect));
31+ } else {
32+ $tables = install_server_tables($link);
33+ $link->close();
34+
35+ if (!$tables['ok']) {
36+ install_error(
37+ 'Connected, but this database has no <code>accounts</code> / <code>players</code> tables. '
38+ . 'Import your server\'s own <code>schema.sql</code> first, then come back.'
39+ );
40+ } elseif (!empty($_POST['continue'])) {
41+ install_max_step(3);
42+ header('Location: ' . install_url(3));
43+ exit;
44+ }
45+ }
46+}
47+
48+// Pre-fill from config.php so a normal install is mostly clicking Next.
49+$saved = install_saved_config();
50+$value = static function (string $key, string $fallback = '') use ($saved) {
51+ $fromState = install_get($key, null);
52+ if ($fromState !== null && $fromState !== '') {
53+ return (string)$fromState;
54+ }
55+ return (string)($saved[$key] ?? $fallback);
56+};
57+?>
58+<h1>Database</h1>
59+<p class="lead">
60+ The same database your Open Tibia server uses. ZnoteX adds its own
61+ <code>znote_*</code> tables beside the server's.
62+</p>
63+
64+<form method="post">
65+ <div class="row">
66+ <div class="field">
67+ <label class="lbl" for="sqlHost">Host</label>
68+ <input type="text" id="sqlHost" name="sqlHost" value="<?= ih($value('sqlHost', '127.0.0.1')) ?>" required>
69+ </div>
70+ <div class="field">
71+ <label class="lbl" for="sqlDatabase">Database</label>
72+ <input type="text" id="sqlDatabase" name="sqlDatabase" value="<?= ih($value('sqlDatabase')) ?>" required>
73+ </div>
74+ </div>
75+
76+ <div class="row">
77+ <div class="field">
78+ <label class="lbl" for="sqlUser">User</label>
79+ <input type="text" id="sqlUser" name="sqlUser" value="<?= ih($value('sqlUser')) ?>" required>
80+ <p class="hint">Do not use <code>root</code> on a public server.</p>
81+ </div>
82+ <div class="field">
83+ <label class="lbl" for="sqlPassword">Password</label>
84+ <input type="password" id="sqlPassword" name="sqlPassword" value="<?= ih($value('sqlPassword')) ?>">
85+ </div>
86+ </div>
87+
88+ <?php if ($tables !== null): ?>
89+ <h2>Server schema</h2>
90+
91+ <?php if ($tables['ok']): ?>
92+ <p class="good">Connected, and your server's tables are there.</p>
93+ <?php else: ?>
94+ <div class="warn">
95+ <strong>The server schema is missing.</strong><br>
96+ ZnoteX reads the tables your OT server creates &mdash; it does not create them, and
97+ importing them afterwards would wipe what ZnoteX writes. Import your server's
98+ <code>schema.sql</code> into <code><?= ih(install_get('sqlDatabase')) ?></code>, then
99+ press Test again.
100+ </div>
101+ <?php endif; ?>
102+
103+ <table>
104+ <tr><th>Table</th><th>Status</th><th></th></tr>
105+ <?php foreach ($tables['required'] as $table => $found): ?>
106+ <tr>
107+ <td><code><?= ih($table) ?></code></td>
108+ <td class="<?= $found ? 'ok' : 'no' ?>"><?= $found ? '&#10003; present' : '&#10007; missing' ?></td>
109+ <td>required</td>
110+ </tr>
111+ <?php endforeach; ?>
112+ <?php foreach ($tables['optional'] as $table => $found): ?>
113+ <tr>
114+ <td><code><?= ih($table) ?></code></td>
115+ <td><?= $found ? '&#10003; present' : '&mdash;' ?></td>
116+ <td>optional &mdash; some pages need it</td>
117+ </tr>
118+ <?php endforeach; ?>
119+ </table>
120+
121+ <?php if (!empty($tables['znote_installed'])): ?>
122+ <p class="warn">
123+ A <code>znote</code> table already exists. The next step will not touch tables that
124+ are already there, so nothing you have will be lost.
125+ </p>
126+ <?php endif; ?>
127+ <?php endif; ?>
128+
129+ <div class="actions">
130+ <button class="btn ghost" type="submit" name="test" value="1">Test connection</button>
131+ <?php if ($tables !== null && $tables['ok']): ?>
132+ <button class="btn" type="submit" name="continue" value="1">Continue</button>
133+ <?php endif; ?>
134+ <a class="btn ghost" href="<?= install_url(1) ?>">Back</a>
135+ </div>
136+</form>
A install/steps/3.php +95-0 View file
@@ -0,0 +1,95 @@
1+<?php
2+/** Step 3 - which server this site sits in front of, and how it is named. */
3+
4+if (!defined('ZNOTE_INSTALL')) { http_response_code(403); die('Direct access denied.'); }
5+
6+$engines = array(
7+ 'TFS_10' => 'TFS 1.1 - 1.4.2',
8+ 'TFS_16' => 'TFS 1.6',
9+ 'CANARY' => 'Canary / OTServBR-Global',
10+ 'TFS_03' => 'TFS 0.3.6+ / 0.4 / OTX',
11+ 'TFS_02' => 'TFS 0.2.13+',
12+ 'OTHIRE' => 'OTHire',
13+);
14+
15+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
16+
17+ $engine = (string)($_POST['ServerEngine'] ?? '');
18+ $title = trim((string)($_POST['site_title'] ?? ''));
19+ $url = rtrim(trim((string)($_POST['site_url'] ?? '')), '/');
20+
21+ if (!isset($engines[$engine])) {
22+ install_error('Pick a server engine.');
23+ } elseif ($title === '') {
24+ install_error('The site needs a name.');
25+ } else {
26+ install_state(array(
27+ 'ServerEngine' => $engine,
28+ 'site_title' => $title,
29+ 'site_url' => $url,
30+ 'server_path' => rtrim(trim((string)($_POST['server_path'] ?? '')), '/\\'),
31+ ));
32+ install_max_step(4);
33+ header('Location: ' . install_url(4));
34+ exit;
35+ }
36+}
37+
38+// A sensible default URL, from the request itself.
39+$guessUrl = install_get('site_url');
40+if ($guessUrl === '') {
41+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
42+ $host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
43+ $base = rtrim(dirname((string)($_SERVER['SCRIPT_NAME'] ?? '')), '/\\');
44+ $base = preg_replace('#/install$#', '', $base);
45+ $guessUrl = $scheme . '://' . $host . $base;
46+}
47+?>
48+<h1>Server</h1>
49+<p class="lead">Which engine you run, and how the site introduces itself.</p>
50+
51+<form method="post">
52+ <div class="field">
53+ <label class="lbl" for="ServerEngine">Server engine</label>
54+ <select id="ServerEngine" name="ServerEngine">
55+ <?php foreach ($engines as $key => $label): ?>
56+ <option value="<?= ih($key) ?>" <?= install_get('ServerEngine', 'TFS_10') === $key ? 'selected' : '' ?>>
57+ <?= ih($label) ?>
58+ </option>
59+ <?php endforeach; ?>
60+ </select>
61+ <p class="hint">
62+ TFS 1.0 is not supported. TFS 1.6 and Canary differ from 1.x only in a few column
63+ names, which ZnoteX handles for you.
64+ </p>
65+ </div>
66+
67+ <div class="row">
68+ <div class="field">
69+ <label class="lbl" for="site_title">Site name</label>
70+ <input type="text" id="site_title" name="site_title"
71+ value="<?= ih(install_get('site_title', 'My Open Tibia Server')) ?>" required>
72+ </div>
73+ <div class="field">
74+ <label class="lbl" for="site_url">Site URL</label>
75+ <input type="text" id="site_url" name="site_url" value="<?= ih($guessUrl) ?>">
76+ <p class="hint">Used in e-mails. No trailing slash.</p>
77+ </div>
78+ </div>
79+
80+ <div class="field">
81+ <label class="lbl" for="server_path">Server folder <em>(optional)</em></label>
82+ <input type="text" id="server_path" name="server_path"
83+ value="<?= ih(install_get('server_path')) ?>"
84+ placeholder="C:/forgottenserver or /home/ots">
85+ <p class="hint">
86+ Where <code>data/</code> and <code>config.lua</code> live. Needed by the creature
87+ library, the spell list and the server info page. You can set it later.
88+ </p>
89+ </div>
90+
91+ <div class="actions">
92+ <button class="btn" type="submit">Continue</button>
93+ <a class="btn ghost" href="<?= install_url(2) ?>">Back</a>
94+ </div>
95+</form>
Top