| @@ -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() |
| @@ -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() |
| @@ -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() |
| @@ -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() |
| @@ -0,0 +1,3 @@ | |||
| 1 | + | <?php | |
| 2 | + | //mail('TEST', 'Hello!', 'Hello, this is a test email.', 'From: Znote OT AAC.'); | |
| 3 | + | ?> |
| @@ -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(); |
| @@ -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(); |
| @@ -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 | + | ?> |
| @@ -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(); |
| @@ -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(); |
| @@ -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 | + | ?> |
| @@ -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'); |
| @@ -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 | + | } |
| @@ -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 | + | ?> |
| @@ -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 | + | ?> |
| @@ -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 |
| @@ -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> |
| @@ -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. |
| @@ -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(); |
| @@ -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(); |
| @@ -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(); |
| @@ -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 | + | [](https://www.codefactor.io/repository/github/open-games-community/znotex/overview/main) | |
| 15 | + | [](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 & 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 & 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 & 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). |