| @@ -0,0 +1,440 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | function lws_is_request(): bool { | |
| 4 | + | if (!config('login_web_service') || config('ServerEngine') !== 'TFS_10') { | |
| 5 | + | return false; | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { | |
| 9 | + | return false; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | $body = file_get_contents('php://input'); | |
| 13 | + | if ($body === false || $body === '') { | |
| 14 | + | return false; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | $decoded = json_decode($body); | |
| 18 | + | if (!is_object($decoded) || !isset($decoded->type)) { | |
| 19 | + | return false; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | $GLOBALS['lws_request'] = $decoded; | |
| 23 | + | return true; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | function lws_field($value, bool $raw = false) { | |
| 27 | + | if (!is_scalar($value)) { | |
| 28 | + | return false; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | return $raw ? (string)$value : sanitize((string)$value); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | function lws_send($message): void { | |
| 35 | + | die(json_encode($message)); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | function lws_error(string $message, int $code = 3): void { | |
| 39 | + | die(json_encode(array('errorCode' => $code, 'errorMessage' => $message))); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | function lws_tier(): int { | |
| 43 | + | $forced = strtolower(trim((string)(config('login_protocol') ?? 'auto'))); | |
| 44 | + | ||
| 45 | + | if (in_array($forced, array('11', '12', '13', '15'), true)) { | |
| 46 | + | return (int)$forced; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | $client = (int)(config('client') ?? 0); | |
| 50 | + | ||
| 51 | + | if ($client >= 1500) return 15; | |
| 52 | + | if ($client >= 1300) return 13; | |
| 53 | + | if ($client >= 1200) return 12; | |
| 54 | + | ||
| 55 | + | return engineIsCanary() ? 12 : 11; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | function lws_lua(string $key, $fallback = null) { | |
| 59 | + | static $lua = null; | |
| 60 | + | ||
| 61 | + | if ($lua === null) { | |
| 62 | + | $loaded = function_exists('serverdata_load') ? serverdata_load('config') : false; | |
| 63 | + | $lua = is_array($loaded) ? $loaded : array(); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | return $lua[$key] ?? $fallback; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | function lws_gameserver(): array { | |
| 70 | + | $gameserver = config('gameserver'); | |
| 71 | + | if (!is_array($gameserver)) { | |
| 72 | + | $gameserver = array(); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | $gameserver += array('ip' => '127.0.0.1', 'port' => 7172, 'name' => 'ZnoteX'); | |
| 76 | + | ||
| 77 | + | $rows = db()->fetchAll(" | |
| 78 | + | SELECT `key`, `value` | |
| 79 | + | FROM `znote_global_storage` | |
| 80 | + | WHERE `key` IN('SERVER_NAME', 'IP', 'GAME_PORT') | |
| 81 | + | "); | |
| 82 | + | ||
| 83 | + | if ($rows !== false) { | |
| 84 | + | foreach ($rows as $row) { | |
| 85 | + | switch ($row['key']) { | |
| 86 | + | case 'SERVER_NAME': $gameserver['name'] = $row['value']; break; | |
| 87 | + | case 'IP': $gameserver['ip'] = $row['value']; break; | |
| 88 | + | case 'GAME_PORT': $gameserver['port'] = (int)$row['value']; break; | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | return $gameserver; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | function lws_pvptype(): int { | |
| 97 | + | $worldType = strtolower(trim((string)lws_lua('worldType', 'pvp'))); | |
| 98 | + | ||
| 99 | + | switch ($worldType) { | |
| 100 | + | case 'no-pvp': return 1; | |
| 101 | + | case 'pvp-enforced': return 2; | |
| 102 | + | case 'retro-pvp': return 3; | |
| 103 | + | case 'expert-pvp': return 0; | |
| 104 | + | case 'pvp': return engineIsCanary() ? 3 : 0; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | return 0; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | function lws_boosted(int $tier): array { | |
| 111 | + | $creature = znote_table_exists('boosted_creature') | |
| 112 | + | ? db()->fetchOne("SELECT `raceid` FROM `boosted_creature` LIMIT 1;") | |
| 113 | + | : false; | |
| 114 | + | ||
| 115 | + | $creatureRace = ($creature !== false) ? (int)$creature['raceid'] : 0; | |
| 116 | + | ||
| 117 | + | if ($tier < 12) { | |
| 118 | + | return array('raceid' => $creatureRace); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | $boss = znote_table_exists('boosted_boss') | |
| 122 | + | ? db()->fetchOne("SELECT `raceid` FROM `boosted_boss` LIMIT 1;") | |
| 123 | + | : false; | |
| 124 | + | ||
| 125 | + | return array( | |
| 126 | + | 'boostedcreature' => ($creatureRace > 0), | |
| 127 | + | 'creatureraceid' => $creatureRace, | |
| 128 | + | 'bossraceid' => ($boss !== false) ? (int)$boss['raceid'] : 0, | |
| 129 | + | 'raceid' => $creatureRace | |
| 130 | + | ); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | function lws_player_columns(int $tier): array { | |
| 134 | + | $columns = array( | |
| 135 | + | 'name', 'sex', 'level', 'vocation', 'lookbody', 'looktype', | |
| 136 | + | 'lookhead', 'looklegs', 'lookfeet', 'lookaddons', 'deletion' | |
| 137 | + | ); | |
| 138 | + | ||
| 139 | + | if ($tier >= 12) { | |
| 140 | + | foreach (array('isreward', 'istutorial') as $optional) { | |
| 141 | + | if (znote_column_exists('players', $optional)) { | |
| 142 | + | $columns[] = $optional; | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | return $columns; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | function lws_character(array $player, int $tier): array { | |
| 151 | + | $character = array( | |
| 152 | + | 'worldid' => 0, | |
| 153 | + | 'name' => $player['name'], | |
| 154 | + | 'ismale' => ((int)$player['sex'] === 1), | |
| 155 | + | 'tutorial' => false, | |
| 156 | + | 'level' => (int)$player['level'], | |
| 157 | + | 'vocation' => vocation_id_to_name($player['vocation']), | |
| 158 | + | 'outfitid' => (int)$player['looktype'], | |
| 159 | + | 'headcolor' => (int)$player['lookhead'], | |
| 160 | + | 'torsocolor' => (int)$player['lookbody'], | |
| 161 | + | 'legscolor' => (int)$player['looklegs'], | |
| 162 | + | 'detailcolor' => (int)$player['lookfeet'], | |
| 163 | + | 'addonsflags' => (int)$player['lookaddons'], | |
| 164 | + | 'ishidden' => ((int)$player['deletion'] === 1), | |
| 165 | + | 'istournamentparticipant' => false, | |
| 166 | + | 'remainingdailytournamentplaytime' => 0 | |
| 167 | + | ); | |
| 168 | + | ||
| 169 | + | if ($tier >= 12) { | |
| 170 | + | $character['tutorial'] = isset($player['istutorial']) && (int)$player['istutorial'] === 1; | |
| 171 | + | $character['dailyrewardstate'] = isset($player['isreward']) ? (int)$player['isreward'] : 0; | |
| 172 | + | $character['ismaincharacter'] = false; | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | return $character; | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | function lws_session_key(string $descriptor, string $password, $token, bool $hasSecret): string { | |
| 179 | + | $key = $descriptor . "\n" . $password; | |
| 180 | + | ||
| 181 | + | if (engineIsCanary()) { | |
| 182 | + | return $key; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | $key .= ($hasSecret && $token !== false) ? "\n" . $token : "\n"; | |
| 186 | + | $key .= "\n" . floor(time() / 30); | |
| 187 | + | ||
| 188 | + | return $key; | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | function lws_register_session(int $accountId, string $sessionKey): void { | |
| 192 | + | if (strtolower(trim((string)(config('login_auth_type') ?? 'password'))) !== 'session') { | |
| 193 | + | return; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | if (!znote_table_exists('account_sessions')) { | |
| 197 | + | return; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | $ttl = (int)(config('login_session_ttl') ?? 86400); | |
| 201 | + | if ($ttl < 60) { | |
| 202 | + | $ttl = 86400; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | $id = hash('sha256', $sessionKey); | |
| 206 | + | $now = time(); | |
| 207 | + | $expires = $now + $ttl; | |
| 208 | + | ||
| 209 | + | db()->execute(" | |
| 210 | + | INSERT INTO `account_sessions` (`id`, `account_id`, `expires`) | |
| 211 | + | VALUES (?, ?, ?) | |
| 212 | + | ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`), `expires` = VALUES(`expires`); | |
| 213 | + | ", [$id, $accountId, $expires]); | |
| 214 | + | ||
| 215 | + | db()->execute("DELETE FROM `account_sessions` WHERE `expires` < ?;", [$now]); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | function lws_premium(array $account): array { | |
| 219 | + | $free = (bool)(config('freePremium') ?? lws_lua('freePremium', false)); | |
| 220 | + | $ends = (int)($account['premium_ends_at'] ?? 0); | |
| 221 | + | ||
| 222 | + | $cap = time() + (365 * 86400); | |
| 223 | + | if ($ends > $cap) { | |
| 224 | + | $ends = $cap; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | return array( | |
| 228 | + | 'ispremium' => ($free || $ends > time()), | |
| 229 | + | 'premiumuntil' => max(0, $ends) | |
| 230 | + | ); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | function lws_handle_login($client, int $tier): void { | |
| 234 | + | $email = isset($client->email) ? lws_field($client->email) : false; | |
| 235 | + | $username = isset($client->accountname) ? lws_field($client->accountname) : false; | |
| 236 | + | $token = isset($client->token) ? lws_field($client->token) : false; | |
| 237 | + | $plain = isset($client->password) ? lws_field($client->password, true) : ''; | |
| 238 | + | ||
| 239 | + | if ($plain === false) { | |
| 240 | + | lws_error('Wrong username and/or password.'); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | $password = SHA1($plain); | |
| 244 | + | ||
| 245 | + | $fieldList = array('id', 'premium_ends_at'); | |
| 246 | + | if (config('twoFactorAuthenticator')) { | |
| 247 | + | $fieldList[] = 'secret'; | |
| 248 | + | } | |
| 249 | + | $fields = accountFieldList($fieldList); | |
| 250 | + | ||
| 251 | + | $account = false; | |
| 252 | + | ||
| 253 | + | if ($email !== false) { | |
| 254 | + | $fields .= ', `name`'; | |
| 255 | + | $account = db()->fetchOne("SELECT {$fields} FROM `accounts` WHERE `email` = ? AND `password` = ? LIMIT 1;", [$email, $password]); | |
| 256 | + | if ($account !== false) { | |
| 257 | + | $username = $account['name']; | |
| 258 | + | } | |
| 259 | + | } elseif ($username !== false) { | |
| 260 | + | $account = db()->fetchOne("SELECT {$fields} FROM `accounts` WHERE `name` = ? AND `password` = ? LIMIT 1;", [$username, $password]); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | if ($account === false) { | |
| 264 | + | lws_error('Wrong username and/or password.'); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | $hasSecret = isset($account['secret']) && $account['secret'] !== null && strlen((string)$account['secret']) > 5; | |
| 268 | + | ||
| 269 | + | if (config('twoFactorAuthenticator') === true && $hasSecret) { | |
| 270 | + | if ($token === false) { | |
| 271 | + | lws_error('Submit a valid two-factor authentication token.', 6); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | require_once(__DIR__ . '/rfc6238.php'); | |
| 275 | + | if (TokenAuth6238::verify($account['secret'], $token) !== true) { | |
| 276 | + | lws_error('Two-factor authentication failed, token is wrong.', 6); | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | $columns = '`' . implode('`, `', lws_player_columns($tier)) . '`'; | |
| 281 | + | $players = db()->fetchAll("SELECT {$columns} FROM `players` WHERE `account_id` = ? AND `deletion` = 0;", [(int)$account['id']]); | |
| 282 | + | ||
| 283 | + | if ($players === false) { | |
| 284 | + | lws_error('Character list is empty.'); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | $gameserver = lws_gameserver(); | |
| 288 | + | $descriptor = ($email !== false) ? $email : $username; | |
| 289 | + | $sessionKey = lws_session_key((string)$descriptor, $plain, $token, $hasSecret); | |
| 290 | + | ||
| 291 | + | lws_register_session((int)$account['id'], $sessionKey); | |
| 292 | + | ||
| 293 | + | $premium = lws_premium($account); | |
| 294 | + | $port = (int)$gameserver['port']; | |
| 295 | + | ||
| 296 | + | $response = array( | |
| 297 | + | 'session' => array( | |
| 298 | + | 'fpstracking' => false, | |
| 299 | + | 'optiontracking' => false, | |
| 300 | + | 'isreturner' => true, | |
| 301 | + | 'returnernotification' => false, | |
| 302 | + | 'showrewardnews' => true, | |
| 303 | + | 'tournamentticketpurchasestate' => 0, | |
| 304 | + | 'emailcoderequest' => false, | |
| 305 | + | 'sessionkey' => $sessionKey, | |
| 306 | + | 'lastlogintime' => 0, | |
| 307 | + | 'ispremium' => $premium['ispremium'], | |
| 308 | + | 'premiumuntil' => $premium['premiumuntil'], | |
| 309 | + | 'status' => 'active' | |
| 310 | + | ), | |
| 311 | + | 'playdata' => array( | |
| 312 | + | 'worlds' => array( | |
| 313 | + | array( | |
| 314 | + | 'id' => 0, | |
| 315 | + | 'name' => $gameserver['name'], | |
| 316 | + | 'externaladdress' => $gameserver['ip'], | |
| 317 | + | 'externalport' => $port, | |
| 318 | + | 'previewstate' => 0, | |
| 319 | + | 'location' => 'ALL', | |
| 320 | + | 'pvptype' => lws_pvptype(), | |
| 321 | + | 'externaladdressunprotected' => $gameserver['ip'], | |
| 322 | + | 'externaladdressprotected' => $gameserver['ip'], | |
| 323 | + | 'externalportunprotected' => $port, | |
| 324 | + | 'externalportprotected' => $port, | |
| 325 | + | 'istournamentworld' => false, | |
| 326 | + | 'restrictedstore' => false, | |
| 327 | + | 'currenttournamentphase' => 2, | |
| 328 | + | 'anticheatprotection' => false | |
| 329 | + | ) | |
| 330 | + | ), | |
| 331 | + | 'characters' => array() | |
| 332 | + | ) | |
| 333 | + | ); | |
| 334 | + | ||
| 335 | + | foreach ($players as $player) { | |
| 336 | + | $response['playdata']['characters'][] = lws_character($player, $tier); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | if ($tier >= 12 && !empty($response['playdata']['characters'])) { | |
| 340 | + | $response['playdata']['characters'][0]['ismaincharacter'] = true; | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | lws_send($response); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | function lws_handle_eventschedule(): void { | |
| 347 | + | $path = rtrim((string)config('server_path'), '/\\') . '/data/XML/events.xml'; | |
| 348 | + | ||
| 349 | + | if (!is_file($path)) { | |
| 350 | + | lws_send(array('eventlist' => array(), 'lastupdatetimestamp' => time())); | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | $xml = new DOMDocument; | |
| 354 | + | if (@$xml->load($path) === false) { | |
| 355 | + | lws_send(array('eventlist' => array(), 'lastupdatetimestamp' => time())); | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | $attr = function ($nodes, string $name) { | |
| 359 | + | foreach ($nodes as $node) { | |
| 360 | + | return $node->getAttribute($name); | |
| 361 | + | } | |
| 362 | + | return ''; | |
| 363 | + | }; | |
| 364 | + | ||
| 365 | + | $stamp = function (string $date): int { | |
| 366 | + | $parsed = date_create($date); | |
| 367 | + | return ($parsed === false) ? 0 : (int)$parsed->format('U'); | |
| 368 | + | }; | |
| 369 | + | ||
| 370 | + | $eventlist = array(); | |
| 371 | + | ||
| 372 | + | foreach ($xml->getElementsByTagName('event') as $event) { | |
| 373 | + | $eventlist[] = array( | |
| 374 | + | 'colorlight' => $attr($event->getElementsByTagName('colors'), 'colorlight'), | |
| 375 | + | 'colordark' => $attr($event->getElementsByTagName('colors'), 'colordark'), | |
| 376 | + | 'description' => $attr($event->getElementsByTagName('description'), 'description'), | |
| 377 | + | 'displaypriority' => (int)$attr($event->getElementsByTagName('details'), 'displaypriority'), | |
| 378 | + | 'enddate' => $stamp($event->getAttribute('enddate')), | |
| 379 | + | 'isseasonal' => ((int)$attr($event->getElementsByTagName('details'), 'isseasonal') === 1), | |
| 380 | + | 'name' => $event->getAttribute('name'), | |
| 381 | + | 'startdate' => $stamp($event->getAttribute('startdate')), | |
| 382 | + | 'specialevent' => (int)$attr($event->getElementsByTagName('details'), 'specialevent') | |
| 383 | + | ); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | lws_send(array('eventlist' => $eventlist, 'lastupdatetimestamp' => time())); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | function lws_handle(): void { | |
| 390 | + | header('Content-Type: application/json'); | |
| 391 | + | ||
| 392 | + | $client = $GLOBALS['lws_request'] ?? null; | |
| 393 | + | if (!is_object($client)) { | |
| 394 | + | lws_error('Type missing.'); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | $tier = lws_tier(); | |
| 398 | + | $type = lws_field($client->type); | |
| 399 | + | ||
| 400 | + | switch ($type) { | |
| 401 | + | case 'cacheinfo': | |
| 402 | + | lws_send(array( | |
| 403 | + | 'playersonline' => (int)user_count_online(), | |
| 404 | + | 'twitchstreams' => 0, | |
| 405 | + | 'twitchviewer' => 0, | |
| 406 | + | 'gamingyoutubestreams' => 0, | |
| 407 | + | 'gamingyoutubeviewer' => 0 | |
| 408 | + | )); | |
| 409 | + | break; | |
| 410 | + | ||
| 411 | + | case 'eventschedule': | |
| 412 | + | lws_handle_eventschedule(); | |
| 413 | + | break; | |
| 414 | + | ||
| 415 | + | case 'boostedcreature': | |
| 416 | + | lws_send(lws_boosted($tier)); | |
| 417 | + | break; | |
| 418 | + | ||
| 419 | + | case 'news': | |
| 420 | + | lws_send(array( | |
| 421 | + | 'gamenews' => array(), | |
| 422 | + | 'categorycounts' => array( | |
| 423 | + | 'support' => 1, | |
| 424 | + | 'game contents' => 2, | |
| 425 | + | 'useful info' => 3, | |
| 426 | + | 'major updates' => 4, | |
| 427 | + | 'client features' => 5 | |
| 428 | + | ), | |
| 429 | + | 'maxeditdate' => time() | |
| 430 | + | )); | |
| 431 | + | break; | |
| 432 | + | ||
| 433 | + | case 'login': | |
| 434 | + | lws_handle_login($client, $tier); | |
| 435 | + | break; | |
| 436 | + | ||
| 437 | + | default: | |
| 438 | + | lws_error('Unsupported type: ' . (($type === false) ? '?' : $type)); | |
| 439 | + | } | |
| 440 | + | } |
| @@ -0,0 +1,61 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | function znote_login_guard_config(): array { | |
| 4 | + | global $config; | |
| 5 | + | $cfg = (array)($config['login_guard'] ?? array()); | |
| 6 | + | ||
| 7 | + | return array( | |
| 8 | + | 'enabled' => !empty($cfg['enabled']), | |
| 9 | + | 'threshold' => max(1, (int)($cfg['threshold'] ?? 5)), | |
| 10 | + | 'window_seconds' => max(60, (int)($cfg['window_minutes'] ?? 15) * 60), | |
| 11 | + | 'lockout_seconds' => max(60, (int)($cfg['lockout_minutes'] ?? 15) * 60), | |
| 12 | + | ); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | function znote_login_guard_ip(): string { | |
| 16 | + | $ip = (string)(function_exists('getIP') ? getIP() : ($_SERVER['REMOTE_ADDR'] ?? '')); | |
| 17 | + | return substr(trim($ip), 0, 45); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | function znote_login_guard_record(string $ip, string $username, bool $success): void { | |
| 21 | + | if (!function_exists('znote_table_exists') || !znote_table_exists('znote_login_attempts')) { | |
| 22 | + | return; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | $now = time(); | |
| 26 | + | db()->execute(" | |
| 27 | + | INSERT INTO `znote_login_attempts` (`ip`, `username`, `success`, `created_at`) | |
| 28 | + | VALUES (?, ?, ?, ?); | |
| 29 | + | ", [$ip, substr($username, 0, 32), $success ? 1 : 0, $now]); | |
| 30 | + | ||
| 31 | + | if (random_int(1, 20) === 1) { | |
| 32 | + | db()->execute("DELETE FROM `znote_login_attempts` WHERE `created_at` < ?;", [$now - 86400]); | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function znote_login_guard_lockout_remaining(string $ip): int { | |
| 37 | + | $cfg = znote_login_guard_config(); | |
| 38 | + | if (!$cfg['enabled'] || !function_exists('znote_table_exists') || !znote_table_exists('znote_login_attempts')) { | |
| 39 | + | return 0; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | $now = time(); | |
| 43 | + | $windowStart = $now - $cfg['window_seconds']; | |
| 44 | + | ||
| 45 | + | $row = db()->fetchOne(" | |
| 46 | + | SELECT COUNT(*) AS `failures`, MAX(`created_at`) AS `last_failure` | |
| 47 | + | FROM `znote_login_attempts` | |
| 48 | + | WHERE `ip` = ? AND `success` = 0 AND `created_at` >= ?; | |
| 49 | + | ", [$ip, $windowStart]); | |
| 50 | + | ||
| 51 | + | if (!is_array($row) || (int)$row['failures'] < $cfg['threshold']) { | |
| 52 | + | return 0; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | $unlocksAt = (int)$row['last_failure'] + $cfg['lockout_seconds']; | |
| 56 | + | return max(0, $unlocksAt - $now); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | function znote_login_guard_is_locked(string $ip): bool { | |
| 60 | + | return znote_login_guard_lockout_remaining($ip) > 0; | |
| 61 | + | } |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | use PHPMailer\PHPMailer\PHPMailer; | |
| 4 | + | use PHPMailer\PHPMailer\Exception; | |
| 5 | + | ||
| 6 | + | class Mail { | |
| 7 | + | protected array $_config; | |
| 8 | + | ||
| 9 | + | public function __construct(array $config) { | |
| 10 | + | $this->_config = $config; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | public function sendMail(string $to, string $title, string $html, string $accname = ''): bool | |
| 14 | + | { | |
| 15 | + | try { | |
| 16 | + | $mail = new PHPMailer(true); | |
| 17 | + | ||
| 18 | + | // SMTP | |
| 19 | + | $mail->isSMTP(); | |
| 20 | + | $mail->SMTPDebug = !empty($this->_config['debug']) ? 2 : 0; | |
| 21 | + | $mail->Host = $this->_config['host']; | |
| 22 | + | $mail->Port = (int)$this->_config['port']; | |
| 23 | + | $mail->SMTPAuth = ($this->_config['username'] ?? '') !== ''; | |
| 24 | + | $mail->Username = $this->_config['username']; | |
| 25 | + | $mail->Password = $this->_config['password']; | |
| 26 | + | $mail->CharSet = 'UTF-8'; | |
| 27 | + | ||
| 28 | + | // Security | |
| 29 | + | $secure = $this->_config['securityType'] ?? ''; | |
| 30 | + | if (in_array($secure, ['ssl', 'tls'], true)) { | |
| 31 | + | $mail->SMTPSecure = $secure; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Sender / receiver | |
| 35 | + | $mail->setFrom($this->_config['email'], $this->_config['fromName']); | |
| 36 | + | $mail->addAddress($to, $accname); | |
| 37 | + | ||
| 38 | + | // Content | |
| 39 | + | $mail->isHTML(true); | |
| 40 | + | $mail->Subject = $title; | |
| 41 | + | $mail->Body = $html; | |
| 42 | + | ||
| 43 | + | // AltBody | |
| 44 | + | $plain = str_replace(['<br>', '<br/>', '<br />'], "\n", $html); | |
| 45 | + | $plain = strip_tags($plain); | |
| 46 | + | $mail->AltBody = $plain; | |
| 47 | + | ||
| 48 | + | return $mail->send(); | |
| 49 | + | ||
| 50 | + | } catch (Exception $e) { | |
| 51 | + | // Log plutôt qu'echo | |
| 52 | + | error_log('Mail error: ' . $e->getMessage()); | |
| 53 | + | return false; | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } |
| @@ -0,0 +1,274 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Navigation menus. | |
| 4 | + | * | |
| 5 | + | * Menu links used to be hardcoded in each theme, so adding one meant editing | |
| 6 | + | * PHP, and every theme carried its own copy. They live in `znote_menu` now and | |
| 7 | + | * are edited from Admin Panel > Menus. | |
| 8 | + | * | |
| 9 | + | * A theme declares the slots it renders in theme.json: | |
| 10 | + | * | |
| 11 | + | * "menus": { | |
| 12 | + | * "main": "Top navigation", | |
| 13 | + | * "sidebar": "Left column", | |
| 14 | + | * "footer": "Footer links" | |
| 15 | + | * } | |
| 16 | + | * | |
| 17 | + | * and renders one with: | |
| 18 | + | * | |
| 19 | + | * <?php foreach (theme_menu_items('main') as $item): ?> | |
| 20 | + | * <a href="<?= h($item['url']) ?>"><?= h($item['label']) ?></a> | |
| 21 | + | * <?php endforeach; ?> | |
| 22 | + | * | |
| 23 | + | * The theme keeps full control of the markup - this only supplies the data, | |
| 24 | + | * already filtered for the current visitor and nested by parent. | |
| 25 | + | */ | |
| 26 | + | ||
| 27 | + | /** | |
| 28 | + | * Entries for one location, filtered by visibility and nested. | |
| 29 | + | * | |
| 30 | + | * Each item: id, label, url, icon, target, children[]. | |
| 31 | + | * A parent whose children are all hidden still shows; a child of a hidden | |
| 32 | + | * parent does not, because it is unreachable. | |
| 33 | + | */ | |
| 34 | + | function theme_menu_items(string $location): array { | |
| 35 | + | static $cache = array(); | |
| 36 | + | ||
| 37 | + | $location = preg_replace('/[^a-z0-9_-]/', '', strtolower($location)); | |
| 38 | + | if ($location === '') { | |
| 39 | + | return array(); | |
| 40 | + | } | |
| 41 | + | if (isset($cache[$location])) { | |
| 42 | + | return $cache[$location]; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | $rows = db()->fetchAll(" | |
| 46 | + | SELECT `id`, `parent_id`, `label`, `url`, `icon`, `target`, `visibility` | |
| 47 | + | FROM `znote_menu` | |
| 48 | + | WHERE `location` = ? | |
| 49 | + | AND `active` = 1 | |
| 50 | + | ORDER BY `sort_order` ASC, `id` ASC; | |
| 51 | + | ", [$location]); | |
| 52 | + | ||
| 53 | + | if (!is_array($rows)) { | |
| 54 | + | // No table yet (migration not run) or nothing defined: the theme falls | |
| 55 | + | // back to whatever it hardcodes. | |
| 56 | + | return $cache[$location] = array(); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | $loggedIn = (function_exists('user_logged_in') && user_logged_in() === true); | |
| 60 | + | $isAdmin = $loggedIn && isset($GLOBALS['user_data']) && has_admin_panel_access($GLOBALS['user_data']); | |
| 61 | + | ||
| 62 | + | $visible = array(); | |
| 63 | + | foreach ($rows as $row) { | |
| 64 | + | switch ($row['visibility']) { | |
| 65 | + | case 'guest': $show = !$loggedIn; break; | |
| 66 | + | case 'user': $show = $loggedIn; break; | |
| 67 | + | case 'admin': $show = $isAdmin; break; | |
| 68 | + | default: $show = true; | |
| 69 | + | } | |
| 70 | + | if ($show && !menu_url_available((string)$row['url'])) { | |
| 71 | + | $show = false; | |
| 72 | + | } | |
| 73 | + | if ($show) { | |
| 74 | + | $visible[(int)$row['id']] = array( | |
| 75 | + | 'id' => (int)$row['id'], | |
| 76 | + | 'parent' => (int)$row['parent_id'], | |
| 77 | + | 'label' => (string)$row['label'], | |
| 78 | + | 'url' => (string)$row['url'], | |
| 79 | + | 'icon' => (string)$row['icon'], | |
| 80 | + | 'target' => (string)$row['target'], | |
| 81 | + | 'children' => array(), | |
| 82 | + | ); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Nest. A child whose parent was filtered out disappears with it. | |
| 87 | + | $tree = array(); | |
| 88 | + | foreach ($visible as $id => $item) { | |
| 89 | + | if ($item['parent'] > 0 && isset($visible[$item['parent']])) { | |
| 90 | + | continue; | |
| 91 | + | } | |
| 92 | + | if ($item['parent'] > 0) { | |
| 93 | + | continue; // parent hidden: so is this | |
| 94 | + | } | |
| 95 | + | $tree[$id] = $item; | |
| 96 | + | } | |
| 97 | + | foreach ($visible as $id => $item) { | |
| 98 | + | if ($item['parent'] > 0 && isset($tree[$item['parent']])) { | |
| 99 | + | $tree[$item['parent']]['children'][] = $item; | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | return $cache[$location] = array_values($tree); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | function menu_url_available(string $url): bool { | |
| 107 | + | global $config; | |
| 108 | + | ||
| 109 | + | $url = trim($url); | |
| 110 | + | if ($url === '' || $url === '#') { | |
| 111 | + | return true; | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | $path = parse_url($url, PHP_URL_PATH); | |
| 115 | + | $page = strtolower(basename($path !== null && $path !== false ? $path : $url)); | |
| 116 | + | ||
| 117 | + | if ($page === 'page.php') { | |
| 118 | + | parse_str((string)(parse_url($url, PHP_URL_QUERY) ?: ''), $mq); | |
| 119 | + | ||
| 120 | + | if (function_exists('setting')) { | |
| 121 | + | $mp = isset($mq['plugin']) ? preg_replace('/[^a-z0-9_-]/i', '', (string)$mq['plugin']) : ''; | |
| 122 | + | if ($mp !== '') { | |
| 123 | + | return setting('plugin:' . $mp . ':enabled', '0') === '1' | |
| 124 | + | && (string)setting('plugin:' . $mp . ':version', '') !== ''; | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if (function_exists('theme_file')) { | |
| 129 | + | $mp = isset($mq['p']) ? preg_replace('/[^a-z0-9_-]/i', '', (string)$mq['p']) : ''; | |
| 130 | + | if ($mp !== '') { | |
| 131 | + | return theme_file('pages/' . $mp . '.php') !== null; | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | switch ($page) { | |
| 137 | + | case 'shop.php': | |
| 138 | + | return !empty($config['shop']['enabled']); | |
| 139 | + | case 'buypoints.php': | |
| 140 | + | return !empty($config['buypoints_enabled']); | |
| 141 | + | case 'guildwar.php': | |
| 142 | + | case 'guildwars.php': | |
| 143 | + | return !empty($config['guildwar_enabled']); | |
| 144 | + | case 'forum.php': | |
| 145 | + | return !empty($config['forum']['enabled']); | |
| 146 | + | case 'powergamers.php': | |
| 147 | + | return !empty($config['powergamers']['enabled']); | |
| 148 | + | case 'toponline.php': | |
| 149 | + | return !empty($config['toponline']['enabled']); | |
| 150 | + | case 'achievements.php': | |
| 151 | + | return !empty($config['Ach']); | |
| 152 | + | case 'items.php': | |
| 153 | + | return !empty($config['items']); | |
| 154 | + | case 'credits.php': | |
| 155 | + | return $config['credits_enabled'] ?? true; | |
| 156 | + | case 'queststatus.php': | |
| 157 | + | return !empty($config['queststatus_enabled']); | |
| 158 | + | default: | |
| 159 | + | return true; | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | function theme_menu_label(string $label): string { | |
| 164 | + | $raw = trim($label); | |
| 165 | + | if ($raw === '') { | |
| 166 | + | return ''; | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | if (function_exists('t') && preg_match('/^[a-z0-9_.-]+$/i', $raw)) { | |
| 170 | + | $translated = t($raw); | |
| 171 | + | if ($translated !== $raw) { | |
| 172 | + | return $translated; | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | $keyByLabel = array( | |
| 177 | + | 'account' => 'nav.account_section', | |
| 178 | + | 'account management' => 'nav.account_management', | |
| 179 | + | 'admin panel' => 'widget.admin.panel', | |
| 180 | + | 'bans' => 'bans.title', | |
| 181 | + | 'buy points' => 'shop.buy_points', | |
| 182 | + | 'changelog' => 'changelog.title', | |
| 183 | + | 'community' => 'nav.community', | |
| 184 | + | 'contact' => 'nav.contact', | |
| 185 | + | 'create account' => 'nav.register', | |
| 186 | + | 'create character' => 'account.create_character', | |
| 187 | + | 'credits' => 'nav.credits', | |
| 188 | + | 'creatures' => 'creatures.title', | |
| 189 | + | 'donate' => 'shop.buy_points', | |
| 190 | + | 'download' => 'downloads.download', | |
| 191 | + | 'download client' => 'nav.download_client', | |
| 192 | + | 'download game' => 'nav.downloads', | |
| 193 | + | 'downloads' => 'nav.downloads', | |
| 194 | + | 'forum' => 'nav.forum', | |
| 195 | + | 'guilds' => 'nav.guilds', | |
| 196 | + | 'helpdesk' => 'helpdesk.title', | |
| 197 | + | 'highscores' => 'nav.highscores', | |
| 198 | + | 'home' => 'nav.home', | |
| 199 | + | 'houses' => 'nav.houses', | |
| 200 | + | 'information' => 'front.server_information', | |
| 201 | + | 'item market' => 'nav.item_market', | |
| 202 | + | 'kill statistics' => 'nav.kill_statistics', | |
| 203 | + | 'kills statistics' => 'nav.kill_statistics', | |
| 204 | + | 'latest deaths' => 'deaths.latest', | |
| 205 | + | 'latest news' => 'nav.latest_news', | |
| 206 | + | 'library' => 'nav.library', | |
| 207 | + | 'log in' => 'nav.login', | |
| 208 | + | 'login' => 'nav.login', | |
| 209 | + | 'logout' => 'nav.logout', | |
| 210 | + | 'lost account' => 'recovery.lost_account_title', | |
| 211 | + | 'lost account?' => 'nav.lost_account', | |
| 212 | + | 'my account' => 'nav.account', | |
| 213 | + | 'news' => 'nav.news', | |
| 214 | + | 'register' => 'nav.register', | |
| 215 | + | 'server info' => 'nav.serverinfo', | |
| 216 | + | 'server information' => 'front.server_information', | |
| 217 | + | 'settings' => 'settings.title', | |
| 218 | + | 'shop' => 'nav.shop', | |
| 219 | + | 'spells' => 'spells.title', | |
| 220 | + | 'store' => 'nav.shop', | |
| 221 | + | 'support' => 'nav.support', | |
| 222 | + | 'vote for us' => 'nav.vote_for_us', | |
| 223 | + | 'vote for us!' => 'nav.vote_for_us', | |
| 224 | + | 'wheel of destiny' => 'tco.nav.wheelofdestiny', | |
| 225 | + | 'wheel of destiny planner' => 'tco.nav.wheelofdestiny', | |
| 226 | + | 'who is online' => 'nav.online', | |
| 227 | + | 'wikipedia' => 'nav.wikipedia', | |
| 228 | + | 'wiki search' => 'nav.wiki_search', | |
| 229 | + | ); | |
| 230 | + | ||
| 231 | + | $normalized = strtolower(preg_replace('/\s+/', ' ', str_replace(array('_', '-'), ' ', $raw))); | |
| 232 | + | if (isset($keyByLabel[$normalized]) && function_exists('t_default')) { | |
| 233 | + | return t_default($keyByLabel[$normalized], $label); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | return $label; | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /** | |
| 240 | + | * The menu slots the active theme declares, as slug => label. | |
| 241 | + | * Falls back to a single "main" slot so the admin page is never empty. | |
| 242 | + | */ | |
| 243 | + | function theme_menu_locations(?string $theme = null): array { | |
| 244 | + | $manifest = theme_manifest($theme ?? theme_active()); | |
| 245 | + | $declared = $manifest['menus'] ?? null; | |
| 246 | + | ||
| 247 | + | if (!is_array($declared) || !$declared) { | |
| 248 | + | return array('main' => t_default('acp.menu.default_location', 'Main navigation')); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | $out = array(); | |
| 252 | + | foreach ($declared as $slug => $label) { | |
| 253 | + | // Accept both {"main":"Top"} and ["main","sidebar"]. | |
| 254 | + | if (is_int($slug)) { | |
| 255 | + | $slug = (string)$label; | |
| 256 | + | $label = ucfirst(str_replace(array('-', '_'), ' ', $slug)); | |
| 257 | + | } | |
| 258 | + | $slug = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)$slug)); | |
| 259 | + | if ($slug !== '') { | |
| 260 | + | $out[$slug] = (string)$label; | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | return $out ?: array('main' => 'Main navigation'); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /** | |
| 268 | + | * True when the menu table exists and holds at least one entry. | |
| 269 | + | * A theme can use it to decide between the managed menu and its own fallback. | |
| 270 | + | */ | |
| 271 | + | function theme_menu_available(): bool { | |
| 272 | + | $row = db()->fetchOne("SELECT `id` FROM `znote_menu` LIMIT 1;"); | |
| 273 | + | return is_array($row) && $row; | |
| 274 | + | } |
| @@ -0,0 +1,306 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * ZnoteX database migrations. | |
| 4 | + | * | |
| 5 | + | * Runs SQL files from SQL/migrations and records successful executions in | |
| 6 | + | * znote_migrations, so updates no longer require manual phpMyAdmin imports. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | function znote_migrations_dir(): string { | |
| 10 | + | return dirname(__DIR__, 2) . '/SQL/migrations'; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | function znote_migrations_table_ensure(): bool { | |
| 14 | + | return db()->rawExecute(" | |
| 15 | + | CREATE TABLE IF NOT EXISTS `znote_migrations` ( | |
| 16 | + | `id` int NOT NULL AUTO_INCREMENT, | |
| 17 | + | `migration` varchar(191) NOT NULL, | |
| 18 | + | `checksum` char(64) NOT NULL, | |
| 19 | + | `executed_at` int NOT NULL, | |
| 20 | + | `execution_time_ms` int NOT NULL DEFAULT '0', | |
| 21 | + | PRIMARY KEY (`id`), | |
| 22 | + | UNIQUE KEY `migration` (`migration`) | |
| 23 | + | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; | |
| 24 | + | "); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | function znote_migrations_applied(): array { | |
| 28 | + | if (!znote_migrations_table_ensure()) { | |
| 29 | + | return array(); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | $rows = db()->fetchAll("SELECT `migration`, `checksum`, `executed_at`, `execution_time_ms` FROM `znote_migrations` ORDER BY `migration` ASC;"); | |
| 33 | + | $out = array(); | |
| 34 | + | ||
| 35 | + | if (is_array($rows)) { | |
| 36 | + | foreach ($rows as $row) { | |
| 37 | + | $out[(string)$row['migration']] = $row; | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | return $out; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function znote_migrations_files(): array { | |
| 45 | + | $dir = znote_migrations_dir(); | |
| 46 | + | $files = is_dir($dir) ? glob($dir . '/*.sql') : array(); | |
| 47 | + | $files = is_array($files) ? $files : array(); | |
| 48 | + | sort($files, SORT_NATURAL | SORT_FLAG_CASE); | |
| 49 | + | ||
| 50 | + | $out = array(); | |
| 51 | + | foreach ($files as $file) { | |
| 52 | + | $name = basename($file); | |
| 53 | + | $out[$name] = array( | |
| 54 | + | 'name' => $name, | |
| 55 | + | 'path' => $file, | |
| 56 | + | 'checksum' => hash_file('sha256', $file) ?: '', | |
| 57 | + | 'size' => filesize($file) ?: 0, | |
| 58 | + | ); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | return $out; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | function znote_migrations_status(): array { | |
| 65 | + | $applied = znote_migrations_applied(); | |
| 66 | + | $files = znote_migrations_files(); | |
| 67 | + | $status = array(); | |
| 68 | + | ||
| 69 | + | foreach ($files as $name => $file) { | |
| 70 | + | $row = $applied[$name] ?? null; | |
| 71 | + | $state = 'pending'; | |
| 72 | + | if (is_array($row)) { | |
| 73 | + | $state = hash_equals((string)$row['checksum'], (string)$file['checksum']) ? 'applied' : 'changed'; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | $status[$name] = $file + array( | |
| 77 | + | 'state' => $state, | |
| 78 | + | 'applied' => $row, | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | foreach ($applied as $name => $row) { | |
| 83 | + | if (!isset($status[$name])) { | |
| 84 | + | $status[$name] = array( | |
| 85 | + | 'name' => $name, | |
| 86 | + | 'path' => '', | |
| 87 | + | 'checksum' => (string)$row['checksum'], | |
| 88 | + | 'size' => 0, | |
| 89 | + | 'state' => 'missing', | |
| 90 | + | 'applied' => $row, | |
| 91 | + | ); | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | ksort($status, SORT_NATURAL | SORT_FLAG_CASE); | |
| 96 | + | return $status; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | function znote_migrations_pending(): array { | |
| 100 | + | return array_filter(znote_migrations_status(), static function (array $migration): bool { | |
| 101 | + | return $migration['state'] === 'pending'; | |
| 102 | + | }); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | final class ZnoteMigrationSqlSplitter | |
| 106 | + | { | |
| 107 | + | private string $sql; | |
| 108 | + | private int $len; | |
| 109 | + | private int $i = 0; | |
| 110 | + | private string $current = ''; | |
| 111 | + | private ?string $quote = null; | |
| 112 | + | private bool $lineComment = false; | |
| 113 | + | private bool $blockComment = false; | |
| 114 | + | private array $statements = array(); | |
| 115 | + | ||
| 116 | + | public function __construct(string $sql) | |
| 117 | + | { | |
| 118 | + | $this->sql = $sql; | |
| 119 | + | $this->len = strlen($sql); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | public function split(): array | |
| 123 | + | { | |
| 124 | + | for ($this->i = 0; $this->i < $this->len; $this->i++) { | |
| 125 | + | $this->step(); | |
| 126 | + | } | |
| 127 | + | $this->flush(); | |
| 128 | + | ||
| 129 | + | return $this->statements; | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | private function step(): void | |
| 133 | + | { | |
| 134 | + | if ($this->lineComment) { | |
| 135 | + | $this->stepLineComment(); | |
| 136 | + | return; | |
| 137 | + | } | |
| 138 | + | if ($this->blockComment) { | |
| 139 | + | $this->stepBlockComment(); | |
| 140 | + | return; | |
| 141 | + | } | |
| 142 | + | if ($this->quote !== null) { | |
| 143 | + | $this->stepQuote(); | |
| 144 | + | return; | |
| 145 | + | } | |
| 146 | + | $this->stepDefault(); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | private function char(): string | |
| 150 | + | { | |
| 151 | + | return $this->sql[$this->i]; | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | private function next(): string | |
| 155 | + | { | |
| 156 | + | return ($this->i + 1 < $this->len) ? $this->sql[$this->i + 1] : ''; | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | private function stepLineComment(): void | |
| 160 | + | { | |
| 161 | + | $char = $this->char(); | |
| 162 | + | $this->current .= $char; | |
| 163 | + | if ($char === "\n") { | |
| 164 | + | $this->lineComment = false; | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | private function stepBlockComment(): void | |
| 169 | + | { | |
| 170 | + | $char = $this->char(); | |
| 171 | + | $next = $this->next(); | |
| 172 | + | $this->current .= $char; | |
| 173 | + | if ($char === '*' && $next === '/') { | |
| 174 | + | $this->current .= $next; | |
| 175 | + | $this->i++; | |
| 176 | + | $this->blockComment = false; | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | private function stepQuote(): void | |
| 181 | + | { | |
| 182 | + | $char = $this->char(); | |
| 183 | + | $next = $this->next(); | |
| 184 | + | $this->current .= $char; | |
| 185 | + | if ($char === '\\' && $next !== '') { | |
| 186 | + | $this->current .= $next; | |
| 187 | + | $this->i++; | |
| 188 | + | return; | |
| 189 | + | } | |
| 190 | + | if ($char === $this->quote) { | |
| 191 | + | $this->quote = null; | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | private function stepDefault(): void | |
| 196 | + | { | |
| 197 | + | $char = $this->char(); | |
| 198 | + | $next = $this->next(); | |
| 199 | + | ||
| 200 | + | if ($this->startsLineComment()) { | |
| 201 | + | $this->lineComment = true; | |
| 202 | + | $this->current .= $char; | |
| 203 | + | return; | |
| 204 | + | } | |
| 205 | + | if ($char === '/' && $next === '*') { | |
| 206 | + | $this->blockComment = true; | |
| 207 | + | $this->current .= $char . $next; | |
| 208 | + | $this->i++; | |
| 209 | + | return; | |
| 210 | + | } | |
| 211 | + | if ($char === '\'' || $char === '"' || $char === '`') { | |
| 212 | + | $this->quote = $char; | |
| 213 | + | $this->current .= $char; | |
| 214 | + | return; | |
| 215 | + | } | |
| 216 | + | if ($char === ';') { | |
| 217 | + | $this->flush(); | |
| 218 | + | return; | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | $this->current .= $char; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | private function startsLineComment(): bool | |
| 225 | + | { | |
| 226 | + | $char = $this->char(); | |
| 227 | + | $next = $this->next(); | |
| 228 | + | return ($char === '-' && $next === '-' && ($this->i + 2 >= $this->len || preg_match('/\s/', $this->sql[$this->i + 2]))) | |
| 229 | + | || $char === '#'; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | private function flush(): void | |
| 233 | + | { | |
| 234 | + | $trimmed = trim($this->current); | |
| 235 | + | if ($trimmed !== '') { | |
| 236 | + | $this->statements[] = $trimmed; | |
| 237 | + | } | |
| 238 | + | $this->current = ''; | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | function znote_migration_split_sql(string $sql): array { | |
| 243 | + | return (new ZnoteMigrationSqlSplitter($sql))->split(); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | function znote_migration_run(string $migration): array { | |
| 247 | + | $files = znote_migrations_files(); | |
| 248 | + | if (!isset($files[$migration])) { | |
| 249 | + | return array('ok' => false, 'message' => 'Migration file not found.', 'statements' => 0, 'time_ms' => 0); | |
| 250 | + | } | |
| 251 | + | if (!znote_migrations_table_ensure()) { | |
| 252 | + | return array('ok' => false, 'message' => 'Could not create znote_migrations table.', 'statements' => 0, 'time_ms' => 0); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | $applied = znote_migrations_applied(); | |
| 256 | + | if (isset($applied[$migration]) && hash_equals((string)$applied[$migration]['checksum'], (string)$files[$migration]['checksum'])) { | |
| 257 | + | return array('ok' => true, 'message' => 'Already applied.', 'statements' => 0, 'time_ms' => 0); | |
| 258 | + | } | |
| 259 | + | if (isset($applied[$migration])) { | |
| 260 | + | return array('ok' => false, 'message' => 'Migration was already applied but the file checksum changed.', 'statements' => 0, 'time_ms' => 0); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | $sql = (string)file_get_contents($files[$migration]['path']); | |
| 264 | + | $statements = znote_migration_split_sql($sql); | |
| 265 | + | $started = microtime(true); | |
| 266 | + | $done = 0; | |
| 267 | + | ||
| 268 | + | foreach ($statements as $index => $statement) { | |
| 269 | + | if (!db()->rawExecute($statement)) { | |
| 270 | + | return array( | |
| 271 | + | 'ok' => false, | |
| 272 | + | 'message' => 'Statement ' . ($index + 1) . ' failed. Check Admin Panel > Error Log for the database reference.', | |
| 273 | + | 'statements' => $done, | |
| 274 | + | 'time_ms' => (int)round((microtime(true) - $started) * 1000), | |
| 275 | + | ); | |
| 276 | + | } | |
| 277 | + | $done++; | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | $timeMs = (int)round((microtime(true) - $started) * 1000); | |
| 281 | + | $recorded = db()->execute(" | |
| 282 | + | INSERT INTO `znote_migrations` (`migration`, `checksum`, `executed_at`, `execution_time_ms`) | |
| 283 | + | VALUES (?, ?, ?, ?); | |
| 284 | + | ", [$migration, $files[$migration]['checksum'], time(), $timeMs]); | |
| 285 | + | ||
| 286 | + | if (!$recorded) { | |
| 287 | + | return array('ok' => false, 'message' => 'Migration ran but could not be recorded.', 'statements' => $done, 'time_ms' => $timeMs); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | return array('ok' => true, 'message' => 'Applied successfully.', 'statements' => $done, 'time_ms' => $timeMs); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | function znote_migrations_run_pending(): array { | |
| 294 | + | $results = array(); | |
| 295 | + | ||
| 296 | + | foreach (array_keys(znote_migrations_pending()) as $migration) { | |
| 297 | + | $result = znote_migration_run($migration); | |
| 298 | + | $results[$migration] = $result; | |
| 299 | + | if (empty($result['ok'])) { | |
| 300 | + | break; | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | return $results; | |
| 305 | + | } | |
| 306 | + | ?> |
| @@ -0,0 +1,634 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | const ZNOTE_MINIMAP_DIR = 'engine/minimap'; | |
| 4 | + | const ZNOTE_MINIMAP_META = 'engine/minimap/minimap.json'; | |
| 5 | + | const ZNOTE_MINIMAP_SIGNATURE = 0x4D4D544F; | |
| 6 | + | const ZNOTE_MINIMAP_VERSION = 1; | |
| 7 | + | const ZNOTE_MINIMAP_BLOCK = 64; | |
| 8 | + | const ZNOTE_MINIMAP_BLOCK_LEN = 12288; | |
| 9 | + | const ZNOTE_MINIMAP_MAX_BLOCK = 20000; | |
| 10 | + | const ZNOTE_MINIMAP_MAX_BYTES = 16777216; | |
| 11 | + | ||
| 12 | + | class ZnoteOtmmReader | |
| 13 | + | { | |
| 14 | + | protected string $data; | |
| 15 | + | protected int $offset = 0; | |
| 16 | + | protected int $length; | |
| 17 | + | ||
| 18 | + | public function __construct(string $data) | |
| 19 | + | { | |
| 20 | + | $this->data = $data; | |
| 21 | + | $this->length = strlen($data); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | public function seek(int $offset): void | |
| 25 | + | { | |
| 26 | + | if ($offset < 0 || $offset > $this->length) { | |
| 27 | + | throw new RuntimeException('Invalid offset inside the .otmm file.'); | |
| 28 | + | } | |
| 29 | + | $this->offset = $offset; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | public function eof(): bool | |
| 33 | + | { | |
| 34 | + | return $this->offset >= $this->length; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | public function remaining(): int | |
| 38 | + | { | |
| 39 | + | return $this->length - $this->offset; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | public function u8(): int | |
| 43 | + | { | |
| 44 | + | $this->need(1); | |
| 45 | + | return ord($this->data[$this->offset++]); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | public function u16(): int | |
| 49 | + | { | |
| 50 | + | $this->need(2); | |
| 51 | + | $value = unpack('v', substr($this->data, $this->offset, 2))[1]; | |
| 52 | + | $this->offset += 2; | |
| 53 | + | return $value; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | public function u32(): int | |
| 57 | + | { | |
| 58 | + | $this->need(4); | |
| 59 | + | $value = unpack('V', substr($this->data, $this->offset, 4))[1]; | |
| 60 | + | $this->offset += 4; | |
| 61 | + | return $value; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | public function string(): string | |
| 65 | + | { | |
| 66 | + | return $this->bytes($this->u16()); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | public function bytes(int $length): string | |
| 70 | + | { | |
| 71 | + | $this->need($length); | |
| 72 | + | $value = substr($this->data, $this->offset, $length); | |
| 73 | + | $this->offset += $length; | |
| 74 | + | return $value; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | protected function need(int $bytes): void | |
| 78 | + | { | |
| 79 | + | if ($bytes < 0 || $this->remaining() < $bytes) { | |
| 80 | + | throw new RuntimeException('Unexpected end of the .otmm file.'); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | function minimap_root(): string | |
| 86 | + | { | |
| 87 | + | return dirname(__DIR__, 2) . '/' . ZNOTE_MINIMAP_DIR; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | function minimap_meta_path(): string | |
| 91 | + | { | |
| 92 | + | return dirname(__DIR__, 2) . '/' . ZNOTE_MINIMAP_META; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | function minimap_data() | |
| 96 | + | { | |
| 97 | + | static $data = null; | |
| 98 | + | if ($data !== null) { | |
| 99 | + | return $data; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | $data = false; | |
| 103 | + | $file = minimap_meta_path(); | |
| 104 | + | if (!is_file($file)) { | |
| 105 | + | return $data; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | $decoded = json_decode((string)file_get_contents($file), true); | |
| 109 | + | if (!is_array($decoded) || empty($decoded['floors'])) { | |
| 110 | + | return $data; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | ksort($decoded['floors'], SORT_NUMERIC); | |
| 114 | + | $data = $decoded; | |
| 115 | + | ||
| 116 | + | return $data; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | function minimap_available(): bool | |
| 120 | + | { | |
| 121 | + | return minimap_data() !== false; | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | function minimap_color(int $color): array | |
| 125 | + | { | |
| 126 | + | if ($color >= 0 && $color < 216) { | |
| 127 | + | return array( | |
| 128 | + | (int)(floor($color / 36) * 51), | |
| 129 | + | (int)(floor(($color % 36) / 6) * 51), | |
| 130 | + | (int)(($color % 6) * 51) | |
| 131 | + | ); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | return array(0, 0, 0); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | function minimap_tile_image(string $raw, bool &$hasVisible) | |
| 138 | + | { | |
| 139 | + | $image = imagecreatetruecolor(ZNOTE_MINIMAP_BLOCK, ZNOTE_MINIMAP_BLOCK); | |
| 140 | + | imagealphablending($image, false); | |
| 141 | + | imagesavealpha($image, true); | |
| 142 | + | imagefill($image, 0, 0, imagecolorallocatealpha($image, 0, 0, 0, 127)); | |
| 143 | + | ||
| 144 | + | $cache = array(); | |
| 145 | + | $hasVisible = false; | |
| 146 | + | ||
| 147 | + | for ($index = 0; $index < ZNOTE_MINIMAP_BLOCK * ZNOTE_MINIMAP_BLOCK; $index++) { | |
| 148 | + | $offset = $index * 3; | |
| 149 | + | $flags = ord($raw[$offset]); | |
| 150 | + | $color = ord($raw[$offset + 1]); | |
| 151 | + | ||
| 152 | + | if (!($flags & 1) || ($flags & 8) || $color === 255) { | |
| 153 | + | continue; | |
| 154 | + | } | |
| 155 | + | if (!isset($cache[$color])) { | |
| 156 | + | list($r, $g, $b) = minimap_color($color); | |
| 157 | + | $cache[$color] = imagecolorallocatealpha($image, $r, $g, $b, 0); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | imagesetpixel($image, $index % ZNOTE_MINIMAP_BLOCK, (int)floor($index / ZNOTE_MINIMAP_BLOCK), $cache[$color]); | |
| 161 | + | $hasVisible = true; | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | return $image; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | function minimap_delete(): void | |
| 168 | + | { | |
| 169 | + | foreach (glob(minimap_root() . '/*.png') ?: array() as $tile) { | |
| 170 | + | @unlink($tile); | |
| 171 | + | } | |
| 172 | + | @unlink(minimap_meta_path()); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | function minimap_import(string $tmpFile, string $originalName, ?string &$error = null): bool | |
| 176 | + | { | |
| 177 | + | $error = null; | |
| 178 | + | ||
| 179 | + | if (strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION)) !== 'otmm') { | |
| 180 | + | $error = 'Only .otmm minimap files are accepted.'; | |
| 181 | + | return false; | |
| 182 | + | } | |
| 183 | + | if (!is_file($tmpFile) || filesize($tmpFile) < 16) { | |
| 184 | + | $error = 'The uploaded file is empty or unreadable.'; | |
| 185 | + | return false; | |
| 186 | + | } | |
| 187 | + | if (filesize($tmpFile) > ZNOTE_MINIMAP_MAX_BYTES) { | |
| 188 | + | $error = 'The .otmm file must be ' . (int)(ZNOTE_MINIMAP_MAX_BYTES / 1048576) . ' MB or smaller.'; | |
| 189 | + | return false; | |
| 190 | + | } | |
| 191 | + | if (!function_exists('gzuncompress') || !function_exists('imagecreatetruecolor')) { | |
| 192 | + | $error = 'PHP needs the zlib and GD extensions to convert an .otmm minimap.'; | |
| 193 | + | return false; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | $root = minimap_root(); | |
| 197 | + | if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) { | |
| 198 | + | $error = 'Could not create ' . ZNOTE_MINIMAP_DIR . '/. Give the web server write access.'; | |
| 199 | + | return false; | |
| 200 | + | } | |
| 201 | + | if (!is_writable($root)) { | |
| 202 | + | $error = ZNOTE_MINIMAP_DIR . '/ is not writable by the web server.'; | |
| 203 | + | return false; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | $data = file_get_contents($tmpFile); | |
| 207 | + | if ($data === false) { | |
| 208 | + | $error = 'Could not read the uploaded file.'; | |
| 209 | + | return false; | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | @set_time_limit(600); | |
| 213 | + | ||
| 214 | + | $floors = array(); | |
| 215 | + | $tiles = 0; | |
| 216 | + | ||
| 217 | + | try { | |
| 218 | + | $reader = new ZnoteOtmmReader($data); | |
| 219 | + | ||
| 220 | + | if ($reader->u32() !== ZNOTE_MINIMAP_SIGNATURE) { | |
| 221 | + | $error = 'This is not a valid OTMM minimap file.'; | |
| 222 | + | return false; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | $start = $reader->u16(); | |
| 226 | + | $version = $reader->u16(); | |
| 227 | + | $reader->u32(); | |
| 228 | + | ||
| 229 | + | if ($version !== ZNOTE_MINIMAP_VERSION) { | |
| 230 | + | $error = 'Unsupported OTMM minimap version.'; | |
| 231 | + | return false; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | $reader->string(); | |
| 235 | + | $reader->seek($start); | |
| 236 | + | ||
| 237 | + | minimap_delete(); | |
| 238 | + | ||
| 239 | + | $blocks = 0; | |
| 240 | + | ||
| 241 | + | while (!$reader->eof()) { | |
| 242 | + | if (++$blocks > ZNOTE_MINIMAP_MAX_BLOCK) { | |
| 243 | + | minimap_delete(); | |
| 244 | + | $error = 'The .otmm minimap contains too many blocks.'; | |
| 245 | + | return false; | |
| 246 | + | } | |
| 247 | + | if ($reader->remaining() < 7) { | |
| 248 | + | break; | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | $x = $reader->u16(); | |
| 252 | + | $y = $reader->u16(); | |
| 253 | + | $z = $reader->u8(); | |
| 254 | + | ||
| 255 | + | if ($x >= 65535 || $y >= 65535 || $z > 15) { | |
| 256 | + | break; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | $length = $reader->u16(); | |
| 260 | + | if ($length <= 0 || $length > $reader->remaining()) { | |
| 261 | + | minimap_delete(); | |
| 262 | + | $error = 'The .otmm minimap contains a corrupt block.'; | |
| 263 | + | return false; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | $raw = @gzuncompress($reader->bytes($length), ZNOTE_MINIMAP_BLOCK_LEN); | |
| 267 | + | if (!is_string($raw) || strlen($raw) !== ZNOTE_MINIMAP_BLOCK_LEN) { | |
| 268 | + | continue; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | $hasVisible = false; | |
| 272 | + | $image = minimap_tile_image($raw, $hasVisible); | |
| 273 | + | ||
| 274 | + | if (!$hasVisible) { | |
| 275 | + | imagedestroy($image); | |
| 276 | + | continue; | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | imagepng($image, $root . '/z' . $z . '-' . $x . '-' . $y . '.png', 6); | |
| 280 | + | imagedestroy($image); | |
| 281 | + | $tiles++; | |
| 282 | + | ||
| 283 | + | if (!isset($floors[$z])) { | |
| 284 | + | $floors[$z] = array( | |
| 285 | + | 'min_x' => $x, | |
| 286 | + | 'min_y' => $y, | |
| 287 | + | 'max_x' => $x + 63, | |
| 288 | + | 'max_y' => $y + 63, | |
| 289 | + | 'tiles' => array() | |
| 290 | + | ); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | $floors[$z]['min_x'] = min($floors[$z]['min_x'], $x); | |
| 294 | + | $floors[$z]['min_y'] = min($floors[$z]['min_y'], $y); | |
| 295 | + | $floors[$z]['max_x'] = max($floors[$z]['max_x'], $x + 63); | |
| 296 | + | $floors[$z]['max_y'] = max($floors[$z]['max_y'], $y + 63); | |
| 297 | + | $floors[$z]['tiles'][] = array($x, $y); | |
| 298 | + | } | |
| 299 | + | } catch (Throwable $e) { | |
| 300 | + | minimap_delete(); | |
| 301 | + | $error = 'Could not read the .otmm minimap: ' . $e->getMessage(); | |
| 302 | + | return false; | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | if (!$floors) { | |
| 306 | + | minimap_delete(); | |
| 307 | + | $error = 'No visible minimap tiles were found in this .otmm file.'; | |
| 308 | + | return false; | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | ksort($floors, SORT_NUMERIC); | |
| 312 | + | ||
| 313 | + | $prepared = array(); | |
| 314 | + | foreach ($floors as $z => $floor) { | |
| 315 | + | $prepared[(int)$z] = array( | |
| 316 | + | 'z' => (int)$z, | |
| 317 | + | 'x' => (int)$floor['min_x'], | |
| 318 | + | 'y' => (int)$floor['min_y'], | |
| 319 | + | 'w' => max(64, (int)($floor['max_x'] - $floor['min_x'] + 1)), | |
| 320 | + | 'h' => max(64, (int)($floor['max_y'] - $floor['min_y'] + 1)), | |
| 321 | + | 't' => $floor['tiles'] | |
| 322 | + | ); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | $meta = array( | |
| 326 | + | 'date' => time(), | |
| 327 | + | 'source' => substr((string)preg_replace('/[^A-Za-z0-9._-]/', '', basename($originalName)), 0, 80), | |
| 328 | + | 'tiles' => $tiles, | |
| 329 | + | 'floors' => $prepared | |
| 330 | + | ); | |
| 331 | + | ||
| 332 | + | if (file_put_contents(minimap_meta_path(), json_encode($meta)) === false) { | |
| 333 | + | minimap_delete(); | |
| 334 | + | $error = 'Could not write ' . ZNOTE_MINIMAP_META . '.'; | |
| 335 | + | return false; | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | return true; | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | ||
| 342 | + | function minimap_was_rendered(): bool | |
| 343 | + | { | |
| 344 | + | return !empty($GLOBALS['__znote_minimap_rendered']); | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | function minimap_render(string $baseUrl = '', bool $withTitle = true): void | |
| 348 | + | { | |
| 349 | + | if (minimap_was_rendered()) { | |
| 350 | + | return; | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | $data = minimap_data(); | |
| 354 | + | if ($data === false) { | |
| 355 | + | return; | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | $GLOBALS['__znote_minimap_rendered'] = true; | |
| 359 | + | ||
| 360 | + | $floors = array(); | |
| 361 | + | foreach ($data['floors'] as $floor) { | |
| 362 | + | $floors[] = array( | |
| 363 | + | 'z' => (int)$floor['z'], | |
| 364 | + | 'x' => (int)$floor['x'], | |
| 365 | + | 'y' => (int)$floor['y'], | |
| 366 | + | 't' => $floor['t'] | |
| 367 | + | ); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | $payload = array( | |
| 371 | + | 'base' => (($baseUrl !== '') ? rtrim($baseUrl, '/') . '/' : '') . ZNOTE_MINIMAP_DIR . '/', | |
| 372 | + | 'v' => (int)($data['date'] ?? 0), | |
| 373 | + | 'start' => 7, | |
| 374 | + | 'floors' => $floors | |
| 375 | + | ); | |
| 376 | + | ?> | |
| 377 | + | <div class="znote-minimap" id="znoteMinimap"> | |
| 378 | + | <?php if ($withTitle): ?><h2 class="znote-minimap-title">World map</h2><?php endif; ?> | |
| 379 | + | <div class="znote-minimap-stage"> | |
| 380 | + | <div class="znote-minimap-canvas"></div> | |
| 381 | + | <div class="znote-minimap-arrows"> | |
| 382 | + | <button type="button" class="znote-minimap-btn" data-minimap="floor-up" title="Floor up">▲</button> | |
| 383 | + | <span class="znote-minimap-level">Floor <b>7</b></span> | |
| 384 | + | <button type="button" class="znote-minimap-btn" data-minimap="floor-down" title="Floor down">▼</button> | |
| 385 | + | </div> | |
| 386 | + | <div class="znote-minimap-zoom"> | |
| 387 | + | <button type="button" class="znote-minimap-btn" data-minimap="zoom-in" title="Zoom in">+</button> | |
| 388 | + | <button type="button" class="znote-minimap-btn" data-minimap="zoom-out" title="Zoom out">−</button> | |
| 389 | + | </div> | |
| 390 | + | </div> | |
| 391 | + | <p class="znote-minimap-hint">Drag to move, scroll or use +/− to zoom, and change floor with the arrows.</p> | |
| 392 | + | </div> | |
| 393 | + | <style> | |
| 394 | + | .znote-minimap { margin: 0 0 20px; } | |
| 395 | + | .znote-minimap-title { margin: 0 0 8px; } | |
| 396 | + | .znote-minimap-stage { | |
| 397 | + | position: relative; | |
| 398 | + | height: 460px; | |
| 399 | + | overflow: hidden; | |
| 400 | + | cursor: grab; | |
| 401 | + | touch-action: none; | |
| 402 | + | background: var(--bg-default, rgb(15,17,20)); | |
| 403 | + | border: 1px solid var(--border, rgb(19,20,23)); | |
| 404 | + | } | |
| 405 | + | .znote-minimap-stage.is-dragging { cursor: grabbing; } | |
| 406 | + | .znote-minimap-canvas { position: absolute; left: 0; top: 0; right: 0; bottom: 0; } | |
| 407 | + | /* max-width/height come back explicitly: a CSS reset like Tailwind's | |
| 408 | + | preflight sets img{max-width:100%;height:auto}, which would collapse | |
| 409 | + | every tile the moment this sits in a themed page. */ | |
| 410 | + | .znote-minimap-canvas img { | |
| 411 | + | position: absolute; | |
| 412 | + | max-width: none; | |
| 413 | + | max-height: none; | |
| 414 | + | min-width: 0; | |
| 415 | + | min-height: 0; | |
| 416 | + | image-rendering: pixelated; | |
| 417 | + | user-select: none; | |
| 418 | + | pointer-events: none; | |
| 419 | + | } | |
| 420 | + | .znote-minimap-arrows, | |
| 421 | + | .znote-minimap-zoom { | |
| 422 | + | position: absolute; | |
| 423 | + | display: flex; | |
| 424 | + | flex-direction: column; | |
| 425 | + | gap: 4px; | |
| 426 | + | z-index: 2; | |
| 427 | + | } | |
| 428 | + | .znote-minimap-arrows { top: 10px; right: 10px; align-items: center; } | |
| 429 | + | .znote-minimap-zoom { bottom: 10px; right: 10px; } | |
| 430 | + | .znote-minimap-btn { | |
| 431 | + | width: 32px; | |
| 432 | + | height: 32px; | |
| 433 | + | padding: 0; | |
| 434 | + | line-height: 1; | |
| 435 | + | font-size: 14px; | |
| 436 | + | cursor: pointer; | |
| 437 | + | color: var(--font-color, rgb(155,162,177)); | |
| 438 | + | background: var(--primary, rgb(30,33,40)); | |
| 439 | + | border: 1px solid var(--border, rgb(19,20,23)); | |
| 440 | + | } | |
| 441 | + | .znote-minimap-btn:hover { color: var(--anchor-hover, #e79424); } | |
| 442 | + | .znote-minimap-level { | |
| 443 | + | padding: 2px 6px; | |
| 444 | + | font-size: 11px; | |
| 445 | + | white-space: nowrap; | |
| 446 | + | color: var(--font-color, rgb(155,162,177)); | |
| 447 | + | background: var(--secondary, rgb(25,28,33)); | |
| 448 | + | border: 1px solid var(--border, rgb(19,20,23)); | |
| 449 | + | } | |
| 450 | + | .znote-minimap-hint { margin: 6px 0 0; font-size: 12px; opacity: .7; } | |
| 451 | + | </style> | |
| 452 | + | <script> | |
| 453 | + | (function () { | |
| 454 | + | var data = <?php echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>; | |
| 455 | + | ||
| 456 | + | function init() { | |
| 457 | + | var viewer = document.getElementById('znoteMinimap'); | |
| 458 | + | if (!viewer || viewer.dataset.ready) return; | |
| 459 | + | viewer.dataset.ready = '1'; | |
| 460 | + | ||
| 461 | + | if (!data || !data.floors || !data.floors.length) return; | |
| 462 | + | ||
| 463 | + | var stage = viewer.querySelector('.znote-minimap-stage'); | |
| 464 | + | var canvas = viewer.querySelector('.znote-minimap-canvas'); | |
| 465 | + | var label = viewer.querySelector('.znote-minimap-level b'); | |
| 466 | + | if (!stage || !canvas) return; | |
| 467 | + | ||
| 468 | + | var TILE = 64; | |
| 469 | + | ||
| 470 | + | var index = 0; | |
| 471 | + | for (var i = 0; i < data.floors.length; i++) { | |
| 472 | + | if (data.floors[i].z === data.start) { index = i; break; } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | var scale = 1, panX = 0, panY = 0; | |
| 476 | + | var dragging = false, startX = 0, startY = 0; | |
| 477 | + | var mounted = {}; | |
| 478 | + | ||
| 479 | + | function floorData() { return data.floors[index]; } | |
| 480 | + | ||
| 481 | + | function focusPoint() { | |
| 482 | + | var floor = floorData(); | |
| 483 | + | if (!floor.t.length) return { x: 0, y: 0 }; | |
| 484 | + | ||
| 485 | + | var xs = [], ys = []; | |
| 486 | + | for (var i = 0; i < floor.t.length; i++) { | |
| 487 | + | xs.push(floor.t[i][0]); | |
| 488 | + | ys.push(floor.t[i][1]); | |
| 489 | + | } | |
| 490 | + | xs.sort(function (a, b) { return a - b; }); | |
| 491 | + | ys.sort(function (a, b) { return a - b; }); | |
| 492 | + | ||
| 493 | + | return { | |
| 494 | + | x: xs[Math.floor(xs.length / 2)] - floor.x + (TILE / 2), | |
| 495 | + | y: ys[Math.floor(ys.length / 2)] - floor.y + (TILE / 2) | |
| 496 | + | }; | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | function center() { | |
| 500 | + | var point = focusPoint(); | |
| 501 | + | panX = Math.round((stage.clientWidth / 2) - point.x * scale); | |
| 502 | + | panY = Math.round((stage.clientHeight / 2) - point.y * scale); | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | // Tiles are placed straight into stage space, one node per visible tile. | |
| 506 | + | // Panning a single huge element instead would ask the browser for a | |
| 507 | + | // layer far past its maximum texture size - a 32000px floor then paints | |
| 508 | + | // as nothing at all. | |
| 509 | + | function render() { | |
| 510 | + | var floor = floorData(); | |
| 511 | + | var width = stage.clientWidth; | |
| 512 | + | var height = stage.clientHeight; | |
| 513 | + | var margin = TILE * 2; | |
| 514 | + | var seen = {}; | |
| 515 | + | ||
| 516 | + | for (var i = 0; i < floor.t.length; i++) { | |
| 517 | + | var fx = floor.t[i][0] - floor.x; | |
| 518 | + | var fy = floor.t[i][1] - floor.y; | |
| 519 | + | ||
| 520 | + | var left = Math.round(fx * scale + panX); | |
| 521 | + | var top = Math.round(fy * scale + panY); | |
| 522 | + | var w = Math.round((fx + TILE) * scale + panX) - left; | |
| 523 | + | var h = Math.round((fy + TILE) * scale + panY) - top; | |
| 524 | + | ||
| 525 | + | if (left > width + margin || top > height + margin || left + w < -margin || top + h < -margin) { | |
| 526 | + | continue; | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | seen[i] = true; | |
| 530 | + | var img = mounted[i]; | |
| 531 | + | ||
| 532 | + | if (!img) { | |
| 533 | + | img = document.createElement('img'); | |
| 534 | + | img.alt = ''; | |
| 535 | + | img.draggable = false; | |
| 536 | + | img.src = data.base + 'z' + floor.z + '-' + floor.t[i][0] + '-' + floor.t[i][1] + '.png?' + data.v; | |
| 537 | + | canvas.appendChild(img); | |
| 538 | + | mounted[i] = img; | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | img.style.left = left + 'px'; | |
| 542 | + | img.style.top = top + 'px'; | |
| 543 | + | img.style.width = w + 'px'; | |
| 544 | + | img.style.height = h + 'px'; | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | for (var key in mounted) { | |
| 548 | + | if (!seen[key]) { | |
| 549 | + | canvas.removeChild(mounted[key]); | |
| 550 | + | delete mounted[key]; | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | function clear() { | |
| 556 | + | canvas.textContent = ''; | |
| 557 | + | mounted = {}; | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | function showFloor(next) { | |
| 561 | + | index = (next + data.floors.length) % data.floors.length; | |
| 562 | + | if (label) label.textContent = floorData().z; | |
| 563 | + | clear(); | |
| 564 | + | center(); | |
| 565 | + | render(); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | function zoom(delta) { | |
| 569 | + | var next = Math.max(0.25, Math.min(4, scale + delta)); | |
| 570 | + | if (next === scale) return; | |
| 571 | + | ||
| 572 | + | var cx = stage.clientWidth / 2, cy = stage.clientHeight / 2; | |
| 573 | + | panX = Math.round(cx - (cx - panX) * (next / scale)); | |
| 574 | + | panY = Math.round(cy - (cy - panY) * (next / scale)); | |
| 575 | + | scale = next; | |
| 576 | + | clear(); | |
| 577 | + | render(); | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // Never capture the pointer for a press that started on a control: | |
| 581 | + | // capturing retargets the following pointerup, so the browser fires | |
| 582 | + | // the click on the stage instead of the button and it does nothing. | |
| 583 | + | stage.addEventListener('pointerdown', function (e) { | |
| 584 | + | if (e.target && e.target.closest && e.target.closest('[data-minimap]')) return; | |
| 585 | + | ||
| 586 | + | dragging = true; | |
| 587 | + | startX = e.clientX - panX; | |
| 588 | + | startY = e.clientY - panY; | |
| 589 | + | stage.setPointerCapture(e.pointerId); | |
| 590 | + | stage.classList.add('is-dragging'); | |
| 591 | + | }); | |
| 592 | + | stage.addEventListener('pointermove', function (e) { | |
| 593 | + | if (!dragging) return; | |
| 594 | + | panX = e.clientX - startX; | |
| 595 | + | panY = e.clientY - startY; | |
| 596 | + | render(); | |
| 597 | + | }); | |
| 598 | + | stage.addEventListener('pointerup', function (e) { | |
| 599 | + | dragging = false; | |
| 600 | + | stage.classList.remove('is-dragging'); | |
| 601 | + | try { stage.releasePointerCapture(e.pointerId); } catch (err) {} | |
| 602 | + | }); | |
| 603 | + | stage.addEventListener('wheel', function (e) { | |
| 604 | + | e.preventDefault(); | |
| 605 | + | zoom(e.deltaY < 0 ? 0.25 : -0.25); | |
| 606 | + | }, { passive: false }); | |
| 607 | + | ||
| 608 | + | viewer.addEventListener('click', function (e) { | |
| 609 | + | var button = e.target.closest('[data-minimap]'); | |
| 610 | + | if (!button) return; | |
| 611 | + | ||
| 612 | + | var action = button.getAttribute('data-minimap'); | |
| 613 | + | if (action === 'zoom-in') zoom(0.25); | |
| 614 | + | else if (action === 'zoom-out') zoom(-0.25); | |
| 615 | + | else if (action === 'floor-up') showFloor(index - 1); | |
| 616 | + | else if (action === 'floor-down') showFloor(index + 1); | |
| 617 | + | }); | |
| 618 | + | ||
| 619 | + | window.addEventListener('resize', function () { center(); render(); }); | |
| 620 | + | ||
| 621 | + | if (label) label.textContent = floorData().z; | |
| 622 | + | center(); | |
| 623 | + | render(); | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | if (document.readyState === 'loading') { | |
| 627 | + | document.addEventListener('DOMContentLoaded', init); | |
| 628 | + | } else { | |
| 629 | + | init(); | |
| 630 | + | } | |
| 631 | + | })(); | |
| 632 | + | </script> | |
| 633 | + | <?php | |
| 634 | + | } |
| @@ -0,0 +1,701 @@ | |||
| 1 | + | <?php | |
| 2 | + | /** | |
| 3 | + | * Hosted payment gateways for shop points. | |
| 4 | + | * | |
| 5 | + | * Success/failed return pages never credit points. A payment becomes real only | |
| 6 | + | * after a signed webhook is verified, the provider API confirms the payment, | |
| 7 | + | * and the stored quote still matches amount, currency, account and points. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | function payment_gateway_ensure_schema(): void { | |
| 11 | + | db()->execute(" | |
| 12 | + | CREATE TABLE IF NOT EXISTS `znote_payment_transactions` ( | |
| 13 | + | `id` bigint NOT NULL AUTO_INCREMENT, | |
| 14 | + | `provider` varchar(32) NOT NULL, | |
| 15 | + | `reference` varchar(128) NOT NULL, | |
| 16 | + | `provider_reference` varchar(128) DEFAULT NULL, | |
| 17 | + | `account_id` int NOT NULL, | |
| 18 | + | `price` decimal(11,2) NOT NULL, | |
| 19 | + | `currency` varchar(8) NOT NULL, | |
| 20 | + | `points` int NOT NULL, | |
| 21 | + | `status` varchar(32) NOT NULL DEFAULT 'pending', | |
| 22 | + | `credited` tinyint NOT NULL DEFAULT '0', | |
| 23 | + | `test_mode` tinyint NOT NULL DEFAULT '0', | |
| 24 | + | `created_at` int NOT NULL, | |
| 25 | + | `updated_at` int NOT NULL, | |
| 26 | + | `credited_at` int DEFAULT NULL, | |
| 27 | + | `payload` longtext, | |
| 28 | + | PRIMARY KEY (`id`), | |
| 29 | + | UNIQUE KEY `provider_reference_internal` (`provider`, `reference`), | |
| 30 | + | KEY `provider_reference_external` (`provider`, `provider_reference`), | |
| 31 | + | KEY `account_status` (`account_id`, `status`, `created_at`) | |
| 32 | + | ) ENGINE=InnoDB; | |
| 33 | + | "); | |
| 34 | + | ||
| 35 | + | db()->execute(" | |
| 36 | + | CREATE TABLE IF NOT EXISTS `znote_payment_events` ( | |
| 37 | + | `id` bigint NOT NULL AUTO_INCREMENT, | |
| 38 | + | `provider` varchar(32) NOT NULL, | |
| 39 | + | `event_id` varchar(128) NOT NULL, | |
| 40 | + | `provider_reference` varchar(128) DEFAULT NULL, | |
| 41 | + | `payment_reference` varchar(128) DEFAULT NULL, | |
| 42 | + | `status` varchar(32) NOT NULL DEFAULT 'received', | |
| 43 | + | `payload` longtext, | |
| 44 | + | `received_at` int NOT NULL, | |
| 45 | + | PRIMARY KEY (`id`), | |
| 46 | + | UNIQUE KEY `provider_event` (`provider`, `event_id`), | |
| 47 | + | KEY `payment_reference` (`provider`, `payment_reference`) | |
| 48 | + | ) ENGINE=InnoDB; | |
| 49 | + | "); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | function payment_gateway_enabled(string $provider): bool { | |
| 53 | + | global $config; | |
| 54 | + | return !empty($config[$provider]['enabled']); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | function payment_gateway_config(string $provider, string $key, $default = '') { | |
| 58 | + | global $config; | |
| 59 | + | return $config[$provider][$key] ?? $default; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | function payment_gateway_public_url(string $path, array $params = []): string { | |
| 63 | + | global $config; | |
| 64 | + | ||
| 65 | + | $base = trim((string)($config['site_url'] ?? ''), '/'); | |
| 66 | + | if ($base === '') { | |
| 67 | + | $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); | |
| 68 | + | $base = ($https ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost'); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | $url = $base . '/' . ltrim($path, '/'); | |
| 72 | + | if ($params) { | |
| 73 | + | $url .= '?' . http_build_query($params); | |
| 74 | + | $url = str_replace('%7BCHECKOUT_SESSION_ID%7D', '{CHECKOUT_SESSION_ID}', $url); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | return $url; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | function payment_gateway_webhook_url(string $provider): string { | |
| 81 | + | $configured = trim((string)payment_gateway_config($provider, 'webhook_url', '')); | |
| 82 | + | return $configured !== '' | |
| 83 | + | ? $configured | |
| 84 | + | : payment_gateway_public_url('payment_webhook.php', ['provider' => $provider]); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function payment_gateway_return_url(string $provider, string $type, array $params = []): string { | |
| 88 | + | $configured = trim((string)payment_gateway_config($provider, $type, '')); | |
| 89 | + | if ($configured !== '') { | |
| 90 | + | if ($params) { | |
| 91 | + | $configured .= (str_contains($configured, '?') ? '&' : '?') . http_build_query($params); | |
| 92 | + | $configured = str_replace('%7BCHECKOUT_SESSION_ID%7D', '{CHECKOUT_SESSION_ID}', $configured); | |
| 93 | + | } | |
| 94 | + | return $configured; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return payment_gateway_public_url($type === 'failed' ? 'failed.php' : 'success.php', $params); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | function payment_gateway_price_tier($price): array|false { | |
| 101 | + | global $config; | |
| 102 | + | ||
| 103 | + | $requested = number_format((float)$price, 2, '.', ''); | |
| 104 | + | foreach ((array)($config['paypal_prices'] ?? []) as $tierPrice => $tierPoints) { | |
| 105 | + | $normalized = number_format((float)$tierPrice, 2, '.', ''); | |
| 106 | + | $points = (int)$tierPoints; | |
| 107 | + | if ($normalized === $requested && (float)$requested > 0 && $points > 0) { | |
| 108 | + | return [ | |
| 109 | + | 'price' => $requested, | |
| 110 | + | 'points' => $points, | |
| 111 | + | ]; | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | return false; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | function payment_gateway_create_reference(string $provider): string { | |
| 119 | + | return $provider . '_' . bin2hex(random_bytes(16)); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | function payment_gateway_insert_transaction(string $provider, int $accountId, string $price, string $currency, int $points, bool $testMode): string { | |
| 123 | + | $reference = payment_gateway_create_reference($provider); | |
| 124 | + | $now = time(); | |
| 125 | + | $test = $testMode ? 1 : 0; | |
| 126 | + | ||
| 127 | + | $inserted = db()->execute(" | |
| 128 | + | INSERT INTO `znote_payment_transactions` | |
| 129 | + | (`provider`, `reference`, `account_id`, `price`, `currency`, `points`, `status`, `credited`, `test_mode`, `created_at`, `updated_at`) | |
| 130 | + | VALUES | |
| 131 | + | (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?); | |
| 132 | + | ", [$provider, $reference, $accountId, $price, strtoupper($currency), $points, $test, $now, $now]); | |
| 133 | + | if (!$inserted) { | |
| 134 | + | throw new RuntimeException('Payment transaction could not be created.'); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | return $reference; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | function payment_gateway_update_provider_reference(string $provider, string $reference, string $providerReference, array $payload = []): bool { | |
| 141 | + | $body = (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); | |
| 142 | + | $now = time(); | |
| 143 | + | ||
| 144 | + | return db()->execute(" | |
| 145 | + | UPDATE `znote_payment_transactions` | |
| 146 | + | SET `provider_reference` = ?, `payload` = ?, `updated_at` = ? | |
| 147 | + | WHERE `provider` = ? AND `reference` = ? LIMIT 1; | |
| 148 | + | ", [$providerReference, $body, $now, $provider, $reference]); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | function payment_gateway_update_status(string $provider, string $reference, string $status, ?string $providerReference = null, array $payload = []): void { | |
| 152 | + | $now = time(); | |
| 153 | + | $sets = ["`status` = ?", "`updated_at` = ?"]; | |
| 154 | + | $params = [$status, $now]; | |
| 155 | + | ||
| 156 | + | if ($providerReference !== null && $providerReference !== '') { | |
| 157 | + | $sets[] = "`provider_reference` = ?"; | |
| 158 | + | $params[] = $providerReference; | |
| 159 | + | } | |
| 160 | + | if ($payload) { | |
| 161 | + | $sets[] = "`payload` = ?"; | |
| 162 | + | $params[] = (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | $params[] = $provider; | |
| 166 | + | $params[] = $reference; | |
| 167 | + | ||
| 168 | + | db()->execute(" | |
| 169 | + | UPDATE `znote_payment_transactions` | |
| 170 | + | SET " . implode(', ', $sets) . " | |
| 171 | + | WHERE `provider` = ? AND `reference` = ? LIMIT 1; | |
| 172 | + | ", $params); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | function payment_gateway_log_event(string $provider, string $eventId, ?string $providerReference, ?string $paymentReference, string $status, string $payload): void { | |
| 176 | + | $eventId = $eventId !== '' ? $eventId : hash('sha256', $payload); | |
| 177 | + | $eventId = substr($eventId, 0, 128); | |
| 178 | + | $providerReference = $providerReference !== null ? substr($providerReference, 0, 128) : null; | |
| 179 | + | $paymentReference = $paymentReference !== null ? substr($paymentReference, 0, 128) : null; | |
| 180 | + | $providerReference = ($providerReference !== null && $providerReference !== '') ? $providerReference : null; | |
| 181 | + | $paymentReference = ($paymentReference !== null && $paymentReference !== '') ? $paymentReference : null; | |
| 182 | + | $body = substr($payload, 0, 65000); | |
| 183 | + | $now = time(); | |
| 184 | + | ||
| 185 | + | db()->execute(" | |
| 186 | + | INSERT INTO `znote_payment_events` | |
| 187 | + | (`provider`, `event_id`, `provider_reference`, `payment_reference`, `status`, `payload`, `received_at`) | |
| 188 | + | VALUES | |
| 189 | + | (?, ?, ?, ?, ?, ?, ?) | |
| 190 | + | ON DUPLICATE KEY UPDATE `received_at` = `received_at`; | |
| 191 | + | ", [$provider, $eventId, $providerReference, $paymentReference, $status, $body, $now]); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | function payment_gateway_update_event_status(string $provider, string $eventId, string $status, ?string $paymentReference = null): void { | |
| 195 | + | $params = [substr($status, 0, 32)]; | |
| 196 | + | $sets = ['`status` = ?']; | |
| 197 | + | if ($paymentReference !== null && $paymentReference !== '') { | |
| 198 | + | $sets[] = '`payment_reference` = ?'; | |
| 199 | + | $params[] = substr($paymentReference, 0, 128); | |
| 200 | + | } | |
| 201 | + | $params[] = $provider; | |
| 202 | + | $params[] = substr($eventId, 0, 128); | |
| 203 | + | db()->execute( | |
| 204 | + | "UPDATE `znote_payment_events` SET " . implode(', ', $sets) . " WHERE `provider` = ? AND `event_id` = ? LIMIT 1;", | |
| 205 | + | $params | |
| 206 | + | ); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | function payment_gateway_http(string $method, string $url, array $headers = [], $body = null): array { | |
| 210 | + | if (!function_exists('curl_init')) { | |
| 211 | + | return ['ok' => false, 'status' => 0, 'body' => '', 'json' => null, 'error' => 'cURL is not enabled']; | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | $ch = curl_init($url); | |
| 215 | + | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); | |
| 216 | + | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |
| 217 | + | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); | |
| 218 | + | curl_setopt($ch, CURLOPT_TIMEOUT, 45); | |
| 219 | + | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |
| 220 | + | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |
| 221 | + | curl_setopt($ch, CURLOPT_USERAGENT, 'ZnoteX/' . ($GLOBALS['version'] ?? '2.0.1')); | |
| 222 | + | ||
| 223 | + | $ca = __DIR__ . '/../cert/cacert.pem'; | |
| 224 | + | if (is_file($ca)) { | |
| 225 | + | curl_setopt($ch, CURLOPT_CAINFO, $ca); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | if ($headers) { | |
| 229 | + | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); | |
| 230 | + | } | |
| 231 | + | if ($body !== null) { | |
| 232 | + | curl_setopt($ch, CURLOPT_POSTFIELDS, $body); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | $response = curl_exec($ch); | |
| 236 | + | $error = curl_error($ch); | |
| 237 | + | $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| 238 | + | curl_close($ch); | |
| 239 | + | ||
| 240 | + | $json = null; | |
| 241 | + | if (is_string($response) && $response !== '') { | |
| 242 | + | $decoded = json_decode($response, true); | |
| 243 | + | if (is_array($decoded)) { | |
| 244 | + | $json = $decoded; | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | return [ | |
| 249 | + | 'ok' => $status >= 200 && $status < 300 && $response !== false, | |
| 250 | + | 'status' => $status, | |
| 251 | + | 'body' => is_string($response) ? $response : '', | |
| 252 | + | 'json' => $json, | |
| 253 | + | 'error' => $error, | |
| 254 | + | ]; | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | function payment_gateway_stripe_api(string $method, string $path, array $params = []): array { | |
| 258 | + | $secret = trim((string)payment_gateway_config('stripe', 'secret_key', '')); | |
| 259 | + | if ($secret === '') { | |
| 260 | + | return ['ok' => false, 'status' => 0, 'body' => '', 'json' => null, 'error' => 'Stripe secret key is missing']; | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | $body = strtoupper($method) === 'GET' ? null : http_build_query($params); | |
| 264 | + | return payment_gateway_http($method, 'https://api.stripe.com' . $path, [ | |
| 265 | + | 'Authorization: Bearer ' . $secret, | |
| 266 | + | 'Content-Type: application/x-www-form-urlencoded', | |
| 267 | + | ], $body); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | function payment_gateway_mercadopago_api(string $method, string $path, array $params = []): array { | |
| 271 | + | $token = trim((string)payment_gateway_config('mercadopago', 'access_token', '')); | |
| 272 | + | if ($token === '') { | |
| 273 | + | return ['ok' => false, 'status' => 0, 'body' => '', 'json' => null, 'error' => 'Mercado Pago access token is missing']; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | $body = strtoupper($method) === 'GET' ? null : json_encode($params, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); | |
| 277 | + | return payment_gateway_http($method, 'https://api.mercadopago.com' . $path, [ | |
| 278 | + | 'Authorization: Bearer ' . $token, | |
| 279 | + | 'Content-Type: application/json', | |
| 280 | + | 'Accept: application/json', | |
| 281 | + | ], $body); | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | function payment_gateway_create_checkout(string $provider, int $accountId, $price): array { | |
| 285 | + | global $config; | |
| 286 | + | ||
| 287 | + | $provider = strtolower($provider); | |
| 288 | + | if (!in_array($provider, ['stripe', 'mercadopago'], true)) { | |
| 289 | + | throw new RuntimeException('Unsupported payment provider.'); | |
| 290 | + | } | |
| 291 | + | if (!payment_gateway_enabled($provider)) { | |
| 292 | + | throw new RuntimeException('This payment provider is disabled.'); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | payment_gateway_ensure_schema(); | |
| 296 | + | ||
| 297 | + | $tier = payment_gateway_price_tier($price); | |
| 298 | + | if ($tier === false) { | |
| 299 | + | throw new RuntimeException('Invalid point package.'); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | $currency = strtoupper(trim((string)payment_gateway_config($provider, 'currency', $config['paypal']['currency'] ?? 'EUR'))); | |
| 303 | + | if (!preg_match('/^[A-Z]{3}$/', $currency)) { | |
| 304 | + | throw new RuntimeException('Invalid payment currency.'); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | $testMode = !empty($config[$provider]['test_mode']); | |
| 308 | + | $reference = payment_gateway_insert_transaction($provider, $accountId, $tier['price'], $currency, $tier['points'], $testMode); | |
| 309 | + | $title = $tier['points'] . ' shop points on ' . ($config['site_title'] ?? 'ZnoteX'); | |
| 310 | + | ||
| 311 | + | if ($provider === 'stripe') { | |
| 312 | + | $multiplier = (int)payment_gateway_config('stripe', 'amount_multiplier', 100); | |
| 313 | + | $amount = (int)round(((float)$tier['price']) * max(1, $multiplier)); | |
| 314 | + | $response = payment_gateway_stripe_api('POST', '/v1/checkout/sessions', [ | |
| 315 | + | 'mode' => 'payment', | |
| 316 | + | 'client_reference_id' => $reference, | |
| 317 | + | 'success_url' => payment_gateway_return_url('stripe', 'success', ['provider' => 'stripe', 'session_id' => '{CHECKOUT_SESSION_ID}']), | |
| 318 | + | 'cancel_url' => payment_gateway_return_url('stripe', 'failed', ['provider' => 'stripe']), | |
| 319 | + | 'metadata' => [ | |
| 320 | + | 'znote_reference' => $reference, | |
| 321 | + | 'account_id' => (string)$accountId, | |
| 322 | + | 'points' => (string)$tier['points'], | |
| 323 | + | ], | |
| 324 | + | 'line_items' => [[ | |
| 325 | + | 'quantity' => 1, | |
| 326 | + | 'price_data' => [ | |
| 327 | + | 'currency' => strtolower($currency), | |
| 328 | + | 'unit_amount' => $amount, | |
| 329 | + | 'product_data' => [ | |
| 330 | + | 'name' => $title, | |
| 331 | + | ], | |
| 332 | + | ], | |
| 333 | + | ]], | |
| 334 | + | ]); | |
| 335 | + | ||
| 336 | + | if (!$response['ok'] || empty($response['json']['url']) || empty($response['json']['id'])) { | |
| 337 | + | payment_gateway_update_status('stripe', $reference, 'create_failed', null, $response['json'] ?? []); | |
| 338 | + | throw new RuntimeException('Stripe checkout creation failed.'); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | if (!payment_gateway_update_provider_reference('stripe', $reference, (string)$response['json']['id'], $response['json'])) { | |
| 342 | + | throw new RuntimeException('Stripe checkout could not be stored.'); | |
| 343 | + | } | |
| 344 | + | return ['url' => (string)$response['json']['url'], 'reference' => $reference]; | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | $response = payment_gateway_mercadopago_api('POST', '/checkout/preferences', [ | |
| 348 | + | 'external_reference' => $reference, | |
| 349 | + | 'notification_url' => payment_gateway_webhook_url('mercadopago'), | |
| 350 | + | 'back_urls' => [ | |
| 351 | + | 'success' => payment_gateway_return_url('mercadopago', 'success', ['provider' => 'mercadopago']), | |
| 352 | + | 'failure' => payment_gateway_return_url('mercadopago', 'failed', ['provider' => 'mercadopago']), | |
| 353 | + | 'pending' => payment_gateway_return_url('mercadopago', 'success', ['provider' => 'mercadopago', 'pending' => 1]), | |
| 354 | + | ], | |
| 355 | + | 'metadata' => [ | |
| 356 | + | 'znote_reference' => $reference, | |
| 357 | + | 'account_id' => $accountId, | |
| 358 | + | 'points' => $tier['points'], | |
| 359 | + | ], | |
| 360 | + | 'items' => [[ | |
| 361 | + | 'title' => $title, | |
| 362 | + | 'quantity' => 1, | |
| 363 | + | 'currency_id' => $currency, | |
| 364 | + | 'unit_price' => (float)$tier['price'], | |
| 365 | + | ]], | |
| 366 | + | ]); | |
| 367 | + | ||
| 368 | + | $url = ''; | |
| 369 | + | if ($testMode && !empty($response['json']['sandbox_init_point'])) { | |
| 370 | + | $url = (string)$response['json']['sandbox_init_point']; | |
| 371 | + | } elseif (!empty($response['json']['init_point'])) { | |
| 372 | + | $url = (string)$response['json']['init_point']; | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | if (!$response['ok'] || $url === '' || empty($response['json']['id'])) { | |
| 376 | + | payment_gateway_update_status('mercadopago', $reference, 'create_failed', null, $response['json'] ?? []); | |
| 377 | + | throw new RuntimeException('Mercado Pago checkout creation failed.'); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | if (!payment_gateway_update_provider_reference('mercadopago', $reference, (string)$response['json']['id'], $response['json'])) { | |
| 381 | + | throw new RuntimeException('Mercado Pago checkout could not be stored.'); | |
| 382 | + | } | |
| 383 | + | return ['url' => $url, 'reference' => $reference]; | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | function payment_gateway_parse_header_signature(string $header): array { | |
| 387 | + | $out = []; | |
| 388 | + | foreach (explode(',', $header) as $part) { | |
| 389 | + | $bits = explode('=', trim($part), 2); | |
| 390 | + | if (count($bits) === 2) { | |
| 391 | + | $out[$bits[0]][] = $bits[1]; | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | return $out; | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | function payment_gateway_verify_stripe_signature(string $payload, string $header): bool { | |
| 398 | + | $secret = trim((string)payment_gateway_config('stripe', 'webhook_secret', '')); | |
| 399 | + | if ($secret === '' || $header === '') { | |
| 400 | + | return false; | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | $parts = payment_gateway_parse_header_signature($header); | |
| 404 | + | $timestamp = isset($parts['t'][0]) ? (int)$parts['t'][0] : 0; | |
| 405 | + | if ($timestamp <= 0 || abs(time() - $timestamp) > 300 || empty($parts['v1'])) { | |
| 406 | + | return false; | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); | |
| 410 | + | foreach ($parts['v1'] as $sig) { | |
| 411 | + | if (hash_equals($expected, $sig)) { | |
| 412 | + | return true; | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | return false; | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | function payment_gateway_verify_mercadopago_signature(string $dataId, string $requestId, string $header): bool { | |
| 420 | + | $secret = trim((string)payment_gateway_config('mercadopago', 'webhook_secret', '')); | |
| 421 | + | if ($secret === '' || $dataId === '' || $requestId === '' || $header === '') { | |
| 422 | + | return false; | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | $parts = payment_gateway_parse_header_signature($header); | |
| 426 | + | $timestamp = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0; | |
| 427 | + | $signature = $parts['v1'][0] ?? ''; | |
| 428 | + | if ($timestamp <= 0 || abs(time() - $timestamp) > 900 || $signature === '') { | |
| 429 | + | return false; | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';'; | |
| 433 | + | $expected = hash_hmac('sha256', $manifest, $secret); | |
| 434 | + | ||
| 435 | + | return hash_equals($expected, $signature); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | function payment_gateway_validate_transaction(string $provider, $tx, string $providerReference, array $payload): string { | |
| 439 | + | if (!is_array($tx)) { | |
| 440 | + | return 'missing_transaction'; | |
| 441 | + | } | |
| 442 | + | if ((int)$tx['credited'] === 1) { | |
| 443 | + | return 'already_credited'; | |
| 444 | + | } | |
| 445 | + | if ($providerReference !== '') { | |
| 446 | + | $storedProviderReference = (string)($tx['provider_reference'] ?? ''); | |
| 447 | + | if ($storedProviderReference !== '' && $storedProviderReference !== $providerReference && $provider === 'stripe') { | |
| 448 | + | return 'provider_reference_mismatch'; | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | $accountId = (int)$tx['account_id']; | |
| 453 | + | $points = (int)$tx['points']; | |
| 454 | + | if ($accountId <= 0 || $points <= 0) { | |
| 455 | + | return 'invalid_transaction'; | |
| 456 | + | } | |
| 457 | + | if (!payment_gateway_provider_amount_matches($provider, $tx, $payload)) { | |
| 458 | + | return 'amount_mismatch'; | |
| 459 | + | } | |
| 460 | + | if (!payment_gateway_provider_mode_matches($tx, $payload)) { | |
| 461 | + | return 'mode_mismatch'; | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | return 'ok'; | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | function payment_gateway_credit_transaction(string $provider, string $reference, string $providerReference, string $expectedStatus, array $payload = []): string { | |
| 468 | + | $now = time(); | |
| 469 | + | $body = (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); | |
| 470 | + | $db = db(); | |
| 471 | + | ||
| 472 | + | try { | |
| 473 | + | if (!$db->beginTransaction()) { | |
| 474 | + | return 'credit_failed'; | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | $tx = $db->fetchOne(" | |
| 478 | + | SELECT * | |
| 479 | + | FROM `znote_payment_transactions` | |
| 480 | + | WHERE `provider` = ? AND `reference` = ? | |
| 481 | + | LIMIT 1 | |
| 482 | + | FOR UPDATE; | |
| 483 | + | ", [$provider, $reference]); | |
| 484 | + | ||
| 485 | + | $validation = payment_gateway_validate_transaction($provider, $tx, $providerReference, $payload); | |
| 486 | + | if ($validation === 'already_credited') { | |
| 487 | + | $db->commit(); | |
| 488 | + | return 'already_credited'; | |
| 489 | + | } | |
| 490 | + | if ($validation !== 'ok') { | |
| 491 | + | $db->rollback(); | |
| 492 | + | if ($validation === 'amount_mismatch' || $validation === 'mode_mismatch') { | |
| 493 | + | payment_gateway_update_status($provider, $reference, $validation, $providerReference, $payload); | |
| 494 | + | } | |
| 495 | + | return $validation; | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | $accountId = (int)$tx['account_id']; | |
| 499 | + | $points = (int)$tx['points']; | |
| 500 | + | ||
| 501 | + | $accountRow = $db->fetchOne( | |
| 502 | + | "SELECT `id` FROM `znote_accounts` WHERE `account_id` = ? LIMIT 1 FOR UPDATE;", | |
| 503 | + | [$accountId] | |
| 504 | + | ); | |
| 505 | + | if (!is_array($accountRow)) { | |
| 506 | + | if (!$db->execute( | |
| 507 | + | "INSERT INTO `znote_accounts` (`account_id`, `ip`, `created`, `points`, `flag`) VALUES (?, 0, ?, 0, '');", | |
| 508 | + | [$accountId, $now] | |
| 509 | + | )) { | |
| 510 | + | $db->rollback(); | |
| 511 | + | return 'credit_failed'; | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | if (!$db->execute( | |
| 516 | + | "UPDATE `znote_accounts` SET `points` = COALESCE(`points`, 0) + ? WHERE `account_id` = ?;", | |
| 517 | + | [$points, $accountId] | |
| 518 | + | )) { | |
| 519 | + | $db->rollback(); | |
| 520 | + | return 'credit_failed'; | |
| 521 | + | } | |
| 522 | + | if (!$db->execute(" | |
| 523 | + | UPDATE `znote_payment_transactions` | |
| 524 | + | SET `provider_reference` = COALESCE(NULLIF(?, ''), `provider_reference`), | |
| 525 | + | `status` = ?, | |
| 526 | + | `credited` = 1, | |
| 527 | + | `credited_at` = ?, | |
| 528 | + | `updated_at` = ?, | |
| 529 | + | `payload` = ? | |
| 530 | + | WHERE `id` = ?; | |
| 531 | + | ", [$providerReference, $expectedStatus, $now, $now, $body, (int)$tx['id']])) { | |
| 532 | + | $db->rollback(); | |
| 533 | + | return 'credit_failed'; | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | if (!$db->commit()) { | |
| 537 | + | $db->rollback(); | |
| 538 | + | return 'credit_failed'; | |
| 539 | + | } | |
| 540 | + | try { | |
| 541 | + | payment_gateway_fire_completed($provider, $reference, $providerReference, $expectedStatus, $accountId, $points, $tx, $payload); | |
| 542 | + | } catch (Throwable $hookError) { | |
| 543 | + | error_log('Payment completed hook failed: ' . $hookError->getMessage()); | |
| 544 | + | } | |
| 545 | + | return 'credited'; | |
| 546 | + | } catch (Throwable $e) { | |
| 547 | + | $db->rollback(); | |
| 548 | + | error_log('Payment credit failed: ' . $e->getMessage()); | |
| 549 | + | return 'credit_failed'; | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | function payment_gateway_fire_completed(string $provider, string $reference, string $providerReference, string $status, int $accountId, int $points, array $tx, array $payload): void { | |
| 554 | + | if (!function_exists('znote_hook')) { | |
| 555 | + | return; | |
| 556 | + | } | |
| 557 | + | znote_hook('payment.completed', array( | |
| 558 | + | 'provider' => $provider, | |
| 559 | + | 'reference' => $reference, | |
| 560 | + | 'provider_reference' => $providerReference, | |
| 561 | + | 'account_id' => $accountId, | |
| 562 | + | 'price' => $tx['price'] ?? null, | |
| 563 | + | 'currency' => $tx['currency'] ?? null, | |
| 564 | + | 'points' => $points, | |
| 565 | + | 'status' => $status, | |
| 566 | + | 'payload' => $payload, | |
| 567 | + | )); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | function payment_gateway_provider_amount_matches(string $provider, array $transaction, array $payload): bool { | |
| 571 | + | $currency = strtoupper((string)($transaction['currency'] ?? '')); | |
| 572 | + | $price = (float)($transaction['price'] ?? 0); | |
| 573 | + | ||
| 574 | + | if ($provider === 'stripe') { | |
| 575 | + | $multiplier = (int)payment_gateway_config('stripe', 'amount_multiplier', 100); | |
| 576 | + | $expectedAmount = (int)round($price * max(1, $multiplier)); | |
| 577 | + | $actualAmount = (int)($payload['amount_total'] ?? 0); | |
| 578 | + | $actualCurrency = strtoupper((string)($payload['currency'] ?? '')); | |
| 579 | + | ||
| 580 | + | return $expectedAmount > 0 && $actualAmount === $expectedAmount && $actualCurrency === $currency; | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | if ($provider === 'mercadopago') { | |
| 584 | + | $actualAmount = number_format((float)($payload['transaction_amount'] ?? 0), 2, '.', ''); | |
| 585 | + | $expectedAmount = number_format($price, 2, '.', ''); | |
| 586 | + | $actualCurrency = strtoupper((string)($payload['currency_id'] ?? '')); | |
| 587 | + | ||
| 588 | + | return $expectedAmount !== '0.00' && $actualAmount === $expectedAmount && $actualCurrency === $currency; | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | return false; | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | function payment_gateway_provider_mode_matches(array $transaction, array $payload): bool { | |
| 595 | + | if (array_key_exists('livemode', $payload)) { | |
| 596 | + | return (bool)$payload['livemode'] !== ((int)($transaction['test_mode'] ?? 0) === 1); | |
| 597 | + | } | |
| 598 | + | if (array_key_exists('live_mode', $payload)) { | |
| 599 | + | return (bool)$payload['live_mode'] !== ((int)($transaction['test_mode'] ?? 0) === 1); | |
| 600 | + | } | |
| 601 | + | return false; | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | function payment_gateway_handle_stripe_webhook(string $payload): array { | |
| 605 | + | $signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; | |
| 606 | + | if (!payment_gateway_verify_stripe_signature($payload, $signature)) { | |
| 607 | + | return ['code' => 401, 'status' => 'invalid_signature']; | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | $event = json_decode($payload, true); | |
| 611 | + | if (!is_array($event)) { | |
| 612 | + | return ['code' => 400, 'status' => 'invalid_json']; | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | $eventId = (string)($event['id'] ?? hash('sha256', $payload)); | |
| 616 | + | $type = (string)($event['type'] ?? ''); | |
| 617 | + | $session = $event['data']['object'] ?? []; | |
| 618 | + | $sessionId = is_array($session) ? (string)($session['id'] ?? '') : ''; | |
| 619 | + | $reference = is_array($session) ? (string)($session['client_reference_id'] ?? ($session['metadata']['znote_reference'] ?? '')) : ''; | |
| 620 | + | ||
| 621 | + | payment_gateway_log_event('stripe', $eventId, $sessionId, $reference, $type !== '' ? $type : 'received', $payload); | |
| 622 | + | ||
| 623 | + | if (!in_array($type, ['checkout.session.completed', 'checkout.session.async_payment_succeeded'], true)) { | |
| 624 | + | payment_gateway_update_event_status('stripe', $eventId, 'ignored', $reference); | |
| 625 | + | return ['code' => 200, 'status' => 'ignored']; | |
| 626 | + | } | |
| 627 | + | if ($sessionId === '' || $reference === '') { | |
| 628 | + | payment_gateway_update_event_status('stripe', $eventId, 'missing_reference', $reference); | |
| 629 | + | return ['code' => 400, 'status' => 'missing_reference']; | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | $response = payment_gateway_stripe_api('GET', '/v1/checkout/sessions/' . rawurlencode($sessionId)); | |
| 633 | + | if (!$response['ok'] || !is_array($response['json'])) { | |
| 634 | + | payment_gateway_update_event_status('stripe', $eventId, 'provider_lookup_failed', $reference); | |
| 635 | + | return ['code' => 502, 'status' => 'provider_lookup_failed']; | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | $verified = $response['json']; | |
| 639 | + | $verifiedReference = (string)($verified['client_reference_id'] ?? ($verified['metadata']['znote_reference'] ?? '')); | |
| 640 | + | if ($verifiedReference !== $reference || ($verified['payment_status'] ?? '') !== 'paid') { | |
| 641 | + | payment_gateway_update_status('stripe', $reference, 'not_paid', $sessionId, $verified); | |
| 642 | + | payment_gateway_update_event_status('stripe', $eventId, 'not_paid', $reference); | |
| 643 | + | return ['code' => 200, 'status' => 'not_paid']; | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | $result = payment_gateway_credit_transaction('stripe', $reference, $sessionId, 'paid', $verified); | |
| 647 | + | payment_gateway_update_event_status('stripe', $eventId, $result, $reference); | |
| 648 | + | return ['code' => $result === 'credit_failed' || $result === 'missing_transaction' ? 500 : 200, 'status' => $result]; | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | function payment_gateway_handle_mercadopago_webhook(string $payload): array { | |
| 652 | + | $body = json_decode($payload, true); | |
| 653 | + | if (!is_array($body)) { | |
| 654 | + | $body = []; | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | $dataId = (string)($_GET['data.id'] ?? $_GET['id'] ?? $_GET['data_id'] ?? ($body['data']['id'] ?? '')); | |
| 658 | + | $requestId = (string)($_SERVER['HTTP_X_REQUEST_ID'] ?? ''); | |
| 659 | + | $signature = (string)($_SERVER['HTTP_X_SIGNATURE'] ?? ''); | |
| 660 | + | ||
| 661 | + | if (!payment_gateway_verify_mercadopago_signature($dataId, $requestId, $signature)) { | |
| 662 | + | return ['code' => 401, 'status' => 'invalid_signature']; | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | $eventId = (string)($body['id'] ?? (($body['action'] ?? 'payment') . '_' . $dataId)); | |
| 666 | + | $type = (string)($body['type'] ?? $_GET['type'] ?? $_GET['topic'] ?? ''); | |
| 667 | + | payment_gateway_log_event('mercadopago', $eventId, $dataId, null, $type !== '' ? $type : 'received', $payload); | |
| 668 | + | ||
| 669 | + | if ($dataId === '') { | |
| 670 | + | payment_gateway_update_event_status('mercadopago', $eventId, 'missing_payment_id'); | |
| 671 | + | return ['code' => 400, 'status' => 'missing_payment_id']; | |
| 672 | + | } | |
| 673 | + | if ($type !== '' && !in_array($type, ['payment', 'payment.updated', 'payment.created'], true)) { | |
| 674 | + | payment_gateway_update_event_status('mercadopago', $eventId, 'ignored'); | |
| 675 | + | return ['code' => 200, 'status' => 'ignored']; | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | $response = payment_gateway_mercadopago_api('GET', '/v1/payments/' . rawurlencode($dataId)); | |
| 679 | + | if (!$response['ok'] || !is_array($response['json'])) { | |
| 680 | + | payment_gateway_update_event_status('mercadopago', $eventId, 'provider_lookup_failed'); | |
| 681 | + | return ['code' => 502, 'status' => 'provider_lookup_failed']; | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | $payment = $response['json']; | |
| 685 | + | $reference = (string)($payment['external_reference'] ?? ($payment['metadata']['znote_reference'] ?? '')); | |
| 686 | + | if ($reference === '') { | |
| 687 | + | payment_gateway_update_event_status('mercadopago', $eventId, 'missing_reference'); | |
| 688 | + | return ['code' => 400, 'status' => 'missing_reference']; | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | if (($payment['status'] ?? '') !== 'approved') { | |
| 692 | + | payment_gateway_update_status('mercadopago', $reference, (string)($payment['status'] ?? 'not_approved'), $dataId, $payment); | |
| 693 | + | payment_gateway_update_event_status('mercadopago', $eventId, 'not_approved', $reference); | |
| 694 | + | return ['code' => 200, 'status' => 'not_approved']; | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | $result = payment_gateway_credit_transaction('mercadopago', $reference, $dataId, 'approved', $payment); | |
| 698 | + | payment_gateway_update_event_status('mercadopago', $eventId, $result, $reference); | |
| 699 | + | return ['code' => $result === 'credit_failed' || $result === 'missing_transaction' ? 500 : 200, 'status' => $result]; | |
| 700 | + | } | |
| 701 | + | ?> |