Rollback OS auto-switching and enforce exact service provisioning

Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/3df8435d-6b14-494a-b81e-ca5ca02f3332

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-09 16:08:32 +00:00 committed by GitHub
parent 8537c4f0f7
commit 87678609ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 317 additions and 165 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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 @@ $service_name = '';
$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 @@ if ($service_id > 0) {
}
}
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) {

View file

@ -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 {

View file

@ -229,9 +229,13 @@ if (!function_exists('billing_detect_install_state')) {
$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 @@ if (!function_exists('billing_agent_offline_reason')) {
}
}
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)

View file

@ -64,9 +64,8 @@ This is the "order gameserver" page. It displays the options for a single specif
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 @@ if (isset($_POST['save']) && !empty($_POST['description'])) {
}
}
/**
* 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
{
$lk = strtolower($gameKey);
if (str_contains($lk, '_win')) {
return 'windows';
}
if (str_contains($lk, '_linux')) {
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_detect_service_os(string $cfgFile, string $gameKey): string
{
$gameKey = strtolower(trim($gameKey));
if ($gameKey === '') {
return '';
$haystack = strtolower(trim($cfgFile !== '' ? $cfgFile : $gameKey));
if ($haystack === '') {
return 'any';
}
$canonical = preg_replace('/_(linux|linux32|linux64|win|win32|win64|windows|windows32|windows64)$/i', '', $gameKey);
return $canonical !== '' ? $canonical : $gameKey;
if (preg_match('/(?:^|[_\\-])(win|windows)(?:[_\\-]|$)/i', $haystack)) {
return 'windows';
}
if (preg_match('/(?:^|[_\\-])linux(?:[_\\-]|$)/i', $haystack)) {
return 'linux';
}
return 'any';
}
function order_variant_label(string $serviceOs): string
{
if ($serviceOs === 'windows') {
return 'Windows';
}
if ($serviceOs === 'linux') {
return 'Linux';
}
return '';
}
// --- Fetch the requested service with config_homes join for canonical game info ---
@ -142,7 +141,7 @@ if ($req_service_id !== 0) {
$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 @@ $services_result = $db->query($qry_services);
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 @@ if ($osColCheck && $osColCheck->num_rows > 0) {
$osColCheck->free();
}
$order_error_message = isset($_GET['error_message']) ? trim((string)$_GET['error_message']) : '';
?>
<div class="order-shell">
<div class="clearfix">
@ -227,45 +228,16 @@ echo "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly";
}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;
$variantLabel = order_variant_label($svcGameOs);
$displayName = $canonicalGameName;
if ($variantLabel !== '' && stripos($displayName, $variantLabel) === false) {
$displayName .= ' - ' . $variantLabel;
}
} 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();
}
}
// 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);
?>
<div class="order-layout">
@ -274,9 +246,9 @@ $osServiceMapJson = json_encode($osServiceMap, JSON_THROW_ON_ERROR);
$imgSrc = billing_image_url((string)($row['img_url'] ?? ''));
if ($imgSrc === '') { $imgSrc = '/images/games/default_server.png'; }
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" alt="<?php echo htmlspecialchars($canonicalGameName, ENT_QUOTES, 'UTF-8'); ?>"
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" alt="<?php echo htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8'); ?>"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;">
<center class="order-media-title"><b><?php echo htmlspecialchars($canonicalGameName, ENT_QUOTES, 'UTF-8'); ?></b></center>
<center class="order-media-title"><b><?php echo htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8'); ?></b></center>
<?php
$isAdmin = false;
if ($isAdmin) {
@ -300,9 +272,11 @@ echo "<p class='order-media-desc'>" . htmlspecialchars((string)($row['descriptio
?>
</div>
<div class="order-form-card">
<?php if ($order_error_message !== ''): ?>
<p class="error"><?php echo htmlspecialchars($order_error_message, ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>
<table class="order-form-table">
<form method="post" action="add_to_cart.php">
<!-- service_id is updated by JS when the location OS changes -->
<input type="hidden" id="order_service_id" name="service_id" value="<?php echo intval($row['service_id']); ?>">
<input type="hidden" name="display_service_id" value="<?php echo intval($row['service_id']); ?>">
<input type="hidden" name="remote_control_password" value="">
@ -318,13 +292,9 @@ echo "<p class='order-media-desc'>" . htmlspecialchars((string)($row['descriptio
<td align="right"><b>Location</b></td>
<td align="left">
<?php
// Fetch servers available for this game from billing_services.remote_server_id
// (a comma-separated list of numeric remote server IDs, e.g. "1,3,7").
// When OS-aware: also collect sibling service's allowed IDs to show all compatible locations.
// Fetch servers available for this exact selected service from billing_services.remote_server_id.
$available_server = false;
$remoteIdsCsv = (string)($row['remote_server_id'] ?? '');
// Also gather allowed IDs from sibling OS-variant services
$allAllowedIds = [];
foreach (explode(',', $remoteIdsCsv) as $part) {
$part = trim($part);
@ -332,23 +302,6 @@ if ($part !== '' && ctype_digit($part)) {
$allAllowedIds[] = (int)$part;
}
}
// Add IDs from sibling service variants so locations appear regardless of which
// service variant is the "primary" one shown to the user.
if (count($osServiceMap) > 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 @@ $firstServer = true;
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 "<div class='location-option'>\n"
. " <input type='radio' name='ip_id' id='rs_{$rsID}' value='{$rsID}' data-os='{$safeOs}' required{$checked} onchange='gspUpdateServiceId(this)'>\n"
. " <input type='radio' name='ip_id' id='rs_{$rsID}' value='{$rsID}' data-os='{$safeOs}' required{$checked}>\n"
. " <label for='rs_{$rsID}'>{$rsNAME}</label>\n"
. "</div>\n";
}
@ -410,9 +362,6 @@ var price = document.getElementById("totalPrice");
var invoiceDuration = document.getElementById("invoiceDuration");
var pricePerSlot = <?php echo number_format(floatval($row['price_monthly']), 2, '.', ''); ?>;
// OS-aware service variant map: {os: service_id}
var osServiceMap = <?php echo $osServiceMapJson; ?>;
function recalc() {
var slots = parseInt(slider.value, 10);
var months = parseInt(invoiceslider.value, 10);
@ -428,24 +377,6 @@ totalInput.value = total;
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); }
})();
</script>
@ -466,7 +397,17 @@ $is_logged_in = (isset($_SESSION['website_user_id']) && !empty($_SESSION['websit
<?php elseif (!$is_logged_in): ?>
<div class="login-placeholder">Please <a href="login.php">login</a> to order</div>
<?php else: ?>
<p class="error">No available server locations for this game.</p>
<p class="error">
<?php
if ($svcGameOs === 'windows') {
echo 'This service requires a Windows server location.';
} elseif ($svcGameOs === 'linux') {
echo 'This service requires a Linux server location.';
} else {
echo 'No available server locations for this service.';
}
?>
</p>
<?php endif; ?>
</form>
</td>

View file

@ -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 @@ if (isset($_POST['save']) && !empty($_POST['description'])) {
$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 @@ if (!$result_services) {
$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 @@ if (!$result_services) {
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 @@ include(__DIR__ . '/includes/menu.php');
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="460" height="225"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;"><br>
<strong><?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?></strong><br>
<?php
$serviceDisplayName = (string)($row['cfg_game_name'] ?? $row['service_name']);
$variantLabel = billing_detect_variant_label($row);
if ($variantLabel !== '' && stripos($serviceDisplayName, $variantLabel) === false) {
$serviceDisplayName .= ' - ' . $variantLabel;
}
?>
<strong><?php echo htmlspecialchars($serviceDisplayName, ENT_QUOTES, 'UTF-8'); ?></strong><br>
<?php
echo billing_service_price_is_free($row['price_monthly'] ?? 0) ? "FREE" : "$" . number_format((float)$row['price_monthly'], 2) . " Monthly";
?>
@ -145,7 +138,14 @@ include(__DIR__ . '/includes/menu.php');
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="230" height="112"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;"><br>
<center><b><?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?></b></center>
<?php
$detailDisplayName = (string)($row['cfg_game_name'] ?? $row['service_name']);
$detailVariantLabel = billing_detect_variant_label($row);
if ($detailVariantLabel !== '' && stripos($detailDisplayName, $detailVariantLabel) === false) {
$detailDisplayName .= ' - ' . $detailVariantLabel;
}
?>
<center><b><?php echo htmlspecialchars($detailDisplayName, ENT_QUOTES, 'UTF-8'); ?></b></center>
<?php
$isAdmin = false;

View file

@ -1 +1 @@
Last Updated at 2:43pm on 2026-05-09
Last Updated at 4:03pm on 2026-05-09

View file

@ -93,7 +93,16 @@ function exec_ogp_module()
{
// Force log file contents to be UTF-8 (fixes http://www.opengamepanel.org/forum/viewthread.php?thread_id=5379)
if(hasValue($home_log)){
$home_log = utf8_encode($home_log);
if (function_exists('mb_check_encoding') && function_exists('mb_convert_encoding')) {
if (!mb_check_encoding($home_log, 'UTF-8')) {
$home_log = mb_convert_encoding($home_log, 'UTF-8', 'ISO-8859-1');
}
} elseif (function_exists('iconv')) {
$converted = @iconv('ISO-8859-1', 'UTF-8//IGNORE', $home_log);
if ($converted !== false) {
$home_log = $converted;
}
}
}
// Using the refreshed class