diff --git a/CHANGELOG.md b/CHANGELOG.md index a73e9945..e696b3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 2026-05-09 +- **Billing OS-variant rollback + exact-service provisioning:** Removed storefront canonical Linux/Windows deduping and order-time OS service auto-switching so each enabled `billing_services` row is sold as its own variant. Checkout/provision now preserve the exact selected `service_id`/`home_cfg_id`/XML, enforce location OS compatibility without silent swapping, log selected XML context in provisioning traces, and show “Server installation is in progress.” instead of immediate executable-missing wording while update installs are active. Also replaced deprecated `utf8_encode()` usage in `modules/gamemanager/view_server_log.php` for PHP 8.3 compatibility. - **Mandatory provisioning trace log + existing-home install retry:** Added visible fail-closed trace logging to `modules/billing/logs/provisioning_trace.log`, threaded detailed per-order provisioning results into checkout success flows, and changed billing provisioning so `Active` orders with an existing `home_id` only skip when the install is already complete. Incomplete existing homes now keep allocating missing IP/mod data and re-use `gamemanager_trigger_update_install()` with traced inputs/outputs instead of silently requiring a manual Game Monitor update. - **Billing provisioning auto-run + idempotency hardening:** Updated paid capture, free checkout, PayPal webhook renewals, and admin add-home registration to always hand activated orders to `billing_invoke_provision()` in trusted internal context. `modules/billing/create_servers.php` now enforces 6-character alphanumeric default passwords, defaults new homes to `ftp_status=1`, adds stricter service/node/port/mod validation logging, retries existing `home_id` installs only when incomplete, and skips already-installed homes to prevent duplicate provisioning on refresh/retry paths. - **Game Monitor IP:PORT fallback reliability:** `modules/gamemanager/server_monitor.php` now resolves missing display/connect endpoints from `home_ip_ports` + `remote_server_ips` for each home so newly provisioned servers consistently show IP:PORT even when initial joined row data is incomplete. diff --git a/docs/COPILOT_TODO.md b/docs/COPILOT_TODO.md index f881dbde..f758df5d 100644 --- a/docs/COPILOT_TODO.md +++ b/docs/COPILOT_TODO.md @@ -13,3 +13,4 @@ - Add an admin billing orders "provisioning details" drawer that reads `modules/billing/logs/provisioning.log` and shows the latest mechanism/result/error per order without leaving the panel. - Add an automated end-to-end check that verifies `create_servers.php` skips already-installed homes while still retrying existing-home orders with missing executable/IP-port/mod prerequisites. - Add a repeatable QA fixture that exercises `modules/billing/logs/provisioning_trace.log` writability failures and verifies payment success pages surface the traced provision result for paid and free orders. +- Add an admin/serverlist UI badge that shows detected service OS variant (Windows/Linux/Any) from XML metadata next to each purchasable service row. diff --git a/modules/billing/add_to_cart.php b/modules/billing/add_to_cart.php index 1f531e20..1dbcc923 100644 --- a/modules/billing/add_to_cart.php +++ b/modules/billing/add_to_cart.php @@ -71,10 +71,41 @@ function billing_rate_from_service(mysqli $db, string $table_prefix, int $servic return $rate; } -function billing_fail_add_to_cart(string $message, array $context = []): void +function billing_detect_service_os(string $cfgFile, string $gameKey): string +{ + $haystack = strtolower(trim($cfgFile !== '' ? $cfgFile : $gameKey)); + if ($haystack === '') { + return 'any'; + } + if (preg_match('/(?:^|[_\\-])(win|windows)(?:[_\\-]|$)/i', $haystack)) { + return 'windows'; + } + if (preg_match('/(?:^|[_\\-])linux(?:[_\\-]|$)/i', $haystack)) { + return 'linux'; + } + return 'any'; +} + +function billing_normalize_node_os(string $serverOs): string +{ + $value = strtolower(trim($serverOs)); + if ($value === '' || $value === 'any') { + return 'any'; + } + if (str_starts_with($value, 'win')) { + return 'windows'; + } + if (str_starts_with($value, 'lin')) { + return 'linux'; + } + return $value; +} + +function billing_fail_add_to_cart(string $message, array $context = [], ?string $redirect = null): void { site_log_error('add_to_cart_failed', array_merge(['message' => $message], $context)); - header('Location: /cart.php?error=add_to_cart'); + $target = $redirect ?? '/cart.php?error=add_to_cart'; + header('Location: ' . $target); exit; } @@ -165,13 +196,21 @@ function billing_fail_add_to_cart(string $message, array $context = []): void $base_rate = 0.0; $slot_min_qty = 1; $slot_max_qty = 1; +$service_home_cfg_id = 0; +$service_remote_server_csv = ''; +$service_cfg_file = ''; +$service_game_key = ''; $durationInfo = billing_normalize_duration($invoice_duration); if ($service_id > 0) { - $stmt = $db->prepare("SELECT service_name, price_monthly, slot_min_qty, slot_max_qty FROM {$table_prefix}billing_services WHERE service_id = ? LIMIT 1"); + $stmt = $db->prepare("SELECT bs.service_name, bs.price_monthly, bs.slot_min_qty, bs.slot_max_qty, bs.home_cfg_id, bs.remote_server_id, ch.home_cfg_file, ch.game_key + FROM {$table_prefix}billing_services bs + LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id + WHERE bs.service_id = ? AND bs.enabled = 1 + LIMIT 1"); if ($stmt) { $stmt->bind_param('i', $service_id); $stmt->execute(); - $stmt->bind_result($service_name, $price_monthly, $slot_min_qty, $slot_max_qty); + $stmt->bind_result($service_name, $price_monthly, $slot_min_qty, $slot_max_qty, $service_home_cfg_id, $service_remote_server_csv, $service_cfg_file, $service_game_key); if ($stmt->fetch()) { $base_rate = floatval($price_monthly); // constrain slots @@ -182,6 +221,61 @@ function billing_fail_add_to_cart(string $message, array $context = []): void } } +if ($service_id <= 0 || $base_rate < 0) { + billing_fail_add_to_cart('Invalid service selection', ['service_id' => $service_id]); +} + +if ($service_name === '') { + billing_fail_add_to_cart('Selected service is not available', ['service_id' => $service_id], '/serverlist.php'); +} + +if ($ip_id <= 0) { + billing_fail_add_to_cart('No location selected', ['service_id' => $service_id], '/order.php?service_id=' . intval($service_id) . '&error_message=' . rawurlencode('Please select a server location.')); +} + +$allowedServerIds = []; +foreach (explode(',', (string)$service_remote_server_csv) as $part) { + $part = trim($part); + if ($part !== '' && ctype_digit($part)) { + $allowedServerIds[(int)$part] = true; + } +} +if (!isset($allowedServerIds[$ip_id])) { + billing_fail_add_to_cart('Selected location is not allowed for this service', [ + 'service_id' => $service_id, + 'ip_id' => $ip_id, + 'remote_server_csv' => $service_remote_server_csv, + ], '/order.php?service_id=' . intval($service_id) . '&error_message=' . rawurlencode('Selected location is not available for this service.')); +} + +$hasServerOsColumn = false; +$osColCheck = mysqli_query($db, "SHOW COLUMNS FROM {$table_prefix}remote_servers LIKE 'server_os'"); +if ($osColCheck && mysqli_num_rows($osColCheck) > 0) { + $hasServerOsColumn = true; +} + +if ($hasServerOsColumn) { + $rsQuery = mysqli_query($db, "SELECT remote_server_id, server_os FROM {$table_prefix}remote_servers WHERE remote_server_id = " . intval($ip_id) . " LIMIT 1"); + if ($rsQuery && mysqli_num_rows($rsQuery) === 1) { + $rsRow = mysqli_fetch_assoc($rsQuery); + $serviceOs = billing_detect_service_os((string)$service_cfg_file, (string)$service_game_key); + $nodeOs = billing_normalize_node_os((string)($rsRow['server_os'] ?? 'any')); + if ($serviceOs !== 'any' && $nodeOs !== 'any' && $serviceOs !== $nodeOs) { + $message = $serviceOs === 'windows' + ? 'This service requires a Windows server location.' + : 'This service requires a Linux server location.'; + billing_fail_add_to_cart('Service and node OS mismatch', [ + 'service_id' => $service_id, + 'home_cfg_id' => $service_home_cfg_id, + 'cfg_file' => $service_cfg_file, + 'node_os' => $nodeOs, + ], '/order.php?service_id=' . intval($service_id) . '&error_message=' . rawurlencode($message)); + } + } else { + billing_fail_add_to_cart('Selected remote server not found', ['service_id' => $service_id, 'ip_id' => $ip_id], '/order.php?service_id=' . intval($service_id) . '&error_message=' . rawurlencode('Selected server location no longer exists.')); + } +} + if ($base_rate <= 0 && $display_service_id > 0) { $fallback_rate = billing_rate_from_service($db, $table_prefix, $display_service_id, $durationInfo['rate_type']); if ($fallback_rate > 0) { diff --git a/modules/billing/adminserverlist.php b/modules/billing/adminserverlist.php index a0bca5a9..8290f49e 100644 --- a/modules/billing/adminserverlist.php +++ b/modules/billing/adminserverlist.php @@ -21,7 +21,7 @@ .muted { color: #999; font-size: 0.85em; } .flash-ok { background: #d4edda; border: 1px solid #c3e6cb; padding: 10px 12px; margin-bottom: 10px; border-radius: 6px; color: #155724; } .flash-err { background: #f8d7da; border: 1px solid #f5c6cb; padding: 10px 12px; margin-bottom: 10px; border-radius: 6px; color: #721c24; } - .servers-cell { text-align: left; min-width: 240px; max-width: 280px; } + .servers-cell { text-align: left; min-width: 160px; max-width: 220px; width: 220px; } .server-cb-label { display: block; white-space: normal; margin: 2px 0; } .action-cell { text-align: center; min-width: 120px; } .btn-row-save, .btn-save-all { diff --git a/modules/billing/create_servers.php b/modules/billing/create_servers.php index d9df1c21..287af776 100644 --- a/modules/billing/create_servers.php +++ b/modules/billing/create_servers.php @@ -229,9 +229,13 @@ function billing_detect_install_state(array $home_info): array $state['exec_path'] = $execPath; $state['exec_exists'] = ($remote->rfile_exists($execPath) === 1); $state['complete'] = $state['exec_exists']; - $state['reason'] = $state['exec_exists'] - ? 'Expected executable already exists on the remote server.' - : 'Expected executable is missing on the remote server.'; + if ($state['exec_exists']) { + $state['reason'] = 'Expected executable already exists on the remote server.'; + } elseif (!empty($state['update_active'])) { + $state['reason'] = 'Server installation is in progress.'; + } else { + $state['reason'] = 'Expected executable is missing on the remote server.'; + } return $state; } } @@ -288,6 +292,53 @@ function billing_agent_offline_reason(int $remote_server_id, array $home_info): } } +if (!function_exists('billing_detect_service_os')) { + function billing_detect_service_os(string $cfg_file, string $game_key): string + { + $haystack = strtolower(trim($cfg_file !== '' ? $cfg_file : $game_key)); + if ($haystack === '') { + return 'any'; + } + if (preg_match('/(?:^|[_\\-])(win|windows)(?:[_\\-]|$)/i', $haystack)) { + return 'windows'; + } + if (preg_match('/(?:^|[_\\-])linux(?:[_\\-]|$)/i', $haystack)) { + return 'linux'; + } + return 'any'; + } +} + +if (!function_exists('billing_normalize_node_os')) { + function billing_normalize_node_os(string $server_os): string + { + $value = strtolower(trim($server_os)); + if ($value === '' || $value === 'any') { + return 'any'; + } + if (str_starts_with($value, 'win')) { + return 'windows'; + } + if (str_starts_with($value, 'lin')) { + return 'linux'; + } + return $value; + } +} + +if (!function_exists('billing_remote_servers_has_os_column')) { + function billing_remote_servers_has_os_column($db, string $db_prefix): bool + { + static $cache = array(); + if (isset($cache[$db_prefix])) { + return $cache[$db_prefix]; + } + $rows = $db->resultQuery("SHOW COLUMNS FROM `{$db_prefix}remote_servers` LIKE 'server_os'"); + $cache[$db_prefix] = !empty($rows); + return $cache[$db_prefix]; + } +} + if (!function_exists('billing_invoke_provision')) { function billing_invoke_provision(array $options = array()) { @@ -681,6 +732,11 @@ function exec_ogp_module() $selected_port = 0; $selected_mod_id = 0; $resolved_mod_cfg_id = 0; + $home_cfg_id = 0; + $mod_cfg_id = 0; + $selected_config_xml = ''; + $selected_game_key = ''; + $selected_service_os = 'any'; $install_mechanism = BILLING_INSTALL_MECHANISM; $install_result = 'pending'; $install_message = ''; @@ -704,9 +760,12 @@ function exec_ogp_module() } billing_provision_trace('Resolved latest invoice row for order.', array('invoice_row' => $invoiceRow)); //Query service info - $service = $db->resultQuery( "SELECT * - FROM `{$db_prefix}billing_services` - WHERE service_id=".$db->realEscapeSingle($service_id) ); + $service = $db->resultQuery( + "SELECT bs.*, ch.home_cfg_file, ch.game_key + FROM `{$db_prefix}billing_services` bs + LEFT JOIN `{$db_prefix}config_homes` ch ON ch.home_cfg_id = bs.home_cfg_id + WHERE bs.service_id=" . $db->realEscapeSingle($service_id) + ); billing_provision_trace('Loaded billing service row.', array( 'service_id' => intval($service_id), 'service_row_found' => !empty($service[0]), @@ -717,6 +776,9 @@ function exec_ogp_module() { $home_cfg_id = $service[0]['home_cfg_id']; $mod_cfg_id = $service[0]['mod_cfg_id']; + $selected_config_xml = (string)($service[0]['home_cfg_file'] ?? ''); + $selected_game_key = (string)($service[0]['game_key'] ?? ''); + $selected_service_os = billing_detect_service_os($selected_config_xml, $selected_game_key); //remote_server_id has been stored in IP_ID //$remote_server_id = $service[0]['remote_server_id']; $remote_server_id = $order['ip']; @@ -730,6 +792,8 @@ function exec_ogp_module() 'order_status' => $order['status'] ?? '', 'order_home_id_before_provisioning' => intval($order['home_id'] ?? 0), 'selected_home_cfg_id' => intval($home_cfg_id), + 'selected_config_xml' => $selected_config_xml, + 'selected_service_os' => $selected_service_os, 'selected_remote_server_id' => intval($remote_server_id), )); if (intval($home_cfg_id) <= 0) { @@ -740,6 +804,44 @@ function exec_ogp_module() $order_failed = true; $order_failure_reason = "Invalid remote server selection '{$remote_server_id}' on order #{$order_id} for service_id {$service_id}."; } + if (!$order_failed) { + $allowedRemote = array(); + foreach (explode(',', (string)($service[0]['remote_server_id'] ?? '')) as $part) { + $part = trim($part); + if ($part !== '' && ctype_digit($part)) { + $allowedRemote[(int)$part] = true; + } + } + if (!empty($allowedRemote) && !isset($allowedRemote[intval($remote_server_id)])) { + $order_failed = true; + $order_failure_reason = "Selected remote server #{$remote_server_id} is not enabled for service_id {$service_id}."; + } + } + if (!$order_failed && billing_remote_servers_has_os_column($db, $db_prefix)) { + $remoteRow = $db->resultQuery( + "SELECT remote_server_id, remote_server_name, server_os + FROM `{$db_prefix}remote_servers` + WHERE remote_server_id=" . $db->realEscapeSingle($remote_server_id) . " + LIMIT 1" + ); + if (empty($remoteRow[0])) { + $order_failed = true; + $order_failure_reason = "Remote server #{$remote_server_id} not found for order #{$order_id} (service_id {$service_id})."; + } else { + $node_os = billing_normalize_node_os((string)($remoteRow[0]['server_os'] ?? 'any')); + billing_provision_trace('Resolved remote server OS for compatibility check.', array( + 'selected_remote_server_id' => intval($remote_server_id), + 'selected_node_os' => $node_os, + 'selected_service_os' => $selected_service_os, + )); + if ($selected_service_os !== 'any' && $node_os !== 'any' && $selected_service_os !== $node_os) { + $order_failed = true; + $order_failure_reason = $selected_service_os === 'windows' + ? 'This service requires a Windows server location.' + : 'This service requires a Linux server location.'; + } + } + } } else { @@ -1291,8 +1393,10 @@ function exec_ogp_module() 'order_id' => intval($order_id), 'invoice_id' => intval($provision_invoice_id), 'user_id' => intval($user_id), + 'service_id' => intval($service_id), 'home_id' => intval($home_id), 'home_cfg_id' => intval($home_cfg_id ?? 0), + 'config_xml' => (string)$selected_config_xml, 'mod_id' => intval($selected_mod_id), 'ip_id' => intval($selected_ip_id), 'port' => intval($selected_port), @@ -1307,8 +1411,10 @@ function exec_ogp_module() 'BILLING PROVISION RESULT order_id=' . intval($order_id) . ' invoice_id=' . intval($provision_invoice_id) . ' user_id=' . intval($user_id) + . ' service_id=' . intval($service_id) . ' home_id=' . intval($home_id) . ' home_cfg_id=' . intval($home_cfg_id ?? 0) + . ' config_xml=' . (string)$selected_config_xml . ' mod_id=' . intval($selected_mod_id) . ' ip_id=' . intval($selected_ip_id) . ' port=' . intval($selected_port) diff --git a/modules/billing/order.php b/modules/billing/order.php index e7d15b64..6476801c 100644 --- a/modules/billing/order.php +++ b/modules/billing/order.php @@ -64,9 +64,8 @@ has the "Add to Cart" button. The gameserver selected is passed from the serverlist page by a GET of the service_id. When the user clicks "Add to Cart", the next page is add_to_cart.php. -OS-aware selection: if both a Linux and a Windows variant of the same game exist as separate -billing_services entries, the system automatically detects the selected location's OS (from -remote_servers.server_os) and routes the cart add to the correct service variant. +Each enabled billing service row is listed and purchased as its own exact variant. +The selected service_id remains the source of truth for checkout and provisioning. */ // Require login for ordering @@ -103,35 +102,35 @@ } } -/** - * Derive OS ('linux'|'windows'|'any') from a game_key string. - * Checks for _win / _windows substrings; then _linux; else 'any'. - */ -function order_game_key_os(string $gameKey): string +function order_price_is_free($value): bool +{ + return ((int) round(((float)$value) * 100)) === 0; +} + +function order_detect_service_os(string $cfgFile, string $gameKey): string { - $lk = strtolower($gameKey); - if (str_contains($lk, '_win')) { + $haystack = strtolower(trim($cfgFile !== '' ? $cfgFile : $gameKey)); + if ($haystack === '') { + return 'any'; + } + if (preg_match('/(?:^|[_\\-])(win|windows)(?:[_\\-]|$)/i', $haystack)) { return 'windows'; } - if (str_contains($lk, '_linux')) { + if (preg_match('/(?:^|[_\\-])linux(?:[_\\-]|$)/i', $haystack)) { return 'linux'; } return 'any'; } -function order_price_is_free($value): bool -{ - return ((int) round(((float)$value) * 100)) === 0; -} - -function order_canonical_game_key(string $gameKey): string +function order_variant_label(string $serviceOs): string { - $gameKey = strtolower(trim($gameKey)); - if ($gameKey === '') { - return ''; + if ($serviceOs === 'windows') { + return 'Windows'; + } + if ($serviceOs === 'linux') { + return 'Linux'; } - $canonical = preg_replace('/_(linux|linux32|linux64|win|win32|win64|windows|windows32|windows64)$/i', '', $gameKey); - return $canonical !== '' ? $canonical : $gameKey; + return ''; } // --- Fetch the requested service with config_homes join for canonical game info --- @@ -142,7 +141,7 @@ function order_canonical_game_key(string $gameKey): string $where_service_id = " WHERE bs.enabled = 1"; } -$qry_services = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key +$qry_services = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key, ch.home_cfg_file AS cfg_file FROM {$table_prefix}billing_services bs LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id {$where_service_id} @@ -152,10 +151,10 @@ function order_canonical_game_key(string $gameKey): string if ($services_result === false) { // Fallback: query without join if config_homes doesn't exist in this context $where_service_id_simple = str_replace('bs.', '', $where_service_id); - $qry_services = "SELECT *, NULL AS cfg_game_name, NULL AS cfg_game_key - FROM {$table_prefix}billing_services - {$where_service_id_simple} - ORDER BY service_name"; + $qry_services = "SELECT *, NULL AS cfg_game_name, NULL AS cfg_game_key, NULL AS cfg_file + FROM {$table_prefix}billing_services + {$where_service_id_simple} + ORDER BY service_name"; $services_result = $db->query($qry_services); } @@ -193,6 +192,8 @@ function order_canonical_game_key(string $gameKey): string $osColCheck->free(); } +$order_error_message = isset($_GET['error_message']) ? trim((string)$_GET['error_message']) : ''; + ?>
@@ -227,45 +228,16 @@ function order_canonical_game_key(string $gameKey): string }else // THIS IS THE SERVER WE WANT TO ORDER { -// Determine canonical game name and OS for this service +// Determine exact selected service display and OS label from config metadata. $svcGameKey = (string)($row['cfg_game_key'] ?? ''); -$svcGameOs = order_game_key_os($svcGameKey); +$cfgFile = (string)($row['cfg_file'] ?? ''); +$svcGameOs = order_detect_service_os($cfgFile, $svcGameKey); $canonicalGameName = (string)($row['cfg_game_name'] ?? $row['service_name']); -$canonicalGameKey = order_canonical_game_key($svcGameKey); - -// Build map of OS variant service IDs for JS-based automatic selection. -// Look for sibling services that share the same cfg_game_name (canonical) but differ in OS. -// e.g. if current service is arma3_linux64, find the arma3_win64 service too. -$osServiceMap = []; // ['linux' => service_id, 'windows' => service_id] -if ($svcGameOs !== 'any' && (!empty($canonicalGameName) || !empty($canonicalGameKey))) { -$siblingQuery = "SELECT bs.service_id, ch.game_key AS cfg_game_key, ch.game_name AS cfg_game_name - FROM {$table_prefix}billing_services bs - LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id - WHERE bs.enabled = 1"; -$siblingResult = $db->query($siblingQuery); -if ($siblingResult) { -while ($sib = $siblingResult->fetch_assoc()) { -$sibGameKey = (string)($sib['cfg_game_key'] ?? ''); -$sibCanonical = order_canonical_game_key($sibGameKey); -$sibName = (string)($sib['cfg_game_name'] ?? ''); -if ($canonicalGameKey !== '') { -if ($sibCanonical !== $canonicalGameKey) { -continue; -} -} elseif ($canonicalGameName !== '' && strcasecmp($sibName, $canonicalGameName) !== 0) { -continue; -} -$sibOs = order_game_key_os((string)($sib['cfg_game_key'] ?? '')); -$osServiceMap[$sibOs] = (int)$sib['service_id']; -} -$siblingResult->free(); -} +$variantLabel = order_variant_label($svcGameOs); +$displayName = $canonicalGameName; +if ($variantLabel !== '' && stripos($displayName, $variantLabel) === false) { + $displayName .= ' - ' . $variantLabel; } -// Always include the current service as a fallback -if (!isset($osServiceMap[$svcGameOs]) || $svcGameOs === 'any') { -$osServiceMap[$svcGameOs] = (int)$row['service_id']; -} -$osServiceMapJson = json_encode($osServiceMap, JSON_THROW_ON_ERROR); ?>
@@ -274,9 +246,9 @@ function order_canonical_game_key(string $gameKey): string $imgSrc = billing_image_url((string)($row['img_url'] ?? '')); if ($imgSrc === '') { $imgSrc = '/images/games/default_server.png'; } ?> -<?php echo htmlspecialchars($canonicalGameName, ENT_QUOTES, 'UTF-8'); ?> -
+
+ +

+ - @@ -318,13 +292,9 @@ function order_canonical_game_key(string $gameKey): string diff --git a/modules/billing/serverlist.php b/modules/billing/serverlist.php index 5d5cb688..7978732e 100644 --- a/modules/billing/serverlist.php +++ b/modules/billing/serverlist.php @@ -28,15 +28,19 @@ function billing_service_price_is_free($value): bool return ((int) round(((float)$value) * 100)) === 0; } -function billing_canonical_game_identity(array $row): string +function billing_detect_variant_label(array $row): string { - $gameKey = strtolower(trim((string)($row['cfg_game_key'] ?? ''))); - if ($gameKey !== '') { - $canonicalKey = preg_replace('/_(linux|linux32|linux64|win|win32|win64|windows|windows32|windows64)$/i', '', $gameKey); - return 'key:' . ($canonicalKey !== '' ? $canonicalKey : $gameKey); + $haystack = strtolower(trim((string)($row['cfg_file'] ?? $row['cfg_game_key'] ?? ''))); + if ($haystack === '') { + return ''; } - $gameName = strtolower(trim((string)($row['cfg_game_name'] ?? $row['service_name'] ?? ''))); - return 'name:' . $gameName; + if (preg_match('/(?:^|[_\-])(win|windows)(?:[_\-]|$)/i', $haystack)) { + return 'Windows'; + } + if (preg_match('/(?:^|[_\-])linux(?:[_\-]|$)/i', $haystack)) { + return 'Linux'; + } + return ''; } // Save new description if admin @@ -49,17 +53,16 @@ function billing_canonical_game_identity(array $row): string $stmt->close(); } -// Fetch services, joining config_homes to get canonical game_name and game_key for OS detection. -// LEFT JOIN so services without a linked config_homes entry still appear. +// Fetch enabled services, keeping one row per billing service. $service_id = isset($_REQUEST['service_id']) ? intval($_REQUEST['service_id']) : 0; if ($service_id !== 0) { $where_clause = "WHERE bs.enabled = 1 AND bs.service_id = {$service_id} AND bs.remote_server_id != '' AND bs.remote_server_id IS NOT NULL"; } else { $where_clause = "WHERE bs.enabled = 1 AND bs.remote_server_id != '' AND bs.remote_server_id IS NOT NULL"; } -$qry_services = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key +$qry_services = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key, ch.home_cfg_file AS cfg_file FROM {$table_prefix}billing_services bs - LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id + LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id {$where_clause} ORDER BY bs.service_name"; $result_services = $db->query($qry_services); @@ -70,10 +73,10 @@ function billing_canonical_game_identity(array $row): string $qry_services_fallback = "SELECT service_id, home_cfg_id, enabled, service_name, description, img_url, price_monthly, slot_min_qty, slot_max_qty, remote_server_id, - NULL AS cfg_game_name, NULL AS cfg_game_key + NULL AS cfg_game_name, NULL AS cfg_game_key, NULL AS cfg_file FROM {$table_prefix}billing_services - {$where_clause_fallback} - ORDER BY service_name"; + {$where_clause_fallback} + ORDER BY service_name"; $result_services = $db->query($qry_services_fallback); } @@ -83,25 +86,8 @@ function billing_canonical_game_identity(array $row): string return; } -// Fetch all service rows and deduplicate by canonical game name so that -// arma3_linux64 and arma3_win64 (both named "Arma 3") appear only once. -// When a specific service_id is requested we skip deduplication. $serviceRows = []; -$seenCanonical = []; while ($row = $result_services->fetch_assoc()) { - if ($service_id !== 0) { - // Single-service detail view: always include without deduplication - $serviceRows[] = $row; - continue; - } - // Derive canonical display name: prefer config_homes game_name (consistent across OS - // variants), fall back to service_name. - $canonicalIdentity = billing_canonical_game_identity($row); - if (isset($seenCanonical[$canonicalIdentity])) { - // Already have this game — skip the duplicate OS variant - continue; - } - $seenCanonical[$canonicalIdentity] = true; $serviceRows[] = $row; } $result_services->free(); @@ -126,7 +112,14 @@ function billing_canonical_game_identity(array $row): string ?>
-
+ +
@@ -145,7 +138,14 @@ function billing_canonical_game_identity(array $row): string ?>
-
+ +
Location 1) { -foreach ($osServiceMap as $_os => $sibSvcId) { -if ($sibSvcId === (int)$row['service_id']) continue; -$sibRow = $db->query("SELECT remote_server_id FROM {$table_prefix}billing_services WHERE service_id = " . intval($sibSvcId) . " LIMIT 1"); -if ($sibRow && ($sibData = $sibRow->fetch_assoc())) { -foreach (explode(',', (string)($sibData['remote_server_id'] ?? '')) as $part) { -$part = trim($part); -if ($part !== '' && ctype_digit($part)) { -$allAllowedIds[] = (int)$part; -} -} -$sibRow->free(); -} -} -} $allAllowedIds = array_unique($allAllowedIds); if (!empty($allAllowedIds)) { @@ -365,18 +318,17 @@ function order_canonical_game_key(string $gameKey): string while ($rs = $rsResult->fetch_assoc()) { $rsID = (int)$rs['remote_server_id']; $rsNAME = htmlspecialchars((string)$rs['remote_server_name'], ENT_QUOTES, 'UTF-8'); -$rsOs = (string)($rs['server_os'] ?? 'any'); +$rsOsRaw = strtolower((string)($rs['server_os'] ?? 'any')); +$rsOs = str_starts_with($rsOsRaw, 'win') ? 'windows' : (str_starts_with($rsOsRaw, 'lin') ? 'linux' : ($rsOsRaw === '' ? 'any' : $rsOsRaw)); $checked = $firstServer ? ' checked' : ''; -// Skip this location if we know the service is OS-specific and the -// node OS is incompatible AND no sibling service covers this OS. -if ($svcGameOs !== 'any' && $rsOs !== 'any' && $rsOs !== $svcGameOs && !isset($osServiceMap[$rsOs])) { -continue; // Incompatible OS variant with no fallback service +if ($svcGameOs !== 'any' && $rsOs !== 'any' && $rsOs !== $svcGameOs) { +continue; } $available_server = true; $firstServer = false; $safeOs = htmlspecialchars($rsOs, ENT_QUOTES, 'UTF-8'); echo "
\n" - . " \n" + . " \n" . " \n" . "
\n"; } @@ -410,9 +362,6 @@ function order_canonical_game_key(string $gameKey): string var invoiceDuration = document.getElementById("invoiceDuration"); var pricePerSlot = ; -// OS-aware service variant map: {os: service_id} -var osServiceMap = ; - function recalc() { var slots = parseInt(slider.value, 10); var months = parseInt(invoiceslider.value, 10); @@ -428,24 +377,6 @@ function recalc() { recalc(); slider.oninput = recalc; invoiceslider.oninput = recalc; - -// Update the hidden service_id based on the selected location's OS. -window.gspUpdateServiceId = function(radio) { -var os = radio.getAttribute('data-os') || 'any'; -var svcInput = document.getElementById('order_service_id'); -if (!svcInput) return; -// Pick the service for this OS, fall back to 'any', then first available -if (osServiceMap[os] !== undefined) { -svcInput.value = osServiceMap[os]; -} else if (osServiceMap['any'] !== undefined) { -svcInput.value = osServiceMap['any']; -} -// else keep the current value -}; - -// Trigger on page load for the pre-checked radio -var checked = document.querySelector('input[name="ip_id"]:checked'); -if (checked) { window.gspUpdateServiceId(checked); } })(); @@ -466,7 +397,17 @@ function recalc() { -

No available server locations for this game.

+

+ +