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 Lua/TFS_16/revscriptsys/shopsystem_globalevent.lua +159-0 View file
@@ -0,0 +1,159 @@
1+local globalevent = GlobalEvent("ShopSystemGlobal")
2+
3+function globalevent.onThink(...)
4+ local shopTypes = {1,5,6,7}
5+ local orderQuery = db.storeQuery([[
6+ SELECT
7+ MIN(`po`.`player_id`) AS `player_id`,
8+ `shop`.`id`,
9+ `shop`.`type`,
10+ `shop`.`itemid`,
11+ `shop`.`count`
12+ FROM `players_online` AS `po`
13+ INNER JOIN `players` AS `p`
14+ ON `po`.`player_id` = `p`.`id`
15+ INNER JOIN `znote_shop_orders` AS `shop`
16+ ON `p`.`account_id` = `shop`.`account_id`
17+ WHERE `shop`.`type` IN(]] .. table.concat(shopTypes, ",") .. [[)
18+ GROUP BY `shop`.`id`
19+ ]])
20+ -- Detect if we got any results
21+ if orderQuery ~= false then
22+ local type_desc = {
23+ "itemids",
24+ "pending premium (skip)",
25+ "pending gender change (skip)",
26+ "pending character name change (skip)",
27+ "Outfit and addons",
28+ "Mounts",
29+ "Instant house purchase"
30+ }
31+ repeat
32+ local player_id = result.getNumber(orderQuery, 'player_id')
33+ local orderId = result.getNumber(orderQuery, 'id')
34+ local orderType = result.getNumber(orderQuery, 'type')
35+ local orderItemId = result.getNumber(orderQuery, 'itemid')
36+ local orderCount = result.getNumber(orderQuery, 'count')
37+ local served = false
38+
39+ local player = Player(player_id)
40+ if player ~= nil then
41+
42+ local description = "Unknown or custom type"
43+ if type_desc[orderType] ~= nil then
44+ description = type_desc[orderType]
45+ end
46+ print("Processing type "..orderType..": ".. description)
47+ print("Processing shop order for: [".. player:getName() .."] type "..orderType..": ".. description)
48+
49+ local tile = Tile(player:getPosition())
50+ if tile ~= nil and tile:hasFlag(TILESTATE_PROTECTIONZONE) then
51+ -- ORDER TYPE 1 (Regular item shop products)
52+ if orderType == 1 then
53+ served = true
54+ local itemType = ItemType(orderItemId)
55+ -- Get weight
56+ if player:getFreeCapacity() >= itemType:getWeight(orderCount) then
57+ local backpack = player:getSlotItem(CONST_SLOT_BACKPACK)
58+ -- variable = (condition) and (return if true) or (return if false)
59+ local needslots = itemType:isStackable() and math.floor(orderCount / 100) + 1 or orderCount
60+ if backpack ~= nil and backpack:getEmptySlots(false) >= needslots then
61+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. orderId .. ";")
62+ player:addItem(orderItemId, orderCount)
63+ player:sendTextMessage(MESSAGE_INFO_DESCR, "Congratulations! You have received " .. orderCount .. "x " .. ItemType(orderItemId):getName() .. "!")
64+ print("Process complete. [".. player:getName() .."] has received " .. orderCount .. "x " .. ItemType(orderItemId):getName() .. ".")
65+ else -- not enough slots
66+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "Your main backpack is full. You need to free up "..needslots.." available slots to get " .. orderCount .. " " .. ItemType(orderItemId):getName() .. "!")
67+ print("Process canceled. [".. player:getName() .."] need more space in his backpack to get " .. orderCount .. "x " .. ItemType(orderItemId):getName() .. ".")
68+ end
69+ else -- not enough cap
70+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "You need more CAP to carry this order!")
71+ print("Process canceled. [".. player:getName() .."] need more cap to carry " .. orderCount .. "x " .. ItemType(orderItemId):getName() .. ".")
72+ end
73+ end
74+
75+ -- ORDER TYPE 5 (Outfit and addon)
76+ if orderType == 5 then
77+ served = true
78+
79+ local itemid = orderItemId
80+ local outfits = {}
81+
82+ if itemid > 1000 then
83+ local first = math.floor(itemid/1000)
84+ table.insert(outfits, first)
85+ itemid = itemid - (first * 1000)
86+ end
87+ table.insert(outfits, itemid)
88+
89+ for _, outfitId in pairs(outfits) do
90+ -- Make sure player don't already have this outfit and addon
91+ if not player:hasOutfit(outfitId, orderCount) then
92+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. orderId .. ";")
93+ player:addOutfit(outfitId)
94+ player:addOutfitAddon(outfitId, orderCount)
95+ player:sendTextMessage(MESSAGE_INFO_DESCR, "Congratulations! You have received a new outfit!")
96+ print("Process complete. [".. player:getName() .."] has received outfit: ["..outfitId.."] with addon: ["..orderCount.."]")
97+ else -- Already has outfit
98+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "You already have this outfit and addon!")
99+ print("Process canceled. [".. player:getName() .."] already have outfit: ["..outfitId.."] with addon: ["..orderCount.."].")
100+ end
101+ end
102+ end
103+
104+ -- ORDER TYPE 6 (Mounts)
105+ if orderType == 6 then
106+ served = true
107+ -- Make sure player don't already have this outfit and addon
108+ if not player:hasMount(orderItemId) then
109+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. orderId .. ";")
110+ player:addMount(orderItemId)
111+ player:sendTextMessage(MESSAGE_INFO_DESCR, "Congratulations! You have received a new mount!")
112+ print("Process complete. [".. player:getName() .."] has received mount: ["..orderItemId.."]")
113+ else -- Already has mount
114+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "You already have this mount!")
115+ print("Process canceled. [".. player:getName() .."] already have mount: ["..orderItemId.."].")
116+ end
117+ end
118+
119+ -- ORDER TYPE 7 (Direct house purchase)
120+ if orderType == 7 then
121+ served = true
122+ local house = House(orderItemId)
123+ -- Logged in player is not necessarily the player that bough the house. So we need to load player from db.
124+ local buyerQuery = db.storeQuery("SELECT `name` FROM `players` WHERE `id` = "..orderCount.." LIMIT 1")
125+ if buyerQuery ~= false then
126+ local buyerName = result.getString(buyerQuery, "name")
127+ result.free(buyerQuery)
128+ if house then
129+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. orderId .. ";")
130+ house:setOwnerGuid(orderCount)
131+ player:sendTextMessage(MESSAGE_INFO_DESCR, "You have successfully bought the house "..house:getName().." on "..buyerName..", be sure to have the money for the rent in the bank.")
132+ print("Process complete. [".. buyerName .."] has received house: ["..house:getName().."]")
133+ else
134+ print("Process canceled. Failed to load house with ID: "..orderItemId)
135+ end
136+ else
137+ print("Process canceled. Failed to load player with ID: "..orderCount)
138+ end
139+ end
140+
141+ if not served then -- If this order hasn't been processed yet (missing type handling?)
142+ print("Znote shop: Type ["..orderType.."] not properly processed. Missing Lua code?")
143+ end
144+ else -- Not in protection zone
145+ player:sendTextMessage(MESSAGE_INFO_DESCR, 'You have a pending shop order, please enter protection zone.')
146+ print("Skipped one shop order. Reason: Player: [".. player:getName() .."] is not inside protection zone.")
147+ end
148+ else -- player not logged in
149+ print("Skipped one shop order. Reason: Player with id [".. player_id .."] is not online.")
150+ end
151+
152+ until not result.next(orderQuery)
153+ result.free(orderQuery)
154+ end
155+ return true
156+end
157+
158+globalevent:interval(30000)
159+globalevent:register()
A Lua/TFS_16/revscriptsys/shopsystem_talkaction.lua +135-0 View file
@@ -0,0 +1,135 @@
1+local talkaction = TalkAction("!shop")
2+
3+function talkaction.onSay(player)
4+ local storage = 54073 -- Make sure to select non-used storage. This is used to prevent SQL load attacks.
5+ local cooldown = 15 -- in seconds.
6+
7+ if player:getStorageValue(storage) <= os.time() then
8+ player:setStorageValue(storage, os.time() + cooldown)
9+
10+ local type_desc = {
11+ "itemids",
12+ "pending premium (skip)",
13+ "pending gender change (skip)",
14+ "pending character name change (skip)",
15+ "Outfit and addons",
16+ "Mounts",
17+ "Instant house purchase"
18+ }
19+ print("Player: " .. player:getName() .. " triggered !shop talkaction.")
20+ -- Create the query
21+ local orderQuery = db.storeQuery("SELECT `id`, `type`, `itemid`, `count` FROM `znote_shop_orders` WHERE `account_id` = " .. player:getAccountId() .. ";")
22+ local served = false
23+
24+ -- Detect if we got any results
25+ if orderQuery ~= false then
26+ repeat
27+ -- Fetch order values
28+ local q_id = result.getNumber(orderQuery, "id")
29+ local q_type = result.getNumber(orderQuery, "type")
30+ local q_itemid = result.getNumber(orderQuery, "itemid")
31+ local q_count = result.getNumber(orderQuery, "count")
32+
33+ local description = "Unknown or custom type"
34+ if type_desc[q_type] ~= nil then
35+ description = type_desc[q_type]
36+ end
37+ print("Processing type "..q_type..": ".. description)
38+
39+ -- ORDER TYPE 1 (Regular item shop products)
40+ if q_type == 1 then
41+ served = true
42+ -- Get weight
43+ if player:getFreeCapacity() >= ItemType(q_itemid):getWeight(q_count) then
44+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. q_id .. ";")
45+ player:addItem(q_itemid, q_count)
46+ player:sendTextMessage(MESSAGE_INFO_DESCR, "Congratulations! You have received " .. q_count .. " x " .. ItemType(q_itemid):getName() .. "!")
47+ else
48+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "Need more CAP!")
49+ end
50+ end
51+
52+ -- ORDER TYPE 5 (Outfit and addon)
53+ if q_type == 5 then
54+ served = true
55+
56+ local itemid = q_itemid
57+ local outfits = {}
58+
59+ if itemid > 1000 then
60+ local first = math.floor(itemid/1000)
61+ table.insert(outfits, first)
62+ itemid = itemid - (first * 1000)
63+ end
64+ table.insert(outfits, itemid)
65+
66+ for _, outfitId in pairs(outfits) do
67+ -- Make sure player don't already have this outfit and addon
68+ if not player:hasOutfit(outfitId, q_count) then
69+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. q_id .. ";")
70+ player:addOutfit(outfitId)
71+ player:addOutfitAddon(outfitId, q_count)
72+ player:sendTextMessage(MESSAGE_INFO_DESCR, "Congratulations! You have received a new outfit!")
73+ else
74+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "You already have this outfit and addon!")
75+ end
76+ end
77+ end
78+
79+ -- ORDER TYPE 6 (Mounts). itemid holds the mount id.
80+ if q_type == 6 then
81+ served = true
82+ -- Make sure player don't already have this mount
83+ if not player:hasMount(q_itemid) then
84+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. q_id .. ";")
85+ player:addMount(q_itemid)
86+ player:sendTextMessage(MESSAGE_INFO_DESCR, "Congratulations! You have received a new mount!")
87+ else
88+ player:sendTextMessage(MESSAGE_STATUS_WARNING, "You already have this mount!")
89+ end
90+ end
91+
92+ -- ORDER TYPE 7 (Direct house purchase)
93+ if q_type == 7 then
94+ served = true
95+ local house = House(q_itemid)
96+ -- Logged in player is not necessarily the player that bough the house. So we need to load player from db.
97+ local buyerQuery = db.storeQuery("SELECT `name` FROM `players` WHERE `id` = "..q_count.." LIMIT 1")
98+ if buyerQuery ~= false then
99+ local buyerName = result.getString(buyerQuery, "name")
100+ result.free(buyerQuery)
101+ if house then
102+ db.query("DELETE FROM `znote_shop_orders` WHERE `id` = " .. q_id .. ";")
103+ house:setOwnerGuid(q_count)
104+ player:sendTextMessage(MESSAGE_INFO_DESCR, "You have successfully bought the house "..house:getName().." on "..buyerName..", be sure to have the money for the rent in the bank.")
105+ print("Process complete. [".. buyerName .."] has received house: ["..house:getName().."]")
106+ end
107+ end
108+ end
109+
110+ -- Add custom order types here
111+ -- Type 1 is for itemids (Already coded here)
112+ -- Type 2 is for premium (Coded on web)
113+ -- Type 3 is for gender change (Coded on web)
114+ -- Type 4 is for character name change (Coded on web)
115+ -- Type 5 is for character outfit and addon (Already coded here)
116+ -- Type 6 is for mounts (Already coded here)
117+ -- Type 7 is for Instant house purchase (Already coded here)
118+ -- So use type 8+ for custom stuff, like etc packages.
119+ -- if q_type == 8 then
120+ -- end
121+ until not result.next(orderQuery)
122+ result.free(orderQuery)
123+ if not served then
124+ player:sendTextMessage(MESSAGE_STATUS_CONSOLE_BLUE, "You have no orders to process in-game.")
125+ end
126+ else
127+ player:sendTextMessage(MESSAGE_STATUS_CONSOLE_BLUE, "You have no orders.")
128+ end
129+ else
130+ player:sendTextMessage(MESSAGE_STATUS_CONSOLE_BLUE, "Can only be executed once every " .. cooldown .. " seconds. Remaining cooldown: " .. player:getStorageValue(storage) - os.time())
131+ end
132+ return false
133+end
134+
135+talkaction:register()
A Lua/TFS_16/revscriptsys/sync_outfit.lua +57-0 View file
@@ -0,0 +1,57 @@
1+-- Sync the full outfits (all 3 addons) a character owns into Znote AAC storage,
2+-- so characterprofile.php can show them. TFS 1.x revscriptsys.
3+--
4+-- looktype lists below are the complete Female / Male outfit sets from the
5+-- current opentibiabr/canary data/XML/outfits.xml. Trim them to your client's
6+-- outfits.xml, or regenerate from that file if you add custom outfits.
7+
8+znote_outfit_list = {
9+ { -- Female outfits (127)
10+ 136, 137, 138, 139, 140, 141, 142, 147, 148, 149,
11+ 150, 155, 156, 157, 158, 252, 269, 270, 279, 288,
12+ 324, 329, 336, 366, 431, 433, 464, 466, 471, 513,
13+ 514, 542, 575, 578, 618, 620, 632, 635, 636, 664,
14+ 666, 683, 694, 696, 698, 724, 732, 745, 749, 759,
15+ 845, 852, 874, 885, 900, 909, 929, 956, 958, 963,
16+ 965, 967, 969, 971, 973, 975, 1020, 1024, 1043, 1050,
17+ 1057, 1070, 1095, 1103, 1128, 1147, 1162, 1174, 1187, 1203,
18+ 1205, 1207, 1211, 1244, 1246, 1252, 1271, 1280, 1283, 1289,
19+ 1293, 1323, 1332, 1339, 1372, 1383, 1385, 1387, 1416, 1437,
20+ 1445, 1450, 1456, 1461, 1490, 1501, 1569, 1576, 1582, 1598,
21+ 1613, 1619, 1663, 1676, 1681, 1714, 1723, 1726, 1746, 1775,
22+ 1777, 1808, 1825, 1832, 1838, 1860, 1861
23+ },
24+ { -- Male outfits (125)
25+ 128, 129, 130, 131, 132, 133, 134, 143, 144, 145,
26+ 146, 151, 152, 153, 154, 251, 268, 273, 278, 289,
27+ 325, 328, 335, 367, 430, 432, 463, 465, 472, 512,
28+ 516, 541, 574, 577, 610, 619, 633, 634, 637, 665,
29+ 667, 684, 695, 697, 699, 725, 733, 746, 750, 760,
30+ 846, 853, 873, 884, 899, 908, 931, 955, 957, 962,
31+ 964, 966, 968, 970, 972, 974, 1021, 1023, 1042, 1051,
32+ 1056, 1069, 1094, 1102, 1127, 1146, 1161, 1173, 1186, 1202,
33+ 1204, 1206, 1210, 1243, 1245, 1251, 1270, 1279, 1282, 1288,
34+ 1292, 1322, 1331, 1338, 1371, 1382, 1384, 1386, 1415, 1436,
35+ 1444, 1449, 1457, 1460, 1489, 1500, 1568, 1575, 1581, 1597,
36+ 1612, 1618, 1662, 1675, 1680, 1713, 1722, 1725, 1745, 1774,
37+ 1776, 1809, 1824, 1831, 1837
38+ }
39+}
40+
41+local syncOutfit = CreatureEvent("ZnoteSyncOutfit")
42+
43+function syncOutfit.onLogin(player)
44+ -- storage_value .. storage_value + highest look type must be free.
45+ -- Must match $config['EQ_shower'] in the Znote AAC config.php.
46+ local storage_value = 10000
47+ for _, lookType in ipairs(znote_outfit_list[player:getSex() + 1]) do
48+ if player:hasOutfit(lookType, 3) then
49+ if player:getStorageValue(storage_value + lookType) ~= 3 then
50+ player:setStorageValue(storage_value + lookType, 3)
51+ end
52+ end
53+ end
54+ return true
55+end
56+
57+syncOutfit:register()
A Lua/TFS_16/revscriptsys/znote_login.lua +68-0 View file
@@ -0,0 +1,68 @@
1+-- Znote LoginWebService (version 1) for protocol 11, 12+
2+-- Move file to this location: data/scripts/znote_login.lua
3+-- And restart OT server, it should auto load script.
4+-- Requires updated version of Znote AAC. (18. June 2020)
5+-- This script will help Znote AAC connect players to this game server.
6+
7+local znote_loginWebService = GlobalEvent("znote_loginWebService")
8+function znote_loginWebService.onStartup()
9+ print(" ")
10+ print("=============================")
11+ print("= Znote AAC loginWebService =")
12+ print("=============================")
13+ local configLua = {
14+ ["SERVER_NAME"] = configManager.getString(configKeys.SERVER_NAME),
15+ ["IP"] = configManager.getString(configKeys.IP),
16+ ["GAME_PORT"] = configManager.getNumber(configKeys.GAME_PORT)
17+ }
18+ local configSQL = {
19+ ["SERVER_NAME"] = false,
20+ ["IP"] = false,
21+ ["GAME_PORT"] = false
22+ }
23+ local webStorage = db.storeQuery([[
24+ SELECT
25+ `key`,
26+ `value`
27+ FROM `znote_global_storage`
28+ WHERE `key` IN('SERVER_NAME', 'IP', 'GAME_PORT')
29+ ]])
30+ if webStorage ~= false then
31+ repeat
32+ local key = result.getString(webStorage, 'key')
33+ local value = result.getString(webStorage, 'value')
34+ configSQL[key] = value
35+ until not result.next(webStorage)
36+ result.free(webStorage)
37+ end
38+ local inserts = {}
39+ if configSQL.SERVER_NAME == false then
40+ table.insert(inserts, "('SERVER_NAME',".. db.escapeString(configLua.SERVER_NAME) ..")")
41+ elseif configSQL.SERVER_NAME ~= configLua.SERVER_NAME then
42+ db.query("UPDATE `znote_global_storage` SET `value`=".. db.escapeString(configLua.SERVER_NAME) .." WHERE `key`='SERVER_NAME';")
43+ print("= Updated [SERVER_NAME] FROM [" .. configSQL.SERVER_NAME .. "] to [" .. configLua.SERVER_NAME .. "]")
44+ end
45+ if configSQL.IP == false then
46+ table.insert(inserts, "('IP',".. db.escapeString(configLua.IP) ..")")
47+ elseif configSQL.IP ~= configLua.IP then
48+ db.query("UPDATE `znote_global_storage` SET `value`=".. db.escapeString(configLua.IP) .." WHERE `key`='IP';")
49+ print("= Updated [IP] FROM [" .. configSQL.IP .. "] to [" .. configLua.IP .. "]")
50+ end
51+ if configSQL.GAME_PORT == false then
52+ table.insert(inserts, "('GAME_PORT',".. db.escapeString(configLua.GAME_PORT) ..")")
53+ elseif configSQL.GAME_PORT ~= tostring(configLua.GAME_PORT) then
54+ db.query("UPDATE `znote_global_storage` SET `value`=".. db.escapeString(configLua.GAME_PORT) .." WHERE `key`='GAME_PORT';")
55+ print("= Updated [GAME_PORT] FROM [" .. configSQL.GAME_PORT .. "] to [" .. configLua.GAME_PORT .. "]")
56+ end
57+ if #inserts > 0 then
58+ db.query("INSERT INTO `znote_global_storage` (`key`,`value`) VALUES "..table.concat(inserts,',')..";")
59+ print("= Fixed " .. #inserts .. " missing configurations.")
60+ end
61+ print("=============================")
62+ print("= SERVER_NAME: " .. configLua.SERVER_NAME)
63+ print("= IP: " .. configLua.IP)
64+ print("= GAME_PORT: " .. configLua.GAME_PORT)
65+ print("=============================")
66+ print(" ")
67+end
68+znote_loginWebService:register()
A mailtest.php +3-0 View file
@@ -0,0 +1,3 @@
1+<?php
2+//mail('TEST', 'Hello!', 'Hello, this is a test email.', 'From: Znote OT AAC.');
3+?>
A market.php +77-0 View file
@@ -0,0 +1,77 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+$server = $config['shop']['imageServer'];
4+$imageType = $config['shop']['imageType'];
5+$items = getItemList();
6+$compare = &$_GET['compare'];
7+
8+$marketLoadError = '';
9+$marketMode = 'list';
10+$offers = array();
11+$historyOffers = array();
12+$activeSellOffers = array();
13+$buylist = false;
14+$itemname = '';
15+
16+// If we failed to load items.xml, a string is returned (not an array) with
17+// the attempted loaded file path.
18+if (is_array($items) === false) {
19+ $marketLoadError = (string)$items;
20+} elseif (!$compare) {
21+ // If you are not comparing any items, present the list.
22+ $cache = new Cache('engine/cache/market');
23+ $cache->setExpiration(60);
24+ if ($cache->hasExpired()) {
25+ $offers = array(
26+ 'wts' => db()->fetchAll("SELECT `mo`.`id`, `mo`.`itemtype` AS `item_id`, `mo`.`amount`, `mo`.`price`, `mo`.`created`, `mo`.`anonymous`, `p`.`name` AS `player_name` FROM `market_offers` AS `mo` INNER JOIN `players` AS `p` ON `mo`.`player_id`=`p`.`id` WHERE `mo`.`sale` = '1' ORDER BY `mo`.`created` DESC;"),
27+ 'wtb' => db()->fetchAll("SELECT `mo`.`id`, `mo`.`itemtype` AS `item_id`, `mo`.`amount`, `mo`.`price`, `mo`.`created`, `mo`.`anonymous`, `p`.`name` AS `player_name` FROM `market_offers` AS `mo` INNER JOIN `players` AS `p` ON `mo`.`player_id`=`p`.`id` WHERE `mo`.`sale` = '0' ORDER BY `mo`.`created` DESC;")
28+ );
29+ $cache->setContent($offers);
30+ $cache->save();
31+ } else {
32+ $offers = $cache->load();
33+ }
34+} else {
35+ // Else you want to compare price.
36+ $marketMode = 'compare';
37+ $compare = ((int)$compare > 0) ? (int)$compare : getValue($compare);
38+
39+ $conditionSql = '`itemtype` = ?';
40+ $conditionParams = [$compare];
41+
42+ if (is_string($compare)) {
43+ $query = array();
44+ foreach ($items as $id => $name) {
45+ if (strpos(strtolower($name), stripslashes(strtolower($compare))) !== false) {
46+ $query[] = (int)$id;
47+ }
48+ }
49+ if (!empty($query)) {
50+ $conditionSql = '`itemtype` IN (' . implode(',', array_fill(0, count($query), '?')) . ')';
51+ $conditionParams = $query;
52+ } else {
53+ $conditionSql = false;
54+ }
55+ }
56+
57+ // First list active bids.
58+ if ($conditionSql !== false) {
59+ $offers = db()->fetchAll("SELECT `mo`.`id`, `mo`.`sale`, `mo`.`itemtype` AS `item_id`, `mo`.`amount`, `mo`.`price`, `mo`.`created`, `mo`.`anonymous`, `p`.`name` AS `player_name` FROM `market_offers` AS `mo` INNER JOIN `players` AS `p` ON `mo`.`player_id`=`p`.`id` WHERE `mo`.{$conditionSql} ORDER BY `mo`.`price` ASC;", $conditionParams);
60+ $historyOffers = db()->fetchAll("SELECT `id`, `itemtype` AS `item_id`, `amount`, `price`, `inserted`, `expires_at` FROM `market_history` WHERE {$conditionSql} AND `state`='255' ORDER BY `price` ASC;", $conditionParams);
61+ }
62+
63+ $itemname = (isset($items[$compare])) ? $items[$compare] : $compare;
64+
65+ // Split active offers into sell offers and the want-to-buy list.
66+ foreach (($offers ? $offers : array()) as $o) {
67+ if ($o['sale'] == 0) {
68+ if ($buylist === false) $buylist = array();
69+ $buylist[] = $o;
70+ } else {
71+ $activeSellOffers[] = $o;
72+ }
73+ }
74+}
75+
76+view('market');
77+theme_close();
A monster_loot.php +147-0 View file
@@ -0,0 +1,147 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+/**
4+ * Monster loot checker.
5+ *
6+ * Reads the same data the admin panel collects under Server Info: items.xml for
7+ * the item names, and the monster folder for the loot trees. Flattens every
8+ * monster's nested loot into plain rows, so the view only has to print them:
9+ *
10+ * $monsterLootError message when the data could not be read, else ''
11+ * $itemList [item id => item name]
12+ * $rarity [label => minimum percent]
13+ * $monsterList one entry per monster:
14+ * name monster name
15+ * state 'loot' | 'empty' | 'failed'
16+ * file the monster file, for the 'failed' message
17+ * loot rows of [level, id, count, chance]
18+ */
19+
20+// In percent (highest first).
21+$rarity = array(
22+ 'Not Rare' => 7,
23+ 'Semi Rare' => 2,
24+ 'Rare' => 0.5,
25+ 'Very Rare' => 0
26+);
27+
28+$monsterLootError = '';
29+$itemList = array();
30+$monsterList = array();
31+
32+/** Flatten one monster's nested <item> tree into rows. */
33+function znote_flatten_loot($loot, int $level, array &$rows): void {
34+ if (empty($loot)) {
35+ return;
36+ }
37+
38+ foreach ($loot as $entry) {
39+ $chance = (float)$entry['chance'];
40+ if (!$chance) {
41+ $chance = (float)$entry['chance1'];
42+ }
43+
44+ $rows[] = array(
45+ 'level' => $level,
46+ 'id' => (int)$entry['id'],
47+ 'count' => (int)$entry['countmax'],
48+ 'chance' => $chance / 1000,
49+ );
50+
51+ // The nested <item> children of this entry - a bag's contents. Recursing
52+ // per child and then reading that child's own ->item skipped a level,
53+ // so bag contents never reached the page.
54+ if (isset($entry->item)) {
55+ znote_flatten_loot($entry->item, $level + 1, $rows);
56+ }
57+ }
58+}
59+
60+// Parsing every monster file is far too slow to repeat per visitor, so the
61+// flattened result is cached at rate 1 and the loot rate applied on the way
62+// out. The admin panel drops this cache whenever items or monsters change.
63+$cache = new Cache('engine/cache/monster_loot');
64+$cache->useMemory(false);
65+$cache->setExpiration(PHP_INT_MAX);
66+
67+$loaded = $cache->load();
68+
69+if (is_array($loaded) && isset($loaded['items'], $loaded['monsters'])) {
70+
71+ $itemList = $loaded['items'];
72+ $monsterList = $loaded['monsters'];
73+
74+} else {
75+
76+ $itemsFile = serverdata_file('items.xml');
77+ $items = is_file($itemsFile) ? @simplexml_load_file($itemsFile) : false;
78+
79+ if ($items === false) {
80+ $monsterLootError = 'No items.xml yet. Upload one in the admin panel, under Server Info.';
81+ } else {
82+
83+ foreach ($items->item as $item) {
84+ $itemList[(int)$item['id']] = (string)$item['name'];
85+ }
86+
87+ $source = serverdata_creature_source();
88+ $monsters = is_file($source['index']) ? @simplexml_load_file($source['index']) : false;
89+
90+ if ($monsters === false) {
91+ $monsterLootError = 'No monsters.xml at ' . $source['label'] . '. Upload your data/monster/ folder as a .zip in the admin panel, under Server Info.';
92+ } else {
93+
94+ foreach ($monsters->monster as $monster) {
95+ $file = (string)$monster['file'];
96+ $loot = ($file !== '' && strpos($file, '..') === false)
97+ ? @simplexml_load_file($source['dir'] . '/' . $file)
98+ : false;
99+
100+ if ($loot === false) {
101+ $monsterList[] = array(
102+ 'name' => (string)$monster['name'],
103+ 'state' => 'failed',
104+ 'file' => $file,
105+ 'loot' => array(),
106+ );
107+ continue;
108+ }
109+
110+ $rows = array();
111+ if (isset($loot->loot->item)) {
112+ znote_flatten_loot($loot->loot->item, 1, $rows);
113+ }
114+
115+ $monsterList[] = array(
116+ 'name' => (string)$monster['name'],
117+ 'state' => $rows ? 'loot' : 'empty',
118+ 'file' => $file,
119+ 'loot' => $rows,
120+ );
121+ }
122+
123+ $cache->setContent(array('items' => $itemList, 'monsters' => $monsterList));
124+ $cache->save();
125+ }
126+ }
127+}
128+
129+// The server's own loot rate, from the imported config.lua.
130+if (isset($_GET['lootrate'])) {
131+ $luaConfig = serverdata_load('config');
132+ $lootRate = (is_array($luaConfig) && isset($luaConfig['rateLoot'])) ? (float)$luaConfig['rateLoot'] : 1;
133+
134+ if ($lootRate > 0 && $lootRate != 1) {
135+ foreach ($monsterList as &$monster) {
136+ foreach ($monster['loot'] as &$drop) {
137+ $drop['chance'] *= $lootRate;
138+ }
139+ unset($drop);
140+ }
141+ unset($monster);
142+ }
143+}
144+
145+view('monster_loot');
146+
147+theme_close();
A myaccount.php +339-0 View file
@@ -0,0 +1,339 @@
1+<?php require_once 'engine/init.php';
2+protect_page();
3+theme_open();
4+#region CANCEL CHARACTER DELETE
5+$undelete_id = $_GET['cancel_delete_id'] ?? null;
6+if($undelete_id) {
7+ $undelete_id = (int)$undelete_id;
8+ $undelete_q1 = db()->fetchOne("
9+ SELECT
10+ `character_name`
11+ FROM `znote_deleted_characters`
12+ WHERE `done` = 0
13+ AND `id` = ?
14+ AND `original_account_id` = ?
15+ AND NOW() < `time`
16+ ", [$undelete_id, (int)$session_user_id]);
17+ if($undelete_q1) {
18+ db()->execute('DELETE FROM `znote_deleted_characters` WHERE `id` = ?', [$undelete_id]);
19+ echo t('acc.delete_cancelled', ['name' => $undelete_q1['character_name']]) .'<br/>';
20+ }
21+}
22+#endregion
23+
24+// Variable used to check if main page should be rendered after handling POST
25+$render_page = true;
26+
27+// Handle GET (verify email)
28+if (isset($_GET['authenticate']) && $config['mailserver']['myaccount_verify_email']):
29+ // If we need to process email verification
30+ if (isset($_GET['u']) && isset($_GET['k'])) {
31+ // Authenticate user, fetch user id and activation key
32+ $auid = (isset($_GET['u']) && (int)$_GET['u'] > 0) ? (int)$_GET['u'] : false;
33+ $akey = (isset($_GET['k']) && (int)$_GET['k'] > 0) ? (int)$_GET['k'] : false;
34+ if ($auid !== false && $akey !== false) {
35+ // Find a match
36+ $user = db()->fetchOne(
37+ "SELECT `id`, `active`, `active_email` FROM `znote_accounts` WHERE `account_id` = ? AND `activekey` = ? LIMIT 1;",
38+ [$auid, $akey]
39+ );
40+ if ($user !== false) {
41+ $userId = (int)$user['id'];
42+ $active = (int)$user['active'];
43+ $active_email = (int)$user['active_email'];
44+ $verify_points = ($active_email == 0 && $config['mailserver']['verify_email_points'] > 0)
45+ ? (int)$config['mailserver']['verify_email_points']
46+ : 0;
47+ // Enable the account to login
48+ if ($active == 0 || $active_email == 0) {
49+ $new_activeKey = rand(100000000, 999999999);
50+ db()->execute(
51+ "UPDATE `znote_accounts`
52+ SET `active` = 1, `active_email` = 1, `activekey` = ?, `points` = `points` + ?
53+ WHERE `id` = ?
54+ LIMIT 1;",
55+ [$new_activeKey, $verify_points, $userId]
56+ );
57+ }
58+ echo '<h1>'. t('common.congrats') .'</h1> <p>'. t('acc.email_verified') .'</p>';
59+ if ($verify_points > 0) echo "<p>" . t('acc.verify_reward', ['points' => "<a href='/shop.php'>{$verify_points} " . t('char.shop_points2') . "</a>"]) . "</p>";
60+ $user_znote_data['active_email'] = 1;
61+ $user_znote_data['points'] = (int)$user_znote_data['points'] + $verify_points;
62+ } else {
63+ echo '<h1>'. t('acc.auth_failed'). '</h1> <p>' . t('acc.auth_failed_or_activated') . '</p>';
64+ }
65+ } else {
66+ echo '<h1>'. t('acc.auth_failed') .'</h1> <p>'. t('acc.auth_failed_text') .'</p>';
67+ }
68+ } else { // We need to send email verification
69+ $verify_account_id = (int)$session_user_id;
70+ $user = db()->fetchOne(
71+ "SELECT `id`, `activekey`, `active_email` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1;",
72+ [$verify_account_id]
73+ );
74+ if ($user !== false) {
75+ $thisurl = config('site_url') . "/myaccount.php";
76+ $thisurl .= "?authenticate&u=".$verify_account_id."&k=".$user['activekey'];
77+
78+ $mailer = new Mail($config['mailserver']);
79+
80+ $title = t('acc.mail_subject_verify', ['host' => $_SERVER['HTTP_HOST']]);
81+
82+ $body = '<h1>' . t('acc.mail_verify_intro') . '</h1>';
83+ $body .= "<p><a href='{$thisurl}'>{$thisurl}</a></p>";
84+ $body .= '<p>' . t('acc.mail_verify_thanks', ['site' => $config['mailserver']['fromName']]) . '</p>';
85+ $body .= '<hr><p>' . t('recovery.mail_noreply') . '</p>';
86+
87+ $user_name = (znote_server_adapter()->accountIdentityColumn() !== 'id') ? $user_data['name'] : $user_data['id'];
88+ //echo "<h1>" . $title . "<h1>" . $body;
89+ $mailer->sendMail($user_data['email'], $title, $body, $user_name);
90+ ?>
91+ <h1><?= t('acc.email_sent') ?></h1>
92+ <p><?= t('acc.verify_sent_intro') ?> <strong><?php echo $user_data['email']; ?></strong></p>
93+ <p><?= t('acc.check_junk_spam') ?></p>
94+ <?php
95+ } else {
96+ echo '<h1>'. t('acc.auth_failed'). '</h1> <p>' . t('acc.verify_send_failed') . '</p>';
97+ }
98+ }
99+endif;
100+
101+// Handle POST
102+if (!empty($_POST['selected_character'])) {
103+ if (!empty($_POST['action'])) {
104+ // Validate token
105+ if (!Token::isValid($_POST['token'])) {
106+ exit();
107+ }
108+ // Sanitize values
109+ $action = getValue($_POST['action'] ?? null);
110+ $char_name = getValue($_POST['selected_character'] ?? null);
111+
112+ // Handle actions
113+ switch($action) {
114+ // Change character comment PAGE2 (Success).
115+ case 'update_comment':
116+ if ((int)user_character_account_id($char_name) === $session_user_id) {
117+ user_update_comment(user_character_id($char_name), getValue($_POST['comment'] ?? null));
118+ echo t('acc.comment_updated');
119+ }
120+ break;
121+ // end
122+
123+ // Hide character
124+ case 'toggle_hide':
125+ $hide = (user_character_hide($char_name) == 1 ? 0 : 1);
126+ if ((int)user_character_account_id($char_name) === $session_user_id) {
127+ user_character_set_hide(user_character_id($char_name), $hide);
128+ }
129+ break;
130+ // end
131+
132+ // DELETE character
133+ case 'delete_character':
134+ if ((int)user_character_account_id($char_name) === $session_user_id) {
135+ $charid = user_character_id($char_name);
136+ if ($charid !== false) {
137+ if (!user_is_online_10($charid)) {
138+ if (guild_leader_gid($charid) === false) user_delete_character_soft($charid);
139+ else echo t('acc.is_guild_leader');
140+ } else echo t('acc.must_be_offline');
141+ }
142+ }
143+ break;
144+ // end
145+
146+ // CHANGE character name
147+ case 'change_name':
148+ $oldname = $char_name;
149+ $newname = isset($_POST['newName']) ? getValue($_POST['newName'] ?? null) : '';
150+
151+ $player = db()->fetchOne("SELECT `id`, `account_id` FROM `players` WHERE `name` = ? LIMIT 1;", [$oldname]);
152+ if ($player === false) {
153+ $errors[] = t('acc.sync_failed');
154+ echo '<font color="red"><b>';
155+ echo output_errors($errors);
156+ echo '</b></font>';
157+ break;
158+ }
159+ $player['online'] = (user_is_online_10($player['id'])) ? 1 : 0;
160+
161+ // Check if user is online
162+ if ($player['online'] == 1) {
163+ $errors[] = t('acc.must_be_offline');
164+ }
165+
166+ // Check if player has bough ticket
167+ $accountId = $player['account_id'];
168+ $order = db()->fetchOne(
169+ "SELECT `id`, `account_id` FROM `znote_shop_orders` WHERE `type` = 4 AND `account_id` = ? LIMIT 1;",
170+ [(int)$accountId]
171+ );
172+ if ($order === false) {
173+ $errors[] = t('acc.no_name_tickets');
174+ }
175+
176+ // Check if player and account matches
177+ if ($order !== false && ($session_user_id != $accountId || $session_user_id != $order['account_id'])) {
178+ if (empty($errors)) {
179+ $errors[] = t('acc.sync_failed');
180+ }
181+ }
182+
183+ $newname = validate_name($newname);
184+ if ($newname === false) {
185+ $errors[] = t('acc.name_max_words');
186+ } else {
187+ if (empty($newname)) {
188+ $errors[] = t('acc.name_required');
189+ } else if (user_character_exist($newname) !== false) {
190+ $errors[] = t('acc.name_taken');
191+ } else if (!preg_match("/^[a-zA-Z_ ]+$/", $newname)) {
192+ $errors[] = t('acc.name_letters');
193+ } else if (strlen($newname) < $config['minL'] || strlen($newname) > $config['maxL']) {
194+ $errors[] = t('acc.name_length', ['min' => $config['minL'], 'max' => $config['maxL']]);
195+ } else if (!ctype_upper($newname[0])) {
196+ $errors[] = t('acc.name_capital');
197+ }
198+
199+ // name restriction
200+ $resname = explode(" ", $_POST['newName']);
201+ foreach($resname as $res) {
202+ if(in_array(strtolower($res), $config['invalidNameTags'])) {
203+ $errors[] = t('reg.restricted_word');
204+ } else if(strlen($res) == 1) {
205+ $errors[] = t('reg.words_too_short');
206+ }
207+ }
208+ }
209+
210+ if (!empty($newname) && empty($errors)) {
211+ $db = db();
212+ if (!$db->beginTransaction()) {
213+ $errors[] = t('acc.sync_failed');
214+ } else {
215+ $ok = $db->execute("UPDATE `players` SET `name` = ? WHERE `id` = ? LIMIT 1;", [$newname, (int)$player['id']]);
216+ $ok = $ok && $db->execute("DELETE FROM `znote_shop_orders` WHERE `id` = ? LIMIT 1;", [(int)$order['id']]);
217+
218+ if ($ok) {
219+ $db->commit();
220+ echo t('acc.name_changed', ['name' => $newname]);
221+ } else {
222+ $db->rollback();
223+ $errors[] = t('acc.sync_failed');
224+ }
225+ }
226+
227+ }
228+
229+ if (!empty($errors)) {
230+ echo '<font color="red"><b>';
231+ echo output_errors($errors);
232+ echo '</b></font>';
233+ }
234+
235+ break;
236+ // end
237+
238+ // Change character sex
239+ case 'change_gender':
240+ if ((int)user_character_account_id($char_name) === $session_user_id) {
241+ $char_id = (int)user_character_id($char_name);
242+ $account_id = user_character_account_id($char_name);
243+
244+ $chr_data['online'] = user_is_online_10($char_id) ? 1 : 0;
245+ if ($chr_data['online'] != 1) {
246+ // Verify that we are not messing around with data
247+ if ($account_id != $user_data['id']) die("wtf? Something went wrong, try relogging.");
248+
249+ // Fetch character tickets
250+ $tickets = shop_account_gender_tickets($account_id);
251+ $tickets = is_array($tickets) ? $tickets : array();
252+ if (!empty($tickets) || $config['free_sex_change'] == true) {
253+ // They are allowed to change gender
254+ $last = false;
255+ $infinite = false;
256+ $tks = 0;
257+ // Do we have any infinite tickets?
258+ foreach ($tickets as $ticket) {
259+ if ($ticket['count'] == 0) $infinite = true;
260+ else if ((int)$ticket['count'] > 0 && $infinite === false) $tks += (int)$ticket['count'];
261+ }
262+ if ($infinite === true) $tks = 0;
263+ $dbid = isset($tickets[0]['id']) ? (int)$tickets[0]['id'] : 0;
264+ // If they dont have unlimited tickets, remove a count from their ticket.
265+ if ($dbid > 0 && $tickets[0]['count'] > 1) { // Decrease count
266+ $tks--;
267+ $tkr = ((int)$tickets[0]['count'] - 1);
268+ shop_update_row_count($dbid, $tkr);
269+ } else if ($dbid > 0 && $tickets[0]['count'] == 1) { // Delete record
270+ shop_delete_row_order($dbid);
271+ $tks--;
272+ }
273+
274+ // Change character gender:
275+ //
276+ user_character_change_gender($char_name);
277+ echo t('acc.gender_changed', ['name' => $char_name]);
278+ if ($tks > 0) echo '<br>You have '. $tks .' gender change tickets left.';
279+ else if ($infinite !== true) echo '<br>You are out of tickets.';
280+ } else echo 'You don\'t have any character gender tickets, buy them in the <a href="shop.php">SHOP</a>!';
281+ } else echo t('acc.must_be_offline');
282+ }
283+ break;
284+ // end
285+
286+ // Change character comment PAGE1:
287+ case 'change_comment':
288+ $render_page = false; // Regular "myaccount" page should not render
289+ if ((int)user_character_account_id($char_name) === $session_user_id) {
290+ $comment_data = user_znote_character_data(user_character_id($char_name), 'comment');
291+ view('myaccount_edit_comment', ['char_name' => $char_name, 'comment_data' => $comment_data]);
292+ }
293+ break;
294+ //end
295+ }
296+ }
297+}
298+
299+if ($render_page) {
300+ $char_count = user_character_list_count($session_user_id);
301+ $pending_delete = user_pending_deletes($session_user_id);
302+ if ($pending_delete) {
303+ foreach($pending_delete as $delete) {
304+ if(new DateTime($delete['time']) > new DateTime())
305+ echo '<b>' . t('acc.caution') . '</b> ' . t('acc.character_will_be_deleted', ['name' => htmlspecialchars((string)$delete['character_name'], ENT_QUOTES, 'UTF-8'), 'time' => htmlspecialchars((string)$delete['time'], ENT_QUOTES, 'UTF-8')]) . ' <a href="myaccount.php?cancel_delete_id=' . $delete['id'] . '">'. t('acc.cancel_op'). '</a><br/>';
306+ else {
307+ user_delete_character(user_character_id($delete['character_name']));
308+ db()->execute('UPDATE `znote_deleted_characters` SET `done` = 1 WHERE `id` = ?', [(int)$delete['id']]);
309+ echo '<b>' . t('acc.character_deleted', ['name' => htmlspecialchars((string)$delete['character_name'], ENT_QUOTES, 'UTF-8')]) . '</b>. ' . t('acc.requested_by_owner');
310+ $char_count--;
311+ }
312+ }
313+ }
314+
315+ ?>
316+ <?php
317+ $char_array = user_character_list($user_data['id']);
318+
319+ $legacy_twofa_status = null;
320+ if ($config['twoFactorAuthenticator'] && znote_server_adapter()->supportsLegacyTwoFactor()) {
321+ $query = db()->fetchOne("SELECT `secret` FROM `accounts` WHERE `id` = ? LIMIT 1;", [(int)$session_user_id]);
322+ $legacy_twofa_status = (is_array($query) && $query['secret'] !== NULL);
323+ }
324+ $twofa2_status = znote2fa_v2_enabled() ? znote2fa_status((int)$session_user_id) : null;
325+ // Backward-compatible alias for third-party themes written before 2FA v2.
326+ $myaccount_status = $legacy_twofa_status;
327+
328+ view('myaccount', [
329+ 'char_array' => $char_array,
330+ 'char_count' => $char_count,
331+ 'myaccount_status' => $myaccount_status,
332+ 'legacy_twofa_status' => $legacy_twofa_status,
333+ 'twofa2_status' => $twofa2_status,
334+ ]);
335+ ?>
336+ <?php
337+}
338+theme_close();
339+?>
A onlinelist.php +96-0 View file
@@ -0,0 +1,96 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+$history = array(
4+ "enabled" => true,
5+ "days" => 14,
6+ "cache" => 300
7+);
8+
9+// Returns a list of players online
10+$array = false;
11+$loadFlags = ($config['country_flags']['enabled'] && $config['country_flags']['onlinelist']) ? true : false;
12+$loadOutfits = ($config['show_outfits']['onlinelist']) ? true : false;
13+if ($config['client'] < 780) {
14+ $outfitQuery = ($loadOutfits) ? ", `p`.`lookbody` AS `body`, `p`.`lookfeet` AS `feet`, `p`.`lookhead` AS `head`, `p`.`looklegs` AS `legs`, `p`.`looktype` AS `type`" : "";
15+} else {
16+ $outfitQuery = ($loadOutfits) ? ", `p`.`lookbody` AS `body`, `p`.`lookfeet` AS `feet`, `p`.`lookhead` AS `head`, `p`.`looklegs` AS `legs`, `p`.`looktype` AS `type`, `p`.`lookaddons` AS `addons`" : "";
17+}
18+
19+// Small 30 seconds players_online cache.
20+$cache = new Cache('engine/cache/onlinelist');
21+$cache->setExpiration(30);
22+if ($cache->hasExpired()) {
23+ // Load online list data from SQL
24+ $array = ($loadFlags === true) ? db()->fetchAll("SELECT `p`.`name` AS `name`, `p`.`level` AS `level`, `p`.`vocation` AS `vocation`, `g`.`name` AS `gname`, `za`.`flag` AS `flag` $outfitQuery FROM `players_online` AS `o` INNER JOIN `players` AS `p` ON `o`.`player_id` = `p`.`id` INNER JOIN `znote_accounts` AS `za` ON `p`.`account_id` = `za`.`account_id` LEFT JOIN `guild_membership` AS `gm` ON `o`.`player_id` = `gm`.`player_id` LEFT JOIN `guilds` AS `g` ON `gm`.`guild_id` = `g`.`id`;") : db()->fetchAll("SELECT `p`.`name` AS `name`, `p`.`level` AS `level`, `p`.`vocation` AS `vocation`, `g`.`name` AS `gname` $outfitQuery FROM `players_online` AS `o` INNER JOIN `players` AS `p` ON `o`.`player_id` = `p`.`id` LEFT JOIN `guild_membership` AS `gm` ON `o`.`player_id` = `gm`.`player_id` LEFT JOIN `guilds` AS `g` ON `gm`.`guild_id` = `g`.`id`;");
25+ // End loading data from SQL
26+ $cache->setContent($array);
27+ $cache->save();
28+} else {
29+ $array = $cache->load();
30+}
31+// End cache
32+
33+/**
34+ * Players-online record.
35+ *
36+ * Updated here because this page already knows the current count - checking it
37+ * anywhere else would mean an extra query on every page load. Stored in
38+ * znote_config, see znote_record_update().
39+ */
40+$onlineNow = is_array($array) ? count($array) : 0;
41+$onlineBroken = znote_record_update($onlineNow);
42+$onlineRecord = znote_record_get();
43+
44+// 5 minute logout history cache
45+if ($history["enabled"]) {
46+ $time = time();
47+ $cache = new Cache('engine/cache/onlinelist_rec');
48+ $cache->setExpiration($history['cache']);
49+ if ($cache->hasExpired()) {
50+ // Load online list data from SQL
51+ $sinceThreshold = $time - ((int)$history['days'] * 24 * 60 * 60);
52+ $recents = ($loadFlags === true) ? db()->fetchAll("
53+ SELECT
54+ `p`.`name` AS `name`,
55+ `p`.`level` AS `level`,
56+ `p`.`vocation` AS `vocation`,
57+ `p`.`lastlogout`,
58+ `g`.`name` AS `gname`,
59+ `za`.`flag` AS `flag`
60+ $outfitQuery
61+ FROM `players` AS `p`
62+ INNER JOIN `znote_accounts` AS `za`
63+ ON `p`.`account_id` = `za`.`account_id`
64+ LEFT JOIN `guild_membership` AS `gm`
65+ ON `p`.`id` = `gm`.`player_id`
66+ LEFT JOIN `guilds` AS `g`
67+ ON `gm`.`guild_id` = `g`.`id`
68+ WHERE `p`.`lastlogout` >= ?
69+ ORDER BY `p`.`lastlogout` DESC;
70+ ", [$sinceThreshold]) : db()->fetchAll("
71+ SELECT
72+ `p`.`name` AS `name`,
73+ `p`.`level` AS `level`,
74+ `p`.`vocation` AS `vocation`,
75+ `p`.`lastlogout`,
76+ `g`.`name` AS `gname`
77+ $outfitQuery
78+ FROM `players` AS `p`
79+ LEFT JOIN `guild_membership` AS `gm`
80+ ON `p`.`id` = `gm`.`player_id`
81+ LEFT JOIN `guilds` AS `g`
82+ ON `gm`.`guild_id` = `g`.`id`
83+ WHERE `p`.`lastlogout` >= ?
84+ ORDER BY `p`.`lastlogout` DESC;
85+ ", [$sinceThreshold]);
86+ // End loading data from SQL
87+ $cache->setContent($recents);
88+ $cache->save();
89+ } else {
90+ $recents = $cache->load();
91+ }
92+}
93+
94+view('onlinelist');
95+
96+theme_close();
A page.php +90-0 View file
@@ -0,0 +1,90 @@
1+<?php
2+/**
3+ * Front controller for pages a theme adds.
4+ *
5+ * Any .php file dropped in layouts/<theme>/pages/ is live at
6+ * page.php?p=<filename> - nothing to register anywhere.
7+ *
8+ * The name is checked against the files that actually exist in the theme, so
9+ * ?p= can never reach anything outside pages/.
10+ *
11+ * Pretty URLs are a rewrite away, if you want them:
12+ * RewriteRule ^([a-z0-9_-]+)\.html$ page.php?p=$1 [L,QSA]
13+ */
14+
15+require_once 'engine/init.php';
16+
17+$requested = theme_sanitize((string)($_GET['p'] ?? ''));
18+
19+// A plugin page: page.php?plugin=shop_coupons&p=redeem
20+// Checked first, and only for an enabled plugin - a disabled one is invisible.
21+$pluginName = znote_plugin_sanitize((string)($_GET['plugin'] ?? ''));
22+$file = ($pluginName !== '')
23+ ? znote_plugin_page($pluginName, $requested)
24+ : (($requested !== '') ? theme_file('pages/' . $requested . '.php') : null);
25+
26+if ($file === null) {
27+ if ($pluginName === '' && $requested !== '' && function_exists('znote_table_exists') && znote_table_exists('znote_pages')) {
28+ $dbPage = db()->fetchOne("
29+ SELECT `slug`, `title`, `body`, `access`
30+ FROM `znote_pages`
31+ WHERE `slug` = ?
32+ AND `active` = 1
33+ LIMIT 1;
34+ ", [$requested]);
35+
36+ if (is_array($dbPage)) {
37+ if ((int)$dbPage['access'] > 0) {
38+ protect_page();
39+ }
40+ $page_filename = 'page_' . $requested;
41+ theme_open();
42+ echo '<h1>' . htmlspecialchars((string)$dbPage['title'], ENT_QUOTES, 'UTF-8') . '</h1>';
43+ echo znote_bbcode_raw((string)$dbPage['body']);
44+ theme_close();
45+ exit;
46+ }
47+ }
48+
49+ http_response_code(404);
50+ $page_filename = 'page_not_found';
51+ theme_open();
52+ echo '<h1>'. t('page.not_found') .'</h1>';
53+ if ($pluginName !== '') {
54+ echo '<p>The <strong>' . htmlspecialchars($pluginName, ENT_QUOTES, 'UTF-8')
55+ . '</strong> plugin has no page called <code>' . htmlspecialchars($requested, ENT_QUOTES, 'UTF-8')
56+ . '</code>, or the plugin is disabled.</p>';
57+ } else {
58+ echo '<p>'. t('page.no_such_page') .' <code>pages/' . htmlspecialchars($requested, ENT_QUOTES, 'UTF-8')
59+ . '.php</code> in the <strong>' . htmlspecialchars(theme_active(), ENT_QUOTES, 'UTF-8')
60+ . '</strong> theme.</p>';
61+ }
62+ theme_close();
63+ exit;
64+}
65+
66+// Lets a theme style one of its own pages from CSS alone: body.page_wiki
67+$page_filename = 'page_' . ($pluginName !== '' ? $pluginName . '_' : '') . $requested;
68+
69+$page_title = ucwords(str_replace(array('-', '_'), ' ', $requested !== '' ? $requested : 'index'));
70+if (function_exists('znote_hook_filter')) {
71+ $page_title = (string) znote_hook_filter('page.title', $page_title, array(
72+ 'plugin' => $pluginName,
73+ 'page' => $requested,
74+ 'filename' => $page_filename,
75+ ));
76+}
77+$GLOBALS['page_title'] = $page_title;
78+
79+theme_open();
80+if ($pluginName !== '') {
81+ ob_start();
82+ include $file;
83+ $pluginContent = ob_get_clean();
84+ echo function_exists('theme_wrap_plugin_page')
85+ ? theme_wrap_plugin_page($pluginContent, $page_title, $pluginName, $requested)
86+ : $pluginContent;
87+} else {
88+ include $file;
89+}
90+theme_close();
A pagseguro_ipn.php +174-0 View file
@@ -0,0 +1,174 @@
1+<?php
2+ // Require the functions to fetch config values
3+ require 'config.php';
4+
5+ $pagseguro = $config['pagseguro'];
6+ $notificationCode = $_POST['notificationCode'] ?? null;
7+ $notificationType = $_POST['notificationType'] ?? null;
8+
9+ // Require the functions to connect to database
10+ require 'engine/database/connect.php';
11+
12+ // Fetch and sanitize POST and GET values
13+ function getValue($value) {
14+ return (!empty($value)) ? sanitize($value) : false;
15+ }
16+ function sanitize($data) {
17+ return htmlentities(strip_tags(mysql_znote_escape_string($data)));
18+ }
19+
20+ require_once 'engine/function/translate.php';
21+ require_once 'engine/function/settings.php';
22+ require_once 'engine/function/users.php';
23+ require_once 'engine/function/plugins.php';
24+ znote_apply_settings();
25+ znote_plugins_load();
26+
27+ // Util function to insert log
28+ function report($code, $details = '') {
29+ $connectedIp = $_SERVER['REMOTE_ADDR'];
30+ $details = getValue($details);
31+ $details .= '\nConnection from IP: '. $connectedIp;
32+ db()->execute('INSERT INTO `znote_pagseguro_notifications` VALUES (null, ?, ?, CURRENT_TIMESTAMP)', [getValue($code), $details]);
33+ }
34+
35+ function VerifyPagseguroIPN($code) {
36+ global $pagseguro;
37+ $url = $pagseguro['urls']['ws'];
38+
39+ $cURL = curl_init();
40+ curl_setopt($cURL, CURLOPT_SSL_VERIFYPEER, false);
41+ curl_setopt($cURL, CURLOPT_SSL_VERIFYHOST, false);
42+ curl_setopt($cURL, CURLOPT_URL, 'https://' . $url . '/v3/transactions/notifications/' . $code . '?email=' . $pagseguro['email'] . '&token=' . $pagseguro['token']);
43+ curl_setopt($cURL, CURLOPT_HEADER, false);
44+ curl_setopt($cURL, CURLOPT_RETURNTRANSFER, true);
45+ curl_setopt($cURL, CURLOPT_FORBID_REUSE, true);
46+ curl_setopt($cURL, CURLOPT_FRESH_CONNECT, true);
47+ curl_setopt($cURL, CURLOPT_CONNECTTIMEOUT, 30);
48+ curl_setopt($cURL, CURLOPT_TIMEOUT, 60);
49+ curl_setopt($cURL, CURLINFO_HEADER_OUT, true);
50+ curl_setopt($cURL, CURLOPT_HTTPHEADER, array(
51+ 'Connection: close',
52+ 'Expect: ',
53+ ));
54+ $Response = curl_exec($cURL);
55+ $Status = (int)curl_getinfo($cURL, CURLINFO_HTTP_CODE);
56+ curl_close($cURL);
57+
58+ $output = print_r($Response, true);
59+ if(empty($Response) OR !$Status){
60+ return null;
61+ }
62+ if(intval($Status / 100) != 2){
63+ return false;
64+ }
65+ return trim($Response);
66+ }
67+
68+ // Send an empty HTTP 200 OK response to acknowledge receipt of the notification
69+ header('HTTP/1.1 200 OK');
70+
71+ if(empty($notificationCode) || empty($notificationType)){
72+ report($notificationCode, 'notificationCode or notificationType is empty. Type: ' . $notificationType . ', Code: ' . $notificationCode);
73+ exit();
74+ }
75+
76+ if ($notificationType !== 'transaction') {
77+ report($notificationCode, 'Unknown ' . $notificationType . ' notificationType');
78+ exit();
79+ }
80+
81+ $rawPayment = VerifyPagseguroIPN($notificationCode);
82+ $payment = simplexml_load_string((string)$rawPayment);
83+ if ($payment === false) {
84+ die('Error: invalid PagSeguro response.');
85+ }
86+ $paymentStatus = (int) $payment->status;
87+ $paymentCode = sanitize($payment->code);
88+
89+ report($notificationCode, $rawPayment);
90+
91+ // Updating Payment Status
92+ db()->execute('UPDATE `znote_pagseguro` SET `payment_status` = ? WHERE `transaction` = ?', [$paymentStatus, $paymentCode]);
93+
94+ // Check that the payment_status is Completed
95+ if ($paymentStatus == 3) {
96+
97+ // Check that transaction has not been previously processed
98+ $transaction = db()->fetchOne('SELECT `transaction`, `completed` FROM `znote_pagseguro` WHERE `transaction` = ?', [$paymentCode]);
99+ $status = true;
100+ $customRaw = (string)$payment->reference;
101+ $custom = (int)$customRaw;
102+
103+ if (!is_array($transaction) || $transaction['completed'] == '1') {
104+ $status = false;
105+ }
106+
107+ if ($payment->grossAmount == 0.0) $status = false; // Wrong ammount of money
108+ $item = $payment->items->item[0];
109+ $paymentData = array(
110+ 'provider' => 'pagseguro',
111+ 'reference' => (string)$paymentCode,
112+ 'custom' => $customRaw,
113+ 'account_id' => $custom,
114+ 'price' => (float)$item->amount,
115+ 'currency' => $pagseguro['currency'] ?? '',
116+ 'points' => (int)$item->quantity,
117+ 'status' => 'completed',
118+ 'raw' => $rawPayment,
119+ 'resolved' => false,
120+ );
121+ if (function_exists('znote_hook_filter')) {
122+ $paymentData = znote_hook_filter('payment.resolve', $paymentData, array('provider' => 'pagseguro', 'raw' => $rawPayment));
123+ }
124+ if (!empty($paymentData['resolved'])) {
125+ $custom = (int)($paymentData['account_id'] ?? 0);
126+ } elseif ($item->amount != ($pagseguro['price'] / 100)) $status = false;
127+ if (number_format((float)$item->amount, 2, '.', '') !== number_format((float)($paymentData['price'] ?? 0), 2, '.', '')) $status = false;
128+ if ($custom <= 0) $status = false;
129+
130+ if ($status) {
131+ $paidPoints = (int)($paymentData['points'] ?? $item->quantity);
132+
133+ // Re-check completion status and credit inside one locked transaction,
134+ // so two concurrent notifications for the same transaction cannot both credit points.
135+ $creditResult = db()->transaction(function ($db) use ($paymentCode, $custom, $paidPoints) {
136+ $row = $db->fetchOne('SELECT `completed` FROM `znote_pagseguro` WHERE `transaction` = ? LIMIT 1 FOR UPDATE;', [$paymentCode]);
137+ if (!is_array($row) || (int)$row['completed'] === 1) {
138+ return 'duplicate';
139+ }
140+
141+ $db->execute('UPDATE `znote_pagseguro` SET `completed` = 1 WHERE `transaction` = ?', [$paymentCode]);
142+
143+ $data = $db->fetchOne("SELECT `points` AS `old_points` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1 FOR UPDATE;", [$custom]);
144+ if (!is_array($data)) {
145+ return 'no_account';
146+ }
147+
148+ $new_points = (int)$data['old_points'] + $paidPoints;
149+ $db->execute("UPDATE `znote_accounts` SET `points` = ? WHERE `account_id` = ?", [$new_points, $custom]);
150+
151+ return 'credited';
152+ });
153+
154+ if ($creditResult === 'credited') {
155+ if (function_exists('znote_hook')) {
156+ znote_hook('payment.completed', array_merge($paymentData, array(
157+ 'provider' => 'pagseguro',
158+ 'reference' => (string)$paymentCode,
159+ 'custom' => $customRaw,
160+ 'account_id' => $custom,
161+ 'price' => $paymentData['price'] ?? (float)$item->amount,
162+ 'currency' => $paymentData['currency'] ?? ($pagseguro['currency'] ?? ''),
163+ 'points' => $paidPoints,
164+ 'status' => 'completed',
165+ )));
166+ }
167+ } elseif ($creditResult === 'no_account') {
168+ report($notificationCode, 'No znote_accounts row for account_id ' . $custom);
169+ }
170+ }
171+ } else if ($paymentStatus == 7) {
172+ db()->execute('UPDATE `znote_pagseguro` SET `completed` = 1 WHERE `transaction` = ?', [$paymentCode]);
173+ }
174+?>
A pagseguro_retorno.php +115-0 View file
@@ -0,0 +1,115 @@
1+<?php
2+ /*
3+ Instalação
4+ - Configurando Pagseguro (Acessar https://pagseguro.uol.com.br/preferencias/integracoes.jhtml)
5+ - Notificação de Transação:
6+ - http://you-site/pagseguro_ipn.php
7+ - Página de redirecionamento:
8+ - A. Página fixa de redirecionamento
9+ - http://you-site/pagseguro_retorno.php
10+ - B. Redirecionamento com o código da transação
11+ - transaction
12+ - Gerar o Token e copiar para a próxima etapa
13+
14+ - Configurando ZnoteACC
15+ - config.php
16+ - $config['pagseguro']['email']
17+ - Seu email da conta do pagseguro que irá receber o pagamento
18+ - $config['pagseguro']['token']
19+ - Preencher com o Token que pedi pra copiar na primeira etapa
20+ - $config['pagseguro']['product_name']
21+ - Nome do Produto
22+
23+ - Instalando Tabelas
24+ CREATE TABLE IF NOT EXISTS `znote_pagseguro` (
25+ `id` int(11) NOT NULL AUTO_INCREMENT,
26+ `transaction` varchar(36) NOT NULL,
27+ `account` int(11) NOT NULL,
28+ `price` decimal(11,2) NOT NULL,
29+ `points` int(11) NOT NULL,
30+ `payment_status` tinyint(1) NOT NULL,
31+ `completed` tinyint(4) NOT NULL,
32+ PRIMARY KEY (`id`),
33+ FOREIGN KEY (account) REFERENCES accounts(id)
34+ ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
35+
36+ CREATE TABLE IF NOT EXISTS `znote_pagseguro_notifications` (
37+ `id` int(11) NOT NULL AUTO_INCREMENT,
38+ `notification_code` varchar(40) NOT NULL,
39+ `details` text NOT NULL,
40+ `receive_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
41+ PRIMARY KEY (`id`)
42+ ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
43+ */
44+
45+ // Require the functions to fetch config values
46+ require 'config.php';
47+
48+ // Require the functions to connect to database
49+ require 'engine/database/connect.php';
50+
51+ $pagseguro = $config['pagseguro'];
52+
53+ // Fetch and sanitize POST and GET values
54+ function getValue($value) {
55+ return (!empty($value)) ? sanitize($value) : false;
56+ }
57+ function sanitize($data) {
58+ return htmlentities(strip_tags(mysql_znote_escape_string($data)));
59+ }
60+
61+ // Util function to insert log
62+ function report($code, $details = '') {
63+ $connectedIp = $_SERVER['REMOTE_ADDR'];
64+ $details = getValue($details);
65+ $details .= '\nConnection from IP: '. $connectedIp;
66+ db()->execute('INSERT INTO `znote_pagseguro_notifications` VALUES (null, ?, ?, CURRENT_TIMESTAMP)', [getValue($code), $details]);
67+ }
68+
69+ function VerifyPagseguroIPN($code) {
70+ global $pagseguro;
71+ $url = $pagseguro['urls']['ws'];
72+
73+ $cURL = curl_init();
74+ curl_setopt($cURL, CURLOPT_SSL_VERIFYPEER, false);
75+ curl_setopt($cURL, CURLOPT_SSL_VERIFYHOST, false);
76+ curl_setopt($cURL, CURLOPT_URL, 'https://' . $url . '/v3/transactions/' . $code . '?email=' . $pagseguro['email'] . '&token=' . $pagseguro['token']);
77+ curl_setopt($cURL, CURLOPT_HEADER, false);
78+ curl_setopt($cURL, CURLOPT_RETURNTRANSFER, true);
79+ curl_setopt($cURL, CURLOPT_FORBID_REUSE, true);
80+ curl_setopt($cURL, CURLOPT_FRESH_CONNECT, true);
81+ curl_setopt($cURL, CURLOPT_CONNECTTIMEOUT, 30);
82+ curl_setopt($cURL, CURLOPT_TIMEOUT, 60);
83+ curl_setopt($cURL, CURLINFO_HEADER_OUT, true);
84+ curl_setopt($cURL, CURLOPT_HTTPHEADER, array(
85+ 'Connection: close',
86+ 'Expect: ',
87+ ));
88+ $Response = curl_exec($cURL);
89+ $Status = (int)curl_getinfo($cURL, CURLINFO_HTTP_CODE);
90+ curl_close($cURL);
91+
92+ return trim($Response);
93+ }
94+
95+ $transactionCode = getValue($_GET['transaction'] ?? null);
96+ $rawTransaction = VerifyPagseguroIPN($transactionCode);
97+ $transaction = simplexml_load_string((string)$rawTransaction);
98+ if ($transaction === false || !isset($transaction->items->item[0])) {
99+ header('Location: shop.php');
100+ exit;
101+ }
102+
103+ $transactionStatus = (int) $transaction->status;
104+ $completed = ($transactionStatus != 7) ? 0 : 1;
105+
106+ $custom = (int) $transaction->reference;
107+ $item = $transaction->items->item[0];
108+ $points = $item->quantity;
109+ $price = $points * ($pagseguro['price'] / 100);
110+ db()->execute(
111+ 'INSERT INTO `znote_pagseguro` VALUES (null, ?, ?, ?, ?, ?, ?)',
112+ [sanitize((string)$transaction->code), $custom, $price, $points, $transactionStatus, $completed]
113+ );
114+
115+ header('Location: shop.php?callback=processing');
A paygol_ipn.php +122-0 View file
@@ -0,0 +1,122 @@
1+<?php
2+require 'config.php';
3+require 'engine/database/connect.php';
4+
5+// Fetch and sanitize POST and GET values
6+function getValue($value) {
7+ return (!empty($value)) ? sanitize($value) : false;
8+}
9+function sanitize($data) {
10+ return htmlentities(strip_tags(mysql_znote_escape_string($data)));
11+}
12+
13+require_once 'engine/function/translate.php';
14+require_once 'engine/function/settings.php';
15+require_once 'engine/function/users.php';
16+require_once 'engine/function/plugins.php';
17+znote_apply_settings();
18+znote_plugins_load();
19+
20+// get the variables from PayGol system
21+$message_id = getValue($_GET['message_id'] ?? null);
22+$service_id = getValue($_GET['service_id'] ?? null);
23+$shortcode = getValue($_GET['shortcode'] ?? null);
24+$keyword = getValue($_GET['keyword'] ?? null);
25+$message = getValue($_GET['message'] ?? null);
26+$sender = getValue($_GET['sender'] ?? null);
27+$operator = getValue($_GET['operator'] ?? null);
28+$country = getValue($_GET['country'] ?? null);
29+$custom_raw = (string)($_GET['custom'] ?? '');
30+$custom = getValue($custom_raw);
31+$points = getValue($_GET['points'] ?? null);
32+$price = getValue($_GET['price'] ?? null);
33+$currency = getValue($_GET['currency'] ?? null);
34+$secret = getValue($_GET['secret'] ?? null);
35+
36+// config paygol settings
37+$paygol = $config['paygol'];
38+
39+// Check for valid secret key
40+if($secret != $paygol['secretKey']) {
41+ header("HTTP/1.0 403 Forbidden");
42+ die("Error: secretKey does not match.");
43+}
44+
45+// Check if request serviceID is the same as it is in config
46+if($service_id != $paygol['serviceID']) {
47+ header("HTTP/1.0 403 Forbidden");
48+ die("Error: serviceID does not match.");
49+}
50+
51+$new_points = $paygol['points'];
52+$paymentData = array(
53+ 'provider' => 'paygol',
54+ 'reference' => (string)$message_id,
55+ 'custom' => $custom_raw,
56+ 'account_id' => (int)$custom,
57+ 'price' => $price,
58+ 'currency' => $currency,
59+ 'points' => (int)$new_points,
60+ 'status' => 'completed',
61+ 'raw' => $_GET,
62+ 'resolved' => false,
63+);
64+if (function_exists('znote_hook_filter')) {
65+ $paymentData = znote_hook_filter('payment.resolve', $paymentData, array('provider' => 'paygol', 'raw' => $_GET));
66+}
67+if (!empty($paymentData['resolved'])) {
68+ $custom = (int)($paymentData['account_id'] ?? 0);
69+ $new_points = (int)($paymentData['points'] ?? 0);
70+ if (number_format((float)$price, 2, '.', '') !== number_format((float)($paymentData['price'] ?? 0), 2, '.', '')) {
71+ header("HTTP/1.0 403 Forbidden");
72+ die("Error: payment price mismatch.");
73+ }
74+}
75+
76+// Check that this message_id has not already been credited
77+if ($message_id !== false) {
78+ $duplicate = db()->fetchOne("SELECT `id` FROM `znote_paygol` WHERE `message_id` = ? LIMIT 1;", [$message_id]);
79+ if ($duplicate !== false) {
80+ header("HTTP/1.0 200 OK");
81+ die("Error: message_id already processed.");
82+ }
83+}
84+
85+// Re-check for a duplicate and credit inside one locked transaction, so two
86+// concurrent notifications for the same message_id cannot both credit points.
87+$creditResult = db()->transaction(function ($db) use ($custom, $price, $new_points, $message_id, $service_id, $shortcode, $keyword, $message, $sender, $operator, $country, $currency) {
88+ if ($message_id !== false) {
89+ $dup = $db->fetchOne("SELECT `id` FROM `znote_paygol` WHERE `message_id` = ? LIMIT 1 FOR UPDATE;", [$message_id]);
90+ if ($dup !== false) {
91+ return 'duplicate';
92+ }
93+ }
94+
95+ $db->execute(
96+ "INSERT INTO `znote_paygol` VALUES ('', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
97+ [$custom, $price, $new_points, $message_id, $service_id, $shortcode, $keyword, $message, $sender, $operator, $country, $currency]
98+ );
99+
100+ $account = $db->fetchOne("SELECT `points` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1 FOR UPDATE;", [$custom]);
101+ if (!is_array($account)) {
102+ return 'no_account';
103+ }
104+
105+ $creditedPoints = (int)$account['points'] + $new_points;
106+ $db->execute("UPDATE `znote_accounts` SET `points` = ? WHERE `account_id` = ?", [$creditedPoints, $custom]);
107+
108+ return 'credited';
109+});
110+
111+if ($creditResult === 'credited' && function_exists('znote_hook')) {
112+ znote_hook('payment.completed', array_merge($paymentData, array(
113+ 'provider' => 'paygol',
114+ 'reference' => (string)$message_id,
115+ 'custom' => $custom_raw,
116+ 'account_id' => (int)$custom,
117+ 'price' => $paymentData['price'] ?? $price,
118+ 'currency' => $paymentData['currency'] ?? $currency,
119+ 'points' => $paymentData['points'] ?? $paygol['points'],
120+ 'status' => 'completed',
121+ )));
122+}
A payment.php +34-0 View file
@@ -0,0 +1,34 @@
1+<?php
2+require_once 'engine/init.php';
3+protect_page();
4+
5+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
6+ header('Location: buypoints.php');
7+ exit;
8+}
9+
10+if (!Token::isValid($_POST['token'] ?? null)) {
11+ http_response_code(400);
12+ theme_open();
13+ $paymentMessage = array('title' => t('payment.invalid_request'), 'text' => t('payment.retry_hint'));
14+ view('payment_message');
15+ theme_close();
16+ exit;
17+}
18+
19+$provider = strtolower(trim((string)($_POST['provider'] ?? '')));
20+$price = $_POST['price'] ?? '';
21+
22+try {
23+ $checkout = payment_gateway_create_checkout($provider, (int)$session_user_id, $price);
24+ header('Location: ' . $checkout['url']);
25+ exit;
26+} catch (Throwable $e) {
27+ error_log('Payment checkout error: ' . $e->getMessage());
28+ http_response_code(400);
29+ theme_open();
30+ $paymentMessage = array('title' => t('payment.unavailable'), 'text' => t('payment.start_failed'));
31+ view('payment_message');
32+ theme_close();
33+}
34+?>
A payment_webhook.php +62-0 View file
@@ -0,0 +1,62 @@
1+<?php
2+if (PHP_VERSION_ID < 80100) {
3+ http_response_code(500);
4+ exit('PHP 8.1 or higher is required.');
5+}
6+
7+$version = '2.0.1';
8+$time = time();
9+$aacQueries = 0;
10+$accQueriesData = [];
11+
12+require 'config.php';
13+require 'engine/database/connect.php';
14+require 'engine/function/general.php';
15+require 'engine/function/settings.php';
16+znote_apply_settings();
17+require 'engine/function/payments.php';
18+
19+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
20+ http_response_code(405);
21+ header('Allow: POST');
22+ header('Content-Type: application/json');
23+ echo json_encode(['received' => false, 'status' => 'method_not_allowed']);
24+ exit;
25+}
26+
27+$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
28+if ($contentLength > 1048576) {
29+ http_response_code(413);
30+ header('Content-Type: application/json');
31+ echo json_encode(['received' => false, 'status' => 'payload_too_large']);
32+ exit;
33+}
34+
35+$provider = strtolower(trim((string)($_GET['provider'] ?? $_POST['provider'] ?? '')));
36+$payload = file_get_contents('php://input') ?: '';
37+if (strlen($payload) > 1048576) {
38+ http_response_code(413);
39+ header('Content-Type: application/json');
40+ echo json_encode(['received' => false, 'status' => 'payload_too_large']);
41+ exit;
42+}
43+
44+payment_gateway_ensure_schema();
45+
46+try {
47+ if ($provider === 'stripe') {
48+ $result = payment_gateway_handle_stripe_webhook($payload);
49+ } elseif ($provider === 'mercadopago') {
50+ $result = payment_gateway_handle_mercadopago_webhook($payload);
51+ } else {
52+ $result = ['code' => 404, 'status' => 'unknown_provider'];
53+ }
54+} catch (Throwable $e) {
55+ error_log('Payment webhook error: ' . $e->getMessage());
56+ $result = ['code' => 500, 'status' => 'server_error'];
57+}
58+
59+http_response_code((int)$result['code']);
60+header('Content-Type: application/json');
61+echo json_encode(['received' => $result['code'] < 500, 'status' => $result['status']]);
62+?>
A phpstan.neon +74-0 View file
@@ -0,0 +1,74 @@
1+parameters:
2+ level: 0
3+ paths:
4+ - admin
5+ - api
6+ - engine
7+ - install
8+ - locale
9+ - layouts/default
10+ - layouts/_example
11+ - layouts/_childexample
12+ - achievements.php
13+ - auctionChar.php
14+ - bans.php
15+ - blank.php
16+ - buypoints.php
17+ - changelog.php
18+ - changepassword.php
19+ - characterprofile.php
20+ - config.countries.php
21+ - config.php
22+ - contact.php
23+ - createcharacter.php
24+ - creatures.php
25+ - credits.php
26+ - deaths.php
27+ - downloads.php
28+ - failed.php
29+ - forum.php
30+ - forum_search.php
31+ - gallery.php
32+ - guilds.php
33+ - guildwar.php
34+ - helpdesk.php
35+ - highscores.php
36+ - house.php
37+ - houses.php
38+ - index.php
39+ - ipn.php
40+ - items.php
41+ - killers.php
42+ - login.php
43+ - logout.php
44+ - mailtest.php
45+ - market.php
46+ - monster_loot.php
47+ - myaccount.php
48+ - onlinelist.php
49+ - page.php
50+ - pagseguro_ipn.php
51+ - pagseguro_retorno.php
52+ - paygol_ipn.php
53+ - payment.php
54+ - payment_webhook.php
55+ - powergamers.php
56+ - protected.php
57+ - queststatus.php
58+ - recovery.php
59+ - register.php
60+ - serverinfo.php
61+ - settings.php
62+ - shop.php
63+ - spells.php
64+ - sub.php
65+ - success.php
66+ - support.php
67+ - topguilds.php
68+ - toponline.php
69+ - twofa.php
70+ - twtrNews.php
71+ - voting.php
72+ excludePaths:
73+ - engine/cache/*
74+ treatPhpDocTypesAsCertain: false
A phpunit.xml +20-0 View file
@@ -0,0 +1,20 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
4+ bootstrap="tests/bootstrap.php"
5+ colors="true"
6+ failOnWarning="true"
7+ failOnNotice="true"
8+ failOnDeprecation="false">
9+ <testsuites>
10+ <testsuite name="security">
11+ <directory>tests/Security</directory>
12+ </testsuite>
13+ </testsuites>
14+ <source>
15+ <include>
16+ <directory>engine/function</directory>
17+ <file>admin/bootstrap.php</file>
18+ </include>
19+ </source>
20+</phpunit>
A plugins/README.md +281-0 View file
@@ -0,0 +1,281 @@
1+# ZnoteX plugins
2+
3+A plugin is a folder in `plugins/`. It can add public pages, admin pages,
4+database tables and behaviour **without editing a single ZnoteX file** — which
5+is the whole point: the next update replaces ZnoteX and leaves your work alone.
6+
7+Install, update, enable and disable them in **Admin Panel → Plugins**.
8+
9+**Installing one:** download it, unzip the folder into `plugins/`, reload the
10+panel, press *Install*. That runs its `install.sql` and switches it on. ZnoteX
11+never downloads a plugin by itself — a plugin is PHP that runs on every page of
12+your site, so putting the files there stays a deliberate act.
13+
14+**Updating one:** replace the folder with the newer version. If its
15+`plugin.json` carries a higher `version` than the one recorded at install time,
16+an *Update* button appears and re-runs `install.sql`. That is why the file has
17+to be idempotent — see below.
18+
19+---
20+
21+## The folder
22+
23+```
24+plugins/my_plugin/
25+ plugin.json name, version, author, description [required]
26+ plugin.php registers hooks and helpers [optional]
27+ pages/<page>.php public page at page.php?plugin=my_plugin&p=<page>
28+ admin/<mod>.php admin page, listed in the sidebar
29+ install.sql tables, created when the plugin is enabled
30+ assets/ css, js, images
31+```
32+
33+Only `plugin.json` is required. A plugin that just reacts to a hook is a
34+`plugin.json` and a `plugin.php`, nothing else.
35+
36+The folder name is the plugin's identity: lowercase letters, digits, `-` and
37+`_`. A folder starting with `_` is ignored, which is how you park one.
38+
39+### plugin.json
40+
41+```json
42+{
43+ "name": "My Plugin",
44+ "version": "1.0.0",
45+ "author": "You",
46+ "description": "One or two sentences shown in the admin panel.",
47+ "url": "https://example.com",
48+ "requires": {
49+ "znotex": ">=2.0.0 <3.0.0",
50+ "php": ">=8.1",
51+ "api": "^1.0",
52+ "extensions": ["json"]
53+ }
54+}
55+```
56+
57+The plugin version is independent from theme and ZnoteX versions. The
58+`requires` object declares which host environment the plugin supports.
59+ZnoteX refuses to install, enable or load an incompatible plugin. The legacy
60+string form, such as `"requires": "2.0.0"`, remains supported and means
61+ZnoteX 2.0.0 or newer.
62+
63+---
64+
65+## plugin.php
66+
67+Loaded on **every request** while the plugin is enabled, right after the
68+database and the settings are up. So:
69+
70+- register hooks and declare functions here — that is all it is for;
71+- never print anything;
72+- keep it cheap. A query here is a query on every page of the site. Do the work
73+ inside the hook, where it only runs when it is needed.
74+
75+A `plugin.php` that throws is skipped and logged. One broken plugin does not
76+take the site down.
77+
78+### Stable extension API
79+
80+```php
81+$api = znote_plugin_api('my_plugin');
82+
83+$api->on('shop.purchased', function (array $data): void {
84+});
85+
86+$value = $api->setting('enabled', '1');
87+$cache = $api->cache('catalogue', 300);
88+$url = $api->url('shop');
89+$asset = $api->asset('style.css');
90+$db = $api->database();
91+```
92+
93+The API also exposes `config()`, `setSetting()`, `dispatch()`,
94+`filter()`, `collect()`, `allows()`, `apiVersion()` and
95+`znoteVersion()`. Plugin settings and cache keys are automatically isolated
96+under the plugin name.
97+
98+---
99+
100+## Pages
101+
102+Drop `pages/shop.php` into your plugin and it is live at
103+`page.php?plugin=my_plugin&p=shop`. Nothing to register.
104+
105+The file is a **fragment**: `page.php` has already run `engine/init.php` and
106+opened the theme, so `$config`, `$user_data` and the `mysql_*` helpers are all
107+there, and the active theme wraps whatever you print. Do not include
108+`init.php`, and do not print a header or a footer.
109+
110+`?p=` is matched against the files that actually exist, so it can never reach
111+anything outside `pages/`. A page of a plugin that is not installed and enabled
112+returns 404.
113+
114+`plugins/.htaccess` blocks direct requests to anything but `assets/`, so nobody
115+can run one of your files outside `page.php` or read your `install.sql`. Guard
116+your pages anyway — someone will run ZnoteX on a server that ignores
117+`.htaccess`:
118+
119+```php
120+if (!isset($config)) { http_response_code(403); die('Direct access denied.'); }
121+```
122+
123+Link to one with `znote_plugin_url('my_plugin', 'shop')`, and to a file in
124+`assets/` with `znote_plugin_asset('my_plugin', 'style.css')`.
125+
126+The `<body>` gets a `page_my_plugin_shop` class, so a theme can style your page
127+from CSS alone.
128+
129+---
130+
131+## settings.json
132+
133+Ship one and the plugin gets a configuration page for free - a **Settings**
134+button next to it in Admin Panel > Plugins - instead of hand-coding a form:
135+
136+```json
137+{
138+ "fields": [
139+ {"key": "api_key", "label": "API key", "type": "text", "default": ""},
140+ {"key": "enabled", "label": "Enabled", "type": "bool", "default": "1"},
141+ {"key": "mode", "label": "Mode", "type": "select", "default": "test",
142+ "options": {"test": "Test", "live": "Live"}},
143+ {"key": "max_items", "label": "Max items", "type": "int", "default": "10", "min": 1, "max": 100},
144+ {"key": "notes", "label": "Notes", "type": "textarea", "default": ""},
145+ {"key": "webhook_secret", "label": "Webhook secret", "type": "password", "default": ""}
146+ ]
147+}
148+```
149+
150+Types: `text`, `textarea`, `password`, `bool`, `int` (with optional `min`/`max`),
151+`select` and `checklist` (both need `options`). Every field is optional except
152+`key` and `type`.
153+
154+Saved values live under the same namespace `$api->setting()` already reads, so
155+`plugin.php` sees exactly what the generated form saved:
156+
157+```php
158+$mode = $api->setting('mode', 'test');
159+```
160+
161+---
162+
163+## Admin pages
164+
165+Drop `admin/orders.php` into your plugin and it appears in the admin sidebar.
166+It is written exactly like a built-in module — see `admin/modules/_template.php`
167+— with the same docblock header and the same `acp_*` helpers:
168+
169+```php
170+<?php
171+/**
172+ * Title: Orders
173+ * Icon: fa-shopping-cart
174+ * Group: Economy
175+ * Order: 60
176+ * Description: One line under the page title.
177+ */
178+```
179+
180+Its key is namespaced `my_plugin__orders`, so a plugin can never shadow a core
181+module by picking the same filename. Use that key with `acp_redirect()`.
182+
183+---
184+
185+## install.sql
186+
187+Run on *Install* and again on every *Update*, statement by statement.
188+
189+**Every statement must be idempotent** — `CREATE TABLE IF NOT EXISTS` and the
190+like. ZnoteX does not track which statements already ran, so an update simply
191+runs the whole file again: whatever the new version added gets created, and what
192+was already there is left alone with its data intact.
193+
194+Neither *Disable* nor *Uninstall* **ever drops a table**. Losing a player's data
195+because someone clicked a button would be the wrong default. Removing a plugin
196+for good is deleting its folder and dropping its tables yourself.
197+
198+### Versioning
199+
200+The version in `plugin.json` is what the update check compares, with PHP's
201+`version_compare()`. Raise it whenever you ship a change that needs
202+`install.sql` re-run, and keep it plain: `1.0.0`, `1.1.0`, `2.0.0`.
203+
204+---
205+
206+## Hooks
207+
208+```php
209+// React to something. Return value ignored.
210+znote_hook_register('shop.purchased', function (array $data) { ... });
211+
212+// Change a value. Gets the current value, returns the new one.
213+znote_hook_register('shop.price', function ($price, array $data) { return $price - 5; });
214+
215+// Add markup. Whatever you return is inserted into the page.
216+znote_hook_register('page.footer', function () { return '<div>...</div>'; });
217+```
218+
219+An optional third argument is the priority, default `10`, lowest first.
220+
221+A callback that throws is caught and logged, and the site carries on. That is
222+the difference between an extension point and a landmine.
223+
224+### The hooks ZnoteX fires
225+
226+| Hook | Kind | When | `$data` |
227+|---|---|---|---|
228+| `plugins.loaded` | notify | every plugin is loaded | — |
229+| `page.head` | collect | before `</head>` | — |
230+| `page.footer` | collect | before `</body>` | — |
231+| `shop.price` | filter | before a purchase is priced | `account_id`, `offer_id`, `offer` |
232+| `shop.purchased` | notify | after the points are taken | `account_id`, `offer_id`, `type`, `itemid`, `count`, `points` |
233+| `account.registered` | notify | after an account is created | `name`, `email` |
234+| `character.created` | notify | after a character is created | `name`, `account_id`, `vocation` |
235+| `character.renamed` | notify | after an admin renames a character | `player_id`, `old_name`, `new_name` |
236+| `payment.completed` | notify | after a real-money payment is recorded | `provider`, `reference`, `provider_reference`, `account_id`, `price`, `currency` |
237+
238+`shop.price` is the one to reach for when you want to change what something
239+costs. Its result is used for all three of the affordability check, the points
240+actually deducted and the shop log, so they cannot disagree.
241+
242+`page.head` and `page.footer` are injected into the theme's own output, so they
243+work with themes written long before your plugin existed — including ones that
244+never call a plugin function.
245+
246+### Your own hooks
247+
248+Publish one and other plugins can extend yours:
249+
250+```php
251+znote_hook('coupon.redeemed', array('code' => $code, 'account_id' => $id));
252+```
253+
254+### Need a hook that isn't there?
255+
256+Adding one is two lines in the core file, and hooks with no listeners cost
257+almost nothing. Open an issue rather than forking.
258+
259+---
260+
261+## The example
262+
263+`plugins/shop_coupons/` is a working plugin that uses every one of these:
264+a public page, an admin page, its own tables, a filter hook that discounts a
265+purchase, a notify hook that consumes the discount afterwards, and a collect
266+hook that puts a banner in the footer. Read it top to bottom — it is commented
267+as a tutorial rather than as production code.
268+
269+---
270+
271+## Checklist
272+
273+- [ ] Folder name is lowercase, unique, and matches nothing in ZnoteX.
274+- [ ] `plugin.json` is valid JSON.
275+- [ ] `plugin.php` prints nothing and runs no queries at load.
276+- [ ] `install.sql` is idempotent.
277+- [ ] Tables and functions are prefixed with the plugin name.
278+- [ ] Every form has `<?= acp_csrf_field() ?>` (admin) or `Token::create()` (public).
279+- [ ] Every value that reaches SQL goes through `esc()` or `(int)`.
280+- [ ] Every value that reaches the page goes through `h()` or `htmlspecialchars()`.
281+- [ ] It still works when it is disabled — that is, nothing else references it.
A powergamers.php +125-0 View file
@@ -0,0 +1,125 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+if (!$config['powergamers']['enabled']) {
4+ echo 'This page has been disabled at config.php.';
5+ theme_close();
6+ exit();
7+}
8+
9+$query_CTE = "
10+ WITH CTE_history AS (
11+ SELECT
12+ `id`,
13+ `player_id`,
14+ CAST(DATE_FORMAT(FROM_UNIXTIME(`lastlogin`), '%y%m%d') as int) AS `login_int`,
15+ CAST(DATE_FORMAT(FROM_UNIXTIME(`lastlogout`), '%y%m%d') as int) AS `logout_int`,
16+ `experience`
17+ FROM `player_history_skill`
18+ ), CTE_time AS (
19+ SELECT
20+ 1 AS `link`,
21+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 7 * 24 * 60 * 60), '%y%m%d') as int) AS `d7ago`,
22+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 6 * 24 * 60 * 60), '%y%m%d') as int) AS `d6ago`,
23+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 5 * 24 * 60 * 60), '%y%m%d') as int) AS `d5ago`,
24+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 4 * 24 * 60 * 60), '%y%m%d') as int) AS `d4ago`,
25+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 3 * 24 * 60 * 60), '%y%m%d') as int) AS `d3ago`,
26+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 2 * 24 * 60 * 60), '%y%m%d') as int) AS `d2ago`,
27+ CAST(DATE_FORMAT(FROM_UNIXTIME(UNIX_TIMESTAMP() - 1 * 24 * 60 * 60), '%y%m%d') as int) AS `d1ago`
28+ ), CTE_first AS (
29+ SELECT `player_id`, MIN(`id`) AS `id`
30+ FROM CTE_history
31+ GROUP BY `player_id`
32+ ), CTE_7b AS (
33+ SELECT `player_id`, MAX(`id`) AS `id`
34+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
35+ WHERE `logout_int` <= `t`.`d7ago`
36+ GROUP BY `player_id`
37+ ), CTE_6b AS (
38+ SELECT `player_id`, MAX(`id`) AS `id`
39+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
40+ WHERE `logout_int` <= `t`.`d6ago`
41+ GROUP BY `player_id`
42+ ), CTE_5b AS (
43+ SELECT `player_id`, MAX(`id`) AS `id`
44+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
45+ WHERE `logout_int` <= `t`.`d5ago`
46+ GROUP BY `player_id`
47+ ), CTE_4b AS (
48+ SELECT `player_id`, MAX(`id`) AS `id`
49+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
50+ WHERE `logout_int` <= `t`.`d4ago`
51+ GROUP BY `player_id`
52+ ), CTE_3b AS (
53+ SELECT `player_id`, MAX(`id`) AS `id`
54+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
55+ WHERE `logout_int` <= `t`.`d3ago`
56+ GROUP BY `player_id`
57+ ), CTE_2b AS (
58+ SELECT `player_id`, MAX(`id`) AS `id`
59+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
60+ WHERE `logout_int` <= `t`.`d2ago`
61+ GROUP BY `player_id`
62+ ), CTE_1b AS (
63+ SELECT `player_id`, MAX(`id`) AS `id`
64+ FROM CTE_history INNER JOIN CTE_time AS `t` ON `t`.`link` = 1
65+ WHERE `logout_int` <= `t`.`d1ago`
66+ GROUP BY `player_id`
67+ )
68+";
69+$cache = new Cache('engine/cache/page_powergamers');
70+if ($cache->hasExpired()) {
71+ $players = db()->fetchAll($query_CTE."
72+ SELECT
73+ `p`.`name`,
74+ IFNULL(`p`.`experience`, 0) - CASE WHEN `h7b`.`experience` IS NULL
75+ THEN `hfb`.`experience`
76+ ELSE `h7b`.`experience`
77+ END AS `diff_exp`,
78+ CAST(`p`.`experience` as SIGNED) - IFNULL(`h1b`.`experience`, 0) AS `diff_0`,
79+ IFNULL(`h1b`.`experience`, 0) - IFNULL(`h2b`.`experience`, 0) AS `diff_1`,
80+ IFNULL(`h2b`.`experience`, 0) - IFNULL(`h3b`.`experience`, 0) AS `diff_2`,
81+ IFNULL(`h3b`.`experience`, 0) - IFNULL(`h4b`.`experience`, 0) AS `diff_3`,
82+ IFNULL(`h4b`.`experience`, 0) - IFNULL(`h5b`.`experience`, 0) AS `diff_4`,
83+ IFNULL(`h5b`.`experience`, 0) - IFNULL(`h6b`.`experience`, 0) AS `diff_5`,
84+ IFNULL(`h6b`.`experience`, 0) - IFNULL(`h7b`.`experience`, 0) AS `diff_6`
85+ FROM `players` AS `p`
86+ LEFT JOIN CTE_first AS `first` ON `p`.`id` = `first`.`player_id`
87+ LEFT JOIN CTE_1b AS `d1b` ON `p`.`id` = `d1b`.`player_id`
88+ LEFT JOIN CTE_2b AS `d2b` ON `p`.`id` = `d2b`.`player_id`
89+ LEFT JOIN CTE_3b AS `d3b` ON `p`.`id` = `d3b`.`player_id`
90+ LEFT JOIN CTE_4b AS `d4b` ON `p`.`id` = `d4b`.`player_id`
91+ LEFT JOIN CTE_5b AS `d5b` ON `p`.`id` = `d5b`.`player_id`
92+ LEFT JOIN CTE_6b AS `d6b` ON `p`.`id` = `d6b`.`player_id`
93+ LEFT JOIN CTE_7b AS `d7b` ON `p`.`id` = `d7b`.`player_id`
94+ LEFT JOIN CTE_history AS `hfb` ON `first`.`id` = `hfb`.`id`
95+ LEFT JOIN CTE_history AS `h1b` ON `d1b`.`id` = `h1b`.`id`
96+ LEFT JOIN CTE_history AS `h2b` ON `d2b`.`id` = `h2b`.`id`
97+ LEFT JOIN CTE_history AS `h3b` ON `d3b`.`id` = `h3b`.`id`
98+ LEFT JOIN CTE_history AS `h4b` ON `d4b`.`id` = `h4b`.`id`
99+ LEFT JOIN CTE_history AS `h5b` ON `d5b`.`id` = `h5b`.`id`
100+ LEFT JOIN CTE_history AS `h6b` ON `d6b`.`id` = `h6b`.`id`
101+ LEFT JOIN CTE_history AS `h7b` ON `d7b`.`id` = `h7b`.`id`
102+ WHERE IFNULL(`p`.`experience`, 0) - CASE WHEN `h7b`.`experience` IS NULL THEN `hfb`.`experience` ELSE `h7b`.`experience` END != 0
103+ ORDER BY IFNULL(`p`.`experience`, 0) - CASE WHEN `h7b`.`experience` IS NULL THEN `hfb`.`experience` ELSE `h7b`.`experience` END DESC
104+ ");
105+ $cache->setContent($players);
106+ $cache->save();
107+} else {
108+ $players = $cache->load();
109+}
110+
111+$dates = db()->fetchOne("
112+ SELECT
113+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 7 * 24 * 60 * 60, '%d %b') AS `d7ago`,
114+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 6 * 24 * 60 * 60, '%d %b') AS `d6ago`,
115+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 5 * 24 * 60 * 60, '%d %b') AS `d5ago`,
116+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 4 * 24 * 60 * 60, '%d %b') AS `d4ago`,
117+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 3 * 24 * 60 * 60, '%d %b') AS `d3ago`,
118+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 2 * 24 * 60 * 60, '%d %b') AS `d2ago`,
119+ FROM_UNIXTIME(UNIX_TIMESTAMP() - 1 * 24 * 60 * 60, '%d %b') AS `d1ago`,
120+ FROM_UNIXTIME(UNIX_TIMESTAMP(), '%d %b') AS `d0ago`
121+");
122+
123+view('powergamers');
124+
125+theme_close();
A protected.php +8-0 View file
@@ -0,0 +1,8 @@
1+<?php
2+require_once 'engine/init.php';
3+// To direct users here, add: protect_page(); Here before loading header.
4+theme_open();
5+
6+view('protected');
7+
8+theme_close();
A queststatus.php +69-0 View file
@@ -0,0 +1,69 @@
1+<?php require_once 'engine/init.php';
2+
3+if (empty($config['queststatus_enabled'])) {
4+ header('Location: index.php');
5+ exit();
6+}
7+
8+protect_page();
9+theme_open();
10+
11+$characters = user_character_list($session_user_id);
12+$selected = isset($_GET['name']) ? htmlspecialchars($_GET['name']) : false;
13+
14+$user_id = false;
15+foreach ($characters as $char) {
16+ if ($selected === false) { $selected = $char['name']; }
17+ if ($char['name'] === $selected) { $user_id = (int)$char['id']; }
18+}
19+
20+$quests = array(
21+ 'Bearslayer' => 1050,
22+ 'Sword Quest' => 1337,
23+ 'Postman Quest' => array(1338, 3),
24+);
25+
26+$completed = '<font color="green">[' . t('quest.completed') . ']</font>';
27+$notstarted = '';
28+
29+function queststatus_progress($min, $max) {
30+ $percent = $max > 0 ? round(($min / $max) * 100) : 0;
31+ return '<font color="orange">[' . $percent . '%]</font>';
32+}
33+?>
34+<?php if ($characters !== false && count($characters) > 0): ?>
35+<form method="get" action="queststatus.php" style="margin-bottom:10px;">
36+ <select name="name" onchange="this.form.submit();">
37+ <?php foreach ($characters as $char): ?>
38+ <option value="<?= htmlspecialchars($char['name']) ?>"<?= $char['name'] === $selected ? ' selected' : '' ?>><?= htmlspecialchars($char['name']) ?></option>
39+ <?php endforeach; ?>
40+ </select>
41+ <noscript><input type="submit" value="<?= t('common.go') ?>"></noscript>
42+</form>
43+<table id="questTable">
44+ <tr class="yellow">
45+ <td><?= t('quest.name') ?></td>
46+ <td><?= t('quest.status') ?></td>
47+ </tr>
48+ <?php foreach ($quests as $key => $quest):
49+ if (!is_array($quest)) {
50+ $query = db()->fetchOne("SELECT `value` FROM `player_storage` WHERE `key` = ? AND `player_id` = ? AND `value` = 1 LIMIT 1;", [(int)$quest, (int)$user_id]);
51+ $quest = ($query !== false) ? $completed : $notstarted;
52+ } else {
53+ $query = db()->fetchOne("SELECT `value` FROM `player_storage` WHERE `key` = ? AND `player_id` = ? AND `value` > 0 LIMIT 1;", [(int)$quest[0], (int)$user_id]);
54+ if (!$query) $quest = $notstarted;
55+ elseif ($query['value'] >= $quest[1]) $quest = $completed;
56+ else $quest = queststatus_progress($query['value'], $quest[1]);
57+ }
58+ ?>
59+ <tr>
60+ <td><?= $key ?></td>
61+ <td><?= $quest ?></td>
62+ </tr>
63+ <?php endforeach; ?>
64+</table>
65+<?php else: ?>
66+<p><?= t('quest.no_characters') ?></p>
67+<?php endif; ?>
68+<?php
69+theme_close();
A README.md +526-0 View file
@@ -0,0 +1,526 @@
1+<div align="center">
2+
3+# ZnoteX
4+
5+<img width="114" height="30" alt="index_f089e12e" src="https://github.com/user-attachments/assets/8a521795-fb9b-48c3-877b-977bea4ca716" />
6+
7+
8+**A complete website for your Open Tibia server.**
9+
10+Version 2.0.5 · Maintained by [Open Games Community](https://opengamescommunity.com)
11+
12+[Website](https://opengamescommunity.com) · [Source & releases](https://github.com/Open-Games-Community/ZnoteX) · [Themes](layouts/README.md) · [Plugins](plugins/README.md)
13+
14+[![CodeFactor](https://www.codefactor.io/repository/github/open-games-community/znotex/badge/main)](https://www.codefactor.io/repository/github/open-games-community/znotex/overview/main)
15+[![PHP Compatibility](https://github.com/Open-Games-Community/ZnoteX/actions/workflows/php-compatibility.yml/badge.svg)](https://github.com/Open-Games-Community/ZnoteX/actions/workflows/php-compatibility.yml)
16+
17+</div>
18+
19+---
20+
21+## About
22+
23+ZnoteX is a full automatic account creator (AAC) and website for Open Tibia servers — account
24+registration, character management, highscores, guilds, houses, a forum, a shop and an admin panel,
25+all in one package. It is written in PHP with a simple procedural framework, so it is easy to read
26+and easy to modify.
27+
28+The original ZnoteX went unmaintained for roughly five years. This repository picks the project
29+back up rather than starting over — we think it is the strongest foundation among the available
30+Open Tibia AAC projects, and we intend to keep building on it.
31+
32+---
33+
34+## Requirements
35+
36+| | |
37+| --- | --- |
38+| **PHP** | 8.1 or newer — 8.1, 8.2, 8.3, 8.4 and 8.5 all supported |
39+| **Database** | MySQL or MariaDB |
40+| **Required extension** | `mysqli` |
41+| **Optional extensions** | `curl` (PayPal, reCaptcha, e-mail) · `openssl` (reCaptcha) · `gd` (guild images) · `apcu` (memory cache) |
42+
43+> PHP 8.0 and older are **not** supported and will be refused at startup.
44+
45+**Optional:** for e-mail verification and account recovery, download
46+[PHPMailer 6.x](https://github.com/PHPMailer/PHPMailer/releases) and extract it into the ZnoteX
47+directory as a folder named `PHPMailer`.
48+
49+---
50+
51+## Supported servers
52+
53+Set `$config['ServerEngine']` in `config.php` to match your server:
54+
55+| Server | `ServerEngine` |
56+| --- | --- |
57+| TFS 1.6 | `TFS_16` |
58+| TFS 1.1 – 1.4.2 | `TFS_10` |
59+| Canary / OTServBR-Global | `CANARY` |
60+| TFS 0.3.6+ / 0.4 / OTX | `TFS_03` |
61+| TFS 0.2.13+ | `TFS_02` |
62+| OTHire | `OTHIRE` |
63+
64+TFS 1.0 is not supported.
65+
66+**Canary notes** — two-factor authentication is unavailable (Canary's account table has nowhere to
67+store it), and the shop uses Znote's own points system rather than Canary coins.
68+
69+---
70+
71+## Web server stacks
72+
73+You need Apache (or nginx) + PHP 8.1+ + MySQL/MariaDB. On Windows, any of these bundles work — just
74+make sure you grab a build that ships **PHP 8.1 or newer**.
75+
76+| Stack | Download | Why pick it |
77+| --- | --- | --- |
78+| **Uniform Server** (UniServerZ) | [uniformserver.com](https://www.uniformserver.com/) · [SourceForge](https://sourceforge.net/projects/miniserver/) | Portable and very light on resources. No installer — unzip and run, easy to move or back up. A great default for a home-hosted server. |
79+| **XAMPP** | [apachefriends.org](https://www.apachefriends.org/) | The most popular and the easiest to set up. Includes phpMyAdmin. Changing PHP version means installing a different XAMPP build. |
80+| **WampServer** | [wampserver.com](https://www.wampserver.com/) | The fastest of the three, and you can switch PHP/MySQL versions from the tray icon. **Heavy on RAM** — MySQL has been seen using 5 GB+. Only worth it if the machine has memory to spare. |
81+
82+On a Linux VPS or shared hosting you do not need any of these. Just set the hosting panel to PHP 8.1
83+or newer (8.3 / 8.4 recommended).
84+
85+---
86+
87+## Installation
88+
89+### Docker (fastest way to try it)
90+
91+```
92+git clone https://github.com/Open-Games-Community/ZnoteX.git
93+cd ZnoteX
94+cp .env.example .env
95+docker compose up -d
96+```
97+
98+That's it — open **http://localhost:8080**. The stack brings up:
99+
100+| Service | What it's for | Default URL |
101+| --- | --- | --- |
102+| **znotex** | PHP 8.5 + Apache, ZnoteX itself, Composer dependencies already installed | http://localhost:8080 |
103+| **db** | MySQL 8.4, pre-loaded with a demo game schema matching `ZNOTE_SERVER_ENGINE` | localhost:3306 |
104+| **phpmyadmin** | Browse the database | http://localhost:8081 |
105+| **mailpit** | Every outgoing e-mail (registration, recovery, etc.) is caught here instead of actually sending | http://localhost:8025 |
106+
107+`ZNOTE_SERVER_ENGINE` in `.env` picks which game database gets imported on first boot, matching
108+the same six choices the installer offers:
109+
110+| Value | Engine | Demo accounts/characters? |
111+| --- | --- | --- |
112+| `TFS_10` (default) | TFS 1.1 - 1.4.2 | Yes - account **`demo`** / password **`demo123`** already has admin panel access, with 3 demo characters |
113+| `TFS_16` | TFS 1.6 | Schema only |
114+| `CANARY` | Canary / OTServBR-Global | Schema only |
115+| `TFS_03` | TFS 0.3.6+ / 0.4 / OTX | Schema only |
116+| `TFS_02` | TFS 0.2.13+ | Falls back to the TFS_03 schema - no dedicated 0.2.x schema is bundled |
117+| `OTHIRE` | OTHire | Schema only |
118+
119+Set it in `.env` **before** the first `docker compose up -d` — the schema is only imported once,
120+into a fresh database volume. To switch engines afterward, `docker compose down -v` (this wipes
121+the database) and start again. `config.local.php` is generated automatically from
122+`docker-compose.yml`'s environment values on every container start — edit those instead of the
123+file itself. Change ports or credentials in `.env` before the first start if the defaults collide
124+with something else on your machine.
125+
126+This environment is for trying ZnoteX or developing on it — every bundled game schema is a demo,
127+not a real Tibia server. Point `ZNOTE_DB_*` at your actual server's database for production use.
128+
129+### The installer
130+
131+Extract ZnoteX into your web directory and open **`/install/`** in a browser. Six steps:
132+
133+| | |
134+| --- | --- |
135+| **1. Requirements** | PHP version, `mysqli`, and whether `engine/cache/` is writable |
136+| **2. Database** | Credentials, and a check that your **OT server's own schema is already imported** |
137+| **3. Server** | Which engine this site sits in front of, the site name and its URL |
138+| **4. Schema** | Imports `SQL/znote_schema.sql` — only the `znote_*` tables |
139+| **5. Administrator** | Creates an account and a character, and remembers the name |
140+| **6. Finish** | Writes `config.local.php` and locks the installer |
141+
142+**Import your OT server's schema first.** ZnoteX reads `accounts` and `players`; it has never
143+created them and will not pretend to. Step 2 refuses to continue until they exist — importing
144+TFS/Canary's own `schema.sql` afterwards would overwrite what the installer is about to write.
145+
146+Step 5 creates a real, working administrator: the account, a character on it, and the password
147+hashed the way `login.php` expects on your engine. Step 6 puts that **account name** in
148+`page_admin_access`, so you can reach `/admin/` the moment the installer finishes. It writes to
149+**`config.local.php`**, not `config.php` — see below — though a checkbox on the last step will
150+write the admin name into `config.php` instead if you prefer.
151+
152+When it is done, **delete the `install/` folder**. It refuses to run again on its own (step 6
153+leaves a lock file), but there is no reason to leave it on a public server.
154+
155+### config.php and config.local.php
156+
157+`config.php` holds every default and every comment. `config.local.php` holds only what is
158+specific to *this* install — database credentials, engine, site name, admin names — and is
159+included last, so it wins.
160+
161+That split is what makes updating painless: a new ZnoteX release can ship a new `config.php`
162+without touching your settings. **Keep `config.local.php` out of version control.**
163+
164+Most other settings are editable from **Admin Panel → Settings** without opening a file at all.
165+
166+### Installing by hand
167+
168+If you would rather not use the installer, or it cannot write the config file:
169+
170+1. Import your OT server's schema, then `SQL/znote_schema.sql`, into the same database.
171+2. Create `config.local.php` next to `config.php`:
172+
173+```php
174+<?php
175+$config['sqlHost'] = '127.0.0.1';
176+$config['sqlUser'] = 'your_db_user';
177+$config['sqlPassword'] = 'your_db_password';
178+$config['sqlDatabase'] = 'your_db_name';
179+
180+$config['ServerEngine'] = 'TFS_10'; // see "Supported servers" above
181+$config['site_title'] = 'My Server';
182+$config['site_url'] = 'https://example.com/';
183+$config['page_admin_access'] = array('YourAccountName');
184+```
185+
186+3. Make `engine/cache/` writable by the web server.
187+4. Open the site. If anything is misconfigured, the page tells you what to fix.
188+
189+### Memory cache (APCu)
190+
191+ZnoteX caches highscores, news and similar pages. It can keep that cache in files under
192+`engine/cache/`, or in RAM via the **APCu** extension.
193+
194+`config.php` ships with `'memory' => true`, so a fresh install without APCu stops on every cached
195+page with *"Configuration error! APCu is not enabled."* If you see that, you have two choices —
196+install APCu, or switch to the file cache by putting this in `config.local.php`:
197+
198+```php
199+$config['cache']['memory'] = false;
200+```
201+
202+The file cache needs no extension, works everywhere, and only requires `engine/cache/` to be
203+writable.
204+
205+**APCu is optional.** It saves a few disk reads per request. On a local or low-traffic server you
206+will not notice the difference — it is worth installing once you have real player traffic.
207+
208+#### Installing APCu on Windows
209+
210+Download from **[pecl.php.net/package/APCu/5.1.28](https://pecl.php.net/package/APCu/5.1.28)** and
211+click the **DLL** link. The build must match your PHP exactly — check yours with `php -i` or
212+`phpinfo()`:
213+
214+| Filename part | Comes from |
215+| --- | --- |
216+| `8.3` | your PHP version |
217+| `ts` / `nts` | *Thread Safety* — `enabled` means **ts** |
218+| `vs16` / `vs17` | *Compiler* — Visual C++ 2019 is `vs16`, 2022 is `vs17` |
219+| `x64` / `x86` | *Architecture* |
220+
221+Uniform Server is thread-safe, so with PHP 8.3 it needs
222+`php_apcu-5.1.28-8.3-ts-vs16-x64.zip`. Most Windows guides say `nts` because that is what other
223+stacks use — picking the wrong one means the DLL is ignored with no error.
224+
225+1. Copy `php_apcu.dll` from the zip into your PHP `extensions` (or `ext`) folder — the path in
226+ `extension_dir`.
227+2. Add to your `php.ini`:
228+ ```ini
229+ extension=apcu
230+ apc.enabled=1
231+ ```
232+ Uniform Server has no single `php.ini`: the web server reads `php_production.ini` or
233+ `php_development.ini` from `core/php83/` depending on the mode it is running in.
234+3. Restart Apache, then set `memory` to `true`.
235+
236+On Linux, `pecl install apcu` or your distribution's `php-apcu` package.
237+
238+### Already have players?
239+
240+Open **`/special/`** to convert an existing OT database for ZnoteX.
241+
242+Coming from another AAC instead? **Admin Panel → Settings → Convert SQL** takes a **MyAAC** or
243+**Gesior2012** database dump and gives you back a ZnoteX conversion SQL — accounts, players,
244+news, gallery and the rest. Tables ZnoteX has no equivalent for are preserved rather than
245+dropped.
246+
247+### Upgrading
248+
249+Use **Admin Panel → Update** (see below) — it handles this automatically. If you would rather
250+do it by hand, replace everything **except** `config.local.php`, `layouts/`, `plugins/` and
251+`engine/cache/`, apply any new file in `SQL/migrations/`, and check **Admin Panel → Plugins** in
252+case a plugin has an update waiting.
253+
254+---
255+## Update ZnoteX
256+
257+**Admin Panel → Update** checks, verifies and installs new ZnoteX releases directly from
258+GitHub — no re-running the installer, no manually copying files. It downloads the release,
259+checks its digital signature and per-file checksums, runs a pre-installation check (PHP version,
260+extensions, disk space, writable paths, local modifications), backs up every file it is about to
261+touch, then installs. If anything goes wrong afterwards, **Restore latest file backup** puts the
262+previous version straight back.
263+
264+---
265+## Features
266+
267+<details open>
268+<summary><b>Accounts &amp; characters</b></summary>
269+
270+- Account registration, password and e-mail changes
271+- E-mail verification and lost-account recovery
272+- Two-factor authentication
273+- reCaptcha anti-spam
274+- Character creation with custom vocations, starting skills and towns
275+- Starting items via the included Lua script
276+- Soft character deletion, and hiding characters from the public list
277+- Support helpdesk with tickets
278+
279+</details>
280+
281+<details>
282+<summary><b>Community</b></summary>
283+
284+- **Forum** — custom boards, guild boards, admin-only feedback board, level restrictions,
285+ outfit avatars, player positions, sticky / closed / hidden threads, and search
286+- **Guilds** — create and disband, invites, ranks, nicknames, guild images and descriptions,
287+ war declarations and ongoing war tracking
288+- **Character profiles** — vocation, level, guild, skills, full outfit and equipment display,
289+ achievements, deaths, quest progression and player comments
290+
291+</details>
292+
293+<details>
294+<summary><b>Server information</b></summary>
295+
296+- Highscores with vocation and skill filters
297+- Latest deaths and latest kills
298+- Server info page with PvP settings, rates and experience stages (from your `config.lua` and `stages.xml`)
299+- Spells list with vocation filters (from `spells.xml`)
300+- Item list (from `items.xml`)
301+- Creature library and monster loot tables (from your `data/monster/` folder)
302+- Interactive world map from your OTClient `.otmm` minimap: drag, zoom, floor by floor
303+- Houses list with town filters, house bidding, and direct purchase with shop points
304+- Downloads page with client links and a connection guide
305+
306+All of the above are uploaded once in **Admin Panel → Server Info** — no FTP, no pasting file
307+contents into a public page.
308+
309+</details>
310+
311+<details>
312+<summary><b>Shop &amp; payments</b></summary>
313+
314+- Database shop offers managed from the admin panel: items, premium days, gender change, name change, outfits, mounts, and custom types
315+- Item market: buy and sell listings, item search, price comparison and transaction history
316+- Payment gateways: **PayPal**, **PagSeguro** , **Mercado Pago**, **Stripe** and **PayGol** (SMS)
317+
318+</details>
319+
320+<details>
321+<summary><b>Administration</b></summary>
322+
323+- New built-in admin control panel available at `/admin/`
324+- Responsive sidebar layout with day/night theme switch
325+- Dashboard with accounts, characters, online players, guilds, houses, shop points and moderation queues
326+- Delete characters, ban characters and accounts
327+- Change account passwords, grant in-game positions
328+- Give shop points, edit player level and skills
329+- Teleport one player or everyone to a town or position
330+- Review in-game bug reports, helpdesk tickets and forum feedback
331+- Shop Manager for adding, previewing, hiding and removing database shop offers
332+- Shop Pending / History page for pending deliveries and completed orders
333+- Moderate gallery uploads, post news and changelogs
334+- Server Info pages that import `config.lua`, `stages.xml`, `items.xml`, `spells.xml`, your
335+ monster folder and an OTClient `.otmm` minimap
336+- Convert a **MyAAC** or **Gesior2012** database into ZnoteX from the panel
337+- Rename characters, and search every page *and setting* from the top bar
338+
339+</details>
340+
341+<details>
342+<summary><b>Setup, themes &amp; extensions</b></summary>
343+
344+- Six-step web installer at `/install/` that checks requirements, verifies your OT schema is
345+ present, imports the ZnoteX tables, creates the first administrator and writes the config
346+- Theme system: every theme is a folder of plain HTML and CSS under `layouts/`, switchable from
347+ the admin panel, with child themes and one-click install from a repository
348+- Per-theme options edited from the panel: background image, logos, links, and an editable line of
349+ footer text — images can be uploaded, and are stored outside the theme so an update keeps them
350+- Plugin system: add pages, admin pages, tables and behaviour from `plugins/` with no core edit,
351+ install and update from the admin panel
352+- Settings editor for most of `config.php`, and a menu builder for the site navigation
353+- Maintenance mode that keeps administrators and the login page reachable
354+
355+</details>
356+
357+<details>
358+<summary><b>Performance</b></summary>
359+
360+- Built-in cache system that serves treated data from flat files instead of hitting MySQL on
361+ every page load
362+
363+</details>
364+
365+---
366+
367+## Admin Control Panel
368+
369+Everything below lives at **`/admin/`**. Access is controlled by
370+`$config['page_admin_access']` in `config.php`. The panel is grouped the way the sidebar is.
371+
372+### Overview
373+
374+- **Search** — every page *and every setting*, by name or by what it does. Typing `download`
375+ reaches the client URL fields under Settings, not just a page whose title happens to match.
376+- **Dashboard** — server and community at a glance: environment, recent accounts and characters,
377+ top point balances, open queues.
378+- **Visitors** — traffic ZnoteX has been recording all along.
379+
380+### Content
381+
382+- **News** — write, edit and remove front-page articles, with a BBCode editor.
383+- **Changelog** — the entries shown on the public changelog page.
384+- **Gallery** — moderate player screenshot submissions.
385+- **Menus** — build the site navigation without touching a template. A top-level entry is a
386+ *category*: a heading that opens its children rather than a link of its own, so it needs no URL.
387+
388+### Players
389+
390+- **Accounts** — search an account, see its characters, points and history.
391+- **Player Tools** — punish, move, rename and maintain characters and their accounts.
392+- **Character Skills** — read and rewrite level, vocation, health, mana and skills.
393+
394+### Server Info
395+
396+Your server's own files, uploaded once here instead of pasted into public pages.
397+
398+- **Server Information** — upload `config.lua`, `stages.xml`, `items.xml`, `spells.xml` and your
399+ monster folder. Each one is parsed on upload and published to the page that uses it:
400+ `serverinfo.php`, `items.php`, `spells.php`, `creatures.php` and `monster_loot.php`. For the
401+ monsters, `monsters.xml` alone gives you the names; a **`.zip` of `data/monster/`** also gives
402+ health, experience, speed and race.
403+- **Minimap** — import the `.otmm` your OTClient/OTCv8 writes. ZnoteX converts it into map tiles
404+ and shows a pan/zoom viewer with floor arrows on Server Information. Nothing is rendered at all
405+ unless a minimap is imported.
406+
407+`config.lua` is parsed on upload and **never written to disk** — it carries your MySQL password,
408+and `engine/XML/` is served by the web server. Only the whitelisted settings are kept.
409+
410+### Economy
411+
412+- **Shop Manager** — add, preview, hide and remove shop offers. They live in `znote_shop_offers`
413+ now, not in `$config['shop_offers']`.
414+- **Payments** — gateways, credentials and the point packages players can buy.
415+- **Shop Pending / History** — pending deliveries and completed purchases.
416+- **Character Auctions** — ongoing, unclaimed and completed character sales.
417+
418+### Support
419+
420+- **Bug Reports** — triage in-game reports, reward reporters, publish changelogs.
421+- **Helpdesk** — answer, close and delete support tickets.
422+- **Feedback Board** — forum threads awaiting a staff reply.
423+
424+### Settings
425+
426+- **Layout** — switch theme, edit its options, browse and install themes from a repository.
427+- **Plugins** — install, update, enable and disable what is in `plugins/`.
428+- **Settings** — most of `config.php`, from the browser.
429+- **Convert SQL** — upload a **MyAAC** or **Gesior2012** database dump and download a ZnoteX
430+ conversion SQL. Tables ZnoteX has no equivalent for are kept rather than dropped, and each
431+ converted row is mapped back to the row it came from, so a conversion can be traced and re-run.
432+
433+---
434+
435+## Themes
436+
437+Every theme is a folder under `layouts/`. A theme is **plain HTML and CSS** — the PHP stays in
438+ZnoteX, so editing one is editing markup, not untangling a template engine. `layouts/default/`
439+is the theme that ships; `layouts/_example/` is a documented skeleton to copy.
440+
441+Everything below is in **Admin Panel → Layouts**.
442+
443+**Switching.** Every installed theme is listed with a screenshot, 12 per page. Click one to make
444+it active. It applies to the public site only — the admin panel never changes.
445+
446+**Options.** A theme declares its own settings in `theme.json` — a background image, its logos,
447+social links, a tagline, a colour, an editable line of footer text. They appear under *Options* on
448+that theme's card and are stored in the database, so you change them from the panel instead of
449+editing the theme's files, and updating the theme cannot lose them.
450+
451+An option of type `image` shows the current picture, its path, and an upload field. Uploads land
452+in `engine/img/theme/<theme>/`, deliberately outside `layouts/`, so replacing or re-extracting a
453+theme leaves them alone. An image that only exists inside a static stylesheet is reachable too:
454+the option declares the CSS rule that places it and ZnoteX writes that into the page head, so no
455+theme file has to change.
456+
457+The footer option is one line of your own, rendered above the credits — the copyright and engine
458+credits stay in the theme's files on purpose, not in the panel.
459+
460+**Installing one.** Two ways:
461+
462+- *Manually* — unzip the theme folder into `layouts/`. It appears on the next page load.
463+- *From a repository* — press **Browse themes** to list themes hosted elsewhere and install one
464+ with a button.
465+
466+The repository is configured by `$config['layout_repository']` in `config.php`, and points at
467+this project's `layouts` branch by default. Downloads are refused unless the URL is **https** and
468+its host is on `allowed_hosts` — a theme is code that runs on your server, so only point it at a
469+repository you trust. The tooling that packages themes and regenerates a catalogue lives on the
470+`layouts` branch, alongside the archives themselves.
471+
472+**Child themes.** A theme can name another as its `parent` and override only the files it wants.
473+The rest falls through to the parent, so a colour change is one stylesheet rather than a fork —
474+and the parent can still be updated underneath it.
475+
476+See [layouts/README.md](layouts/README.md) for the full contract.
477+
478+---
479+
480+## Plugins
481+
482+A plugin is a folder under `plugins/` that adds public pages, admin pages, database tables and
483+behaviour **without editing a single ZnoteX file** — so an update never costs you your work.
484+
485+```
486+plugins/my_plugin/
487+ plugin.json name, version, author, description [required]
488+ plugin.php registers hooks
489+ pages/<page>.php public page at page.php?plugin=my_plugin&p=<page>
490+ admin/<mod>.php admin page, listed in the sidebar
491+ install.sql tables, created on install
492+ assets/ css, js, images
493+```
494+
495+Everything below is in **Admin Panel → Plugins**.
496+
497+**Installing one.** Download it, unzip the folder into `plugins/`, reload the panel, press
498+**Install**. That runs its `install.sql` and switches it on. ZnoteX **never downloads a plugin by
499+itself**: a plugin is PHP that runs on every page of your site, so putting the files there stays
500+a deliberate act rather than a button.
501+
502+**Updating one.** Replace the folder with the newer version. If its `plugin.json` carries a
503+higher version than the one recorded at install time, an **Update** button appears and applies
504+whatever the new version needs.
505+
506+**Removing one.** *Disable* stops a plugin; *Uninstall* also forgets its version. Neither ever
507+drops a table — removing a plugin for good means deleting its folder and dropping its tables
508+yourself.
509+
510+> A plugin runs with the same privileges as the rest of the site, and nothing sandboxes it.
511+> Install plugins whose author you know or whose code you have read.
512+
513+`plugins/shop_coupons/` is a working example — redeemable codes that either credit shop points or
514+take a percentage off the next purchase — and is commented as a tutorial. See
515+[plugins/README.md](plugins/README.md) for the contract and the list of hooks.
516+
517+---
518+
519+## Contributing
520+
521+Issues and pull requests are welcome at
522+[github.com/Open-Games-Community/ZnoteX](https://github.com/Open-Games-Community/ZnoteX).
523+
524+## License
525+
526+See [LICENSE](LICENSE). Original ZnoteAAC modified by Alex renamed to ZnoteX; layout by Blackwolf (Snavy).
Top