fix: auto-provision port/mod assignment, error logging, retry UI, GSP wording

Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/98539de7-36c5-4a0e-962e-e30f5e4c9125

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-07 16:31:10 +00:00 committed by GitHub
parent e0b843897d
commit 8f8a2a4c06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 974 additions and 27 deletions

View file

@ -0,0 +1,209 @@
<?php
/**
* Admin: Game Mod/Build Defaults for Billing Auto-Provisioning
*
* Allows admins to:
* - See available mods/builds per game config (config_mods table)
* - Mark exactly one mod/build per game as the billing auto-install default
* (is_default_for_billing = 1)
* - Override the per-service mod_cfg_id in billing_services
*
* A safe migration for is_default_for_billing is included in billing module.php
* db_version 8. This page gracefully handles the column being absent.
*/
function exec_ogp_module()
{
global $db, $view, $table_prefix;
$db_prefix = isset($table_prefix) ? $table_prefix : '';
$user_id = $_SESSION['user_id'];
$isAdmin = $db->isAdmin($user_id);
if (!$isAdmin) {
echo "<div class='failure'><p>Access Denied: Admin privileges required.</p></div>";
return;
}
// -------------------------------------------------------------------
// Check whether is_default_for_billing column exists.
// It is added by db_version 8 migration; show a warning if missing.
// -------------------------------------------------------------------
$colExists = false;
$colCheck = $db->resultQuery(
"SELECT COUNT(*) AS cnt
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$db_prefix}config_mods'
AND COLUMN_NAME = 'is_default_for_billing'"
);
if (!empty($colCheck[0]['cnt']) && intval($colCheck[0]['cnt']) > 0) {
$colExists = true;
}
if (!$colExists) {
echo "<div class='failure'>"
. "<p><strong>Database migration required.</strong> "
. "The <code>is_default_for_billing</code> column is not present in <code>{$db_prefix}config_mods</code>. "
. "Please run the billing module update (Admin &rarr; Module Manager &rarr; Update) to apply db_version&nbsp;8.</p>"
. "</div>";
return;
}
// -------------------------------------------------------------------
// Handle POST: save default mod for a game (home_cfg_id)
// -------------------------------------------------------------------
$saveMsg = '';
if (isset($_POST['save_default']) && isset($_POST['home_cfg_id'])) {
$save_home_cfg_id = intval($_POST['home_cfg_id']);
$save_mod_cfg_id = intval($_POST['mod_cfg_id'] ?? 0);
// Clear all current defaults for this game first
$db->query(
"UPDATE `{$db_prefix}config_mods`
SET is_default_for_billing = 0
WHERE home_cfg_id = " . $save_home_cfg_id
);
if ($save_mod_cfg_id > 0) {
// Set the selected mod as default (only if it belongs to this game)
$updated = $db->query(
"UPDATE `{$db_prefix}config_mods`
SET is_default_for_billing = 1
WHERE mod_cfg_id = " . $save_mod_cfg_id . "
AND home_cfg_id = " . $save_home_cfg_id
);
$saveMsg = $updated
? "<div class='success'><p>Default mod/build updated for game config #{$save_home_cfg_id}.</p></div>"
: "<div class='failure'><p>Failed to update default — mod may not belong to this game.</p></div>";
} else {
$saveMsg = "<div class='info'><p>Default cleared for game config #{$save_home_cfg_id}. Billing will use the service-specific mod or fail with an admin-visible error if none is set.</p></div>";
}
}
echo $saveMsg;
echo "<h2>Game Mod/Build Defaults for Billing</h2>";
echo "<p>Mark one mod/build per game as the auto-install default used when billing provisions a new server. "
. "This is used when a billing service does not specify its own mod (mod_cfg_id&nbsp;=&nbsp;0).</p>";
echo "<p><strong>Priority order during provisioning:</strong> "
. "1) Service-specific mod_cfg_id &rarr; 2) is_default_for_billing here &rarr; "
. "3) Single available mod (auto-selected) &rarr; 4) Fail with admin-visible error.</p>";
// -------------------------------------------------------------------
// Load all game configs that have at least one mod defined
// -------------------------------------------------------------------
$games = $db->resultQuery(
"SELECT ch.home_cfg_id, ch.home_name
FROM `{$db_prefix}config_homes` ch
WHERE EXISTS (
SELECT 1 FROM `{$db_prefix}config_mods` cm
WHERE cm.home_cfg_id = ch.home_cfg_id
)
ORDER BY ch.home_name ASC"
);
if (empty($games)) {
echo "<div class='info'><p>No game configurations with mods found. Add mods via Admin &rarr; Game Manager &rarr; Configure Games.</p></div>";
return;
}
echo "<table class='tablesorter' style='width:100%;'>";
echo "<thead><tr><th>Game</th><th>home_cfg_id</th><th>Available Mods/Builds</th><th>Current Default</th><th>Action</th></tr></thead><tbody>";
foreach ((array)$games as $game) {
$hcfgid = intval($game['home_cfg_id']);
$gameName = htmlspecialchars($game['home_name'] ?? "Game #{$hcfgid}");
// Load mods for this game
$mods = $db->resultQuery(
"SELECT mod_cfg_id, mod_key, mod_name, is_default_for_billing
FROM `{$db_prefix}config_mods`
WHERE home_cfg_id = " . $hcfgid . "
ORDER BY mod_name ASC"
);
if (empty($mods)) {
continue;
}
$currentDefault = null;
foreach ($mods as $m) {
if (!empty($m['is_default_for_billing'])) {
$currentDefault = htmlspecialchars($m['mod_name'] . ' (mod_cfg_id=' . $m['mod_cfg_id'] . ')');
}
}
echo "<tr>";
echo "<td><strong>{$gameName}</strong></td>";
echo "<td>{$hcfgid}</td>";
echo "<td>";
$modNames = array_map(fn($m) => htmlspecialchars($m['mod_name']), $mods);
echo implode('<br>', $modNames);
echo "</td>";
echo "<td>";
echo $currentDefault
? "<span style='color:green;'>&#10003; " . $currentDefault . "</span>"
: "<span style='color:#999;'>None</span>";
echo "</td>";
echo "<td>";
// Form to set default
echo "<form method='post' action='home.php?m=billing&p=admin_game_defaults' style='white-space:nowrap;'>";
echo "<input type='hidden' name='save_default' value='1'>";
echo "<input type='hidden' name='home_cfg_id' value='{$hcfgid}'>";
echo "<select name='mod_cfg_id'>";
echo "<option value='0'>(No default / clear)</option>";
foreach ($mods as $m) {
$sel = !empty($m['is_default_for_billing']) ? " selected" : "";
echo "<option value='" . intval($m['mod_cfg_id']) . "'{$sel}>"
. htmlspecialchars($m['mod_name'])
. " [" . htmlspecialchars($m['mod_key']) . "]"
. "</option>";
}
echo "</select> ";
echo "<button type='submit' class='btn btn-sm'>Save</button>";
echo "</form>";
echo "</td>";
echo "</tr>";
}
echo "</tbody></table>";
// -------------------------------------------------------------------
// Show billing_services with their current mod_cfg_id
// -------------------------------------------------------------------
echo "<div style='margin-top:30px;'>";
echo "<h3>Billing Services — Mod/Build Override</h3>";
echo "<p>Services below have an explicit <code>mod_cfg_id</code> set. This takes priority over the game default above. "
. "Set to 0 to fall back to the game default.</p>";
$services = $db->resultQuery(
"SELECT s.service_id, s.service_name, s.home_cfg_id, s.mod_cfg_id,
ch.home_name AS game_name,
cm.mod_name
FROM `{$db_prefix}billing_services` s
LEFT JOIN `{$db_prefix}config_homes` ch ON ch.home_cfg_id = s.home_cfg_id
LEFT JOIN `{$db_prefix}config_mods` cm ON cm.mod_cfg_id = s.mod_cfg_id
ORDER BY s.service_name ASC"
);
if (empty($services)) {
echo "<p style='color:#999;'>No billing services configured.</p>";
} else {
echo "<table class='tablesorter' style='width:100%;'>";
echo "<thead><tr><th>Service</th><th>Game</th><th>Current mod_cfg_id</th><th>Mod Name</th></tr></thead><tbody>";
foreach ((array)$services as $svc) {
echo "<tr>";
echo "<td>".htmlspecialchars($svc['service_name'] ?? '')." (#".intval($svc['service_id']).")</td>";
echo "<td>".htmlspecialchars($svc['game_name'] ?? 'N/A')."</td>";
echo "<td>".intval($svc['mod_cfg_id']).(intval($svc['mod_cfg_id']) === 0 ? " <em>(use game default)</em>" : "")."</td>";
echo "<td>".htmlspecialchars($svc['mod_name'] ?? ($svc['mod_cfg_id'] == 0 ? '—' : 'mod not found'))."</td>";
echo "</tr>";
}
echo "</tbody></table>";
echo "<p><small>To change a service's mod, edit it in Admin &rarr; Billing &rarr; Services.</small></p>";
}
echo "</div>";
}
?>

View file

@ -6,7 +6,8 @@
function exec_ogp_module()
{
global $db, $view;
global $db, $view, $table_prefix;
$db_prefix = isset($table_prefix) ? $table_prefix : '';
$user_id = $_SESSION['user_id'];
$isAdmin = $db->isAdmin($user_id);
@ -14,7 +15,28 @@ function exec_ogp_module()
echo "<div class='failure'><p>Access Denied: Admin privileges required.</p></div>";
return;
}
// -------------------------------------------------------------------
// Handle "Retry Provisioning" for a single order
// -------------------------------------------------------------------
if (isset($_POST['retry_provision_order']) && !empty($_POST['retry_order_id'])) {
$retry_id = intval($_POST['retry_order_id']);
require_once __DIR__ . '/create_servers.php';
$retryResult = billing_invoke_provision([
'order_ids' => [$retry_id],
'user_id' => $user_id,
'is_admin' => true,
]);
if (!empty($retryResult['provisioned_count'])) {
echo "<div class='success'><p>Retry provisioning succeeded for order #{$retry_id}.</p></div>";
} elseif (!empty($retryResult['output'])) {
echo "<div class='failure'><p>Retry provisioning for order #{$retry_id} did not succeed. See details below.</p>"
. "<pre>" . htmlspecialchars($retryResult['output']) . "</pre></div>";
} else {
echo "<div class='failure'><p>Retry provisioning for order #{$retry_id}: no result returned. Check server logs.</p></div>";
}
}
// Handle bulk actions
if (isset($_POST['bulk_action']) && isset($_POST['selected_orders'])) {
$action = $_POST['bulk_action'];
@ -30,13 +52,13 @@ function exec_ogp_module()
exit;
break;
case 'expire':
$db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='Expired' WHERE order_id=".$order_id);
$db->query("UPDATE `{$db_prefix}billing_orders` SET status='Expired' WHERE order_id=".$order_id);
break;
case 'activate':
$db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='Active' WHERE order_id=".$order_id);
$db->query("UPDATE `{$db_prefix}billing_orders` SET status='Active' WHERE order_id=".$order_id);
break;
case 'invoice':
$db->query("UPDATE OGP_DB_PREFIXbilling_orders SET status='Invoiced' WHERE order_id=".$order_id);
$db->query("UPDATE `{$db_prefix}billing_orders` SET status='Invoiced' WHERE order_id=".$order_id);
break;
}
}
@ -66,9 +88,9 @@ function exec_ogp_module()
// Build query
$query = "SELECT o.*, s.service_name, u.users_login, u.users_email
FROM OGP_DB_PREFIXbilling_orders o
LEFT JOIN OGP_DB_PREFIXbilling_services s ON o.service_id = s.service_id
LEFT JOIN OGP_DB_PREFIXusers u ON o.user_id = u.user_id
FROM `{$db_prefix}billing_orders` o
LEFT JOIN `{$db_prefix}billing_services` s ON o.service_id = s.service_id
LEFT JOIN `{$db_prefix}users` u ON o.user_id = u.user_id
WHERE 1=1";
if ($status_filter != 'all') {
@ -91,6 +113,17 @@ function exec_ogp_module()
echo "<div class='info'><p>No orders found matching your filters.</p></div>";
return;
}
// Pre-fetch provisioning error counts per order for display
$errorCounts = [];
$errCountRows = $db->resultQuery(
"SELECT billing_order_id, COUNT(*) AS cnt
FROM `{$db_prefix}billing_provisioning_errors`
GROUP BY billing_order_id"
);
foreach ((array)$errCountRows as $ecr) {
$errorCounts[intval($ecr['billing_order_id'])] = intval($ecr['cnt']);
}
echo "<form method='post' action='home.php?m=billing&p=admin_orders'>";
echo "<div style='margin-bottom: 10px;'>";
@ -130,39 +163,83 @@ function exec_ogp_module()
case 'Expired': $status_class = 'label-danger'; break;
default: $status_class = 'label-info';
}
$oid = intval($order['order_id']);
$errCount = $errorCounts[$oid] ?? 0;
echo "<tr>";
echo "<td><input type='checkbox' name='selected_orders[]' value='".$order['order_id']."'></td>";
echo "<td>".$order['order_id']."</td>";
echo "<td>".$order['users_login']."<br><small>".$order['users_email']."</small></td>";
echo "<td>".$order['home_name']."</td>";
echo "<td>".$order['service_name']."</td>";
echo "<td><input type='checkbox' name='selected_orders[]' value='".$oid."'></td>";
echo "<td>".$oid."</td>";
echo "<td>".htmlspecialchars($order['users_login'] ?? '')."<br><small>".htmlspecialchars($order['users_email'] ?? '')."</small></td>";
echo "<td>".htmlspecialchars($order['home_name'] ?? '')."</td>";
echo "<td>".htmlspecialchars($order['service_name'] ?? '')."</td>";
echo "<td>".$order['max_players']."</td>";
echo "<td>$".number_format($order['price'], 2)."</td>";
echo "<td>".$order['qty']." ".$order['invoice_duration']."(s)</td>";
echo "<td><span class='label ".$status_class."'>".$order['status']."</span></td>";
echo "<td><span class='label ".$status_class."'>".$order['status']."</span>";
if ($errCount > 0) {
echo " <span class='label label-warning' title='Provisioning errors'>" . $errCount . " error(s)</span>";
}
echo "</td>";
echo "<td>".date('Y-m-d H:i', strtotime($order['order_date']))."</td>";
echo "<td>".($order['end_date'] ? date('Y-m-d', strtotime($order['end_date'])) : 'N/A')."</td>";
echo "<td>".($order['home_id'] ? $order['home_id'] : 'N/A')."</td>";
echo "<td>";
if ($order['status'] == 'Active' && !$order['home_id']) {
echo "<a href='home.php?m=billing&p=provision_servers&order_id=".$order['order_id']."' class='btn btn-sm'>Provision</a> ";
echo "<a href='home.php?m=billing&p=provision_servers&order_id=".$oid."' class='btn btn-sm'>Provision</a> ";
// Retry provisioning button (inline POST form)
echo "<form method='post' action='home.php?m=billing&p=admin_orders' style='display:inline;'>";
echo "<input type='hidden' name='retry_provision_order' value='1'>";
echo "<input type='hidden' name='retry_order_id' value='".$oid."'>";
echo "<button type='submit' class='btn btn-sm btn-warning'>Retry Provisioning</button>";
echo "</form> ";
}
if ($order['status'] == 'Active' && $order['home_id']) {
echo "<a href='home.php?m=gamemanager&p=game_monitor&home_id-mod_id-ip=".$order['home_id']."' class='btn btn-sm'>View Server</a> ";
}
echo "<a href='#' onclick='viewOrder(".$order['order_id'].")' class='btn btn-sm'>Details</a>";
if ($errCount > 0) {
echo "<a href='#' onclick='toggleErrors(".$oid.")' class='btn btn-sm btn-danger'>Errors</a> ";
}
echo "<a href='#' onclick='viewOrder(".$oid.")' class='btn btn-sm'>Details</a>";
echo "</td>";
echo "</tr>";
// Collapsible provisioning error rows
if ($errCount > 0) {
echo "<tr id='errors_".$oid."' style='display:none;background:#fff8f8;'>";
echo "<td colspan='13'>";
$errRows = $db->resultQuery(
"SELECT * FROM `{$db_prefix}billing_provisioning_errors`
WHERE billing_order_id=" . $oid . "
ORDER BY created_at DESC LIMIT 20"
);
if (!empty($errRows)) {
echo "<table style='width:100%;font-size:0.9em;'>";
echo "<thead><tr><th>Time</th><th>Remote Srv</th><th>IP ID</th><th>Port</th><th>Mod</th><th>Message</th></tr></thead><tbody>";
foreach ($errRows as $er) {
echo "<tr>";
echo "<td>".htmlspecialchars($er['created_at'])."</td>";
echo "<td>".intval($er['remote_server_id'])."</td>";
echo "<td>".intval($er['ip_id'])."</td>";
echo "<td>".intval($er['attempted_port'])."</td>";
echo "<td>".intval($er['mod_cfg_id'])."</td>";
echo "<td>".htmlspecialchars($er['failure_message'])."</td>";
echo "</tr>";
}
echo "</tbody></table>";
}
echo "</td></tr>";
}
}
echo "</tbody></table>";
echo "</form>";
// JavaScript for checkbox toggle
// JavaScript for checkbox toggle and error panel
echo "<script>
function toggleAll(checkbox) {
var checkboxes = document.getElementsByName('selected_orders[]');
@ -170,6 +247,14 @@ function exec_ogp_module()
checkboxes[i].checked = checkbox.checked;
}
}
function toggleErrors(orderId) {
var row = document.getElementById('errors_' + orderId);
if (row) {
row.style.display = (row.style.display === 'none') ? 'table-row' : 'none';
}
return false;
}
function viewOrder(orderId) {
alert('Order details for #' + orderId + '\\n\\nFull order details feature coming soon.');
@ -178,9 +263,11 @@ function exec_ogp_module()
</script>";
// Summary stats
$stats = $db->resultQuery("SELECT status, COUNT(*) as count, SUM(price) as total
FROM OGP_DB_PREFIXbilling_orders
GROUP BY status");
$stats = $db->resultQuery(
"SELECT status, COUNT(*) as count, SUM(price) as total
FROM `{$db_prefix}billing_orders`
GROUP BY status"
);
echo "<div style='margin-top: 30px;'>";
echo "<h3>Order Statistics</h3>";
@ -204,8 +291,8 @@ function exec_ogp_module()
// are the reason the game monitor may show "No expiration date found".
$orphans = $db->resultQuery(
"SELECT o.order_id, o.user_id, o.home_name, o.home_id, o.status, o.end_date
FROM OGP_DB_PREFIXbilling_orders o
LEFT JOIN OGP_DB_PREFIXserver_homes sh ON sh.home_id = o.home_id
FROM `{$db_prefix}billing_orders` o
LEFT JOIN `{$db_prefix}server_homes` sh ON sh.home_id = o.home_id
WHERE o.home_id != '0'
AND o.home_id != ''
AND sh.home_id IS NULL
@ -214,9 +301,9 @@ function exec_ogp_module()
echo "<div style='margin-top: 30px;'>";
echo "<h3>Orphaned home_id Diagnostics</h3>";
echo "<p style='color:#666;'>Billing orders that reference a <code>home_id</code> which no longer exists in <code>gsp_server_homes</code>. ";
echo "<p style='color:#666;'>Billing orders that reference a <code>home_id</code> which no longer exists in <code>server_homes</code>. ";
echo "These orders will not show an expiration date on the game monitor. ";
echo "Reset <code>home_id</code> to <code>0</code> or re-provision these orders to fix them. ";
echo "Reset <code>home_id</code> to <code>0</code> and use the Retry Provisioning button to re-provision them. ";
echo "Run <code>normalize_billing_order_status.sql</code> to standardize any legacy status values.</p>";
if (empty($orphans)) {
@ -237,5 +324,39 @@ function exec_ogp_module()
echo "</tbody></table>";
}
echo "</div>";
// Recent provisioning errors (all orders) ————————————————————————————
$recentErrors = $db->resultQuery(
"SELECT e.*, o.home_name, u.users_login
FROM `{$db_prefix}billing_provisioning_errors` e
LEFT JOIN `{$db_prefix}billing_orders` o ON o.order_id = e.billing_order_id
LEFT JOIN `{$db_prefix}users` u ON u.user_id = e.user_id
ORDER BY e.created_at DESC
LIMIT 50"
);
echo "<div style='margin-top: 30px;'>";
echo "<h3>Recent Provisioning Errors</h3>";
if (empty($recentErrors)) {
echo "<p style='color:green;'>&#10003; No provisioning errors recorded.</p>";
} else {
echo "<table class='tablesorter' style='width:100%;'>";
echo "<thead><tr><th>Time</th><th>Order ID</th><th>User</th><th>Server Name</th><th>Remote Srv</th><th>IP ID</th><th>Port</th><th>Mod</th><th>Message</th></tr></thead><tbody>";
foreach ($recentErrors as $er) {
echo "<tr>";
echo "<td>".htmlspecialchars($er['created_at'])."</td>";
echo "<td>".intval($er['billing_order_id'])."</td>";
echo "<td>".htmlspecialchars($er['users_login'] ?? ('uid:'.intval($er['user_id'])))."</td>";
echo "<td>".htmlspecialchars($er['home_name'] ?? '')."</td>";
echo "<td>".intval($er['remote_server_id'])."</td>";
echo "<td>".intval($er['ip_id'])."</td>";
echo "<td>".intval($er['attempted_port'])."</td>";
echo "<td>".intval($er['mod_cfg_id'])."</td>";
echo "<td>".htmlspecialchars($er['failure_message'])."</td>";
echo "</tr>";
}
echo "</tbody></table>";
}
echo "</div>";
}
?>

View file

@ -27,6 +27,584 @@ if (!function_exists('billing_invoke_provision')) {
}
}
/**
* Log a provisioning failure to billing_provisioning_errors.
* All parameters are sanitised inside this function.
*/
if (!function_exists('billing_log_provision_error')) {
function billing_log_provision_error(
$db,
string $db_prefix,
int $billing_order_id,
int $home_id,
int $user_id,
int $remote_server_id,
int $ip_id,
int $attempted_port,
int $mod_cfg_id,
string $failure_message
): void {
try {
$db->query(
"INSERT INTO `{$db_prefix}billing_provisioning_errors`
(`billing_order_id`,`home_id`,`user_id`,`remote_server_id`,`ip_id`,`attempted_port`,`mod_cfg_id`,`failure_message`,`created_at`)
VALUES ("
. intval($billing_order_id) . ","
. intval($home_id) . ","
. intval($user_id) . ","
. intval($remote_server_id) . ","
. intval($ip_id) . ","
. intval($attempted_port) . ","
. intval($mod_cfg_id) . ","
. "'" . $db->realEscapeSingle($failure_message) . "',"
. "NOW())"
);
} catch (Throwable $e) {
// Never let logging itself break provisioning
error_log('billing_log_provision_error: ' . $e->getMessage());
}
}
}
function exec_ogp_module()
{
global $db,$view,$settings,$table_prefix;
$db_prefix = isset($table_prefix) ? $table_prefix : '';
// $now is used in multiple branches below — define it once here so it is
// always a string that date() / strtotime() can handle safely (PHP 8 fix).
$now = date('Y-m-d H:i:s');
$override = isset($GLOBALS['BILLING_PROVISION_OVERRIDE']) ? $GLOBALS['BILLING_PROVISION_OVERRIDE'] : null;
$user_id = isset($override['user_id']) ? intval($override['user_id']) : (isset($_SESSION['user_id']) ? intval($_SESSION['user_id']) : 0);
$isAdmin = isset($override['is_admin']) ? (bool)$override['is_admin'] : $db->isAdmin($user_id);
$provision_all = $override ? !empty($override['provision_all']) : isset($_POST['provision_all']);
$orderIds = array();
if ($override && !empty($override['order_ids'])) {
$orderIds = array_map('intval', (array)$override['order_ids']);
}
if (empty($orderIds)) {
$order_id = null;
if (isset($_POST['order_id'])) {
$order_id = $_POST['order_id'];
}
if(isset($_GET['order_id'])){
$order_id = $_GET['order_id'];
}
if (!empty($order_id)) {
$orderIds = array(intval($order_id));
}
}
// Handle provision_all request - provision all Active (paid) orders for this user
if ($provision_all) {
if ( $isAdmin ){
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_orders` WHERE status='Active' AND (home_id='0' OR home_id='') ORDER BY order_id" );
} else {
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_orders` WHERE user_id=".$db->realEscapeSingle($user_id)." AND status='Active' AND (home_id='0' OR home_id='') ORDER BY order_id" );
}
}
// Handle provision_single or order_id parameter - provision specific order
else {
if (empty($orderIds)) {
echo "<div class='failure'>No order ID specified.</div>";
$GLOBALS['BILLING_PROVISION_LAST_RESULT'] = array('provisioned_count'=>0,'failed_count'=>0,'orders'=>array());
return;
}
$idList = implode(',', array_map('intval', $orderIds));
if ( $isAdmin ){
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_orders` WHERE order_id IN ($idList) AND status='Active'" );
} else {
$orders = $db->resultQuery( "SELECT * FROM `{$db_prefix}billing_orders` WHERE order_id IN ($idList) AND user_id=".$db->realEscapeSingle($user_id)." AND status='Active'" );
}
}
$processed_orders = array();
if( !empty($orders) )
{
$provisioned_count = 0;
$failed_count = 0;
foreach ((array)$orders as $order)
{
$end_date = null;
$end_date_str = null;
$order_id = $order['order_id'];
$processed_orders[] = intval($order_id);
$service_id = $order['service_id'];
$home_name = $order['home_name'];
$remote_control_password = $order['remote_control_password'];
$ftp_password = $order['ftp_password'];
if ($remote_control_password === '' || strcasecmp((string)$remote_control_password, 'ChangeMe') === 0) {
$remote_control_password = billing_generate_provision_password();
}
if ($ftp_password === '' || strcasecmp((string)$ftp_password, 'ChangeMe') === 0) {
$ftp_password = billing_generate_provision_password();
}
$ip = $order['ip'];
$max_players = $order['max_players'];
$user_id = $order['user_id'];
$extended = isset($order['extended']) && $order['extended'] == "1" ? TRUE : FALSE;
$alreadyProvisioned = !$extended && intval($order['home_id'] ?? 0) > 0;
//Query service info
$service = $db->resultQuery( "SELECT *
FROM `{$db_prefix}billing_services`
WHERE service_id=".$db->realEscapeSingle($service_id) );
if( !empty( $service[0] ) )
{
$home_cfg_id = $service[0]['home_cfg_id'];
$mod_cfg_id = $service[0]['mod_cfg_id'];
//remote_server_id has been stored in IP_ID
//$remote_server_id = $service[0]['remote_server_id'];
$remote_server_id = $order['ip'];
$ftp = $service[0]['ftp'];
$install_method = $service[0]['install_method'];
$manual_url = $service[0]['manual_url'];
$access_rights = $service[0]['access_rights'];
}
else
return;
if($alreadyProvisioned)
{
$home_id = intval($order['home_id']);
}
elseif($extended)
{
$home_id = $order['home_id'];
//Get The home info without mods in 1 array (Necesary for remote connection).
$home_info = $db->getGameHomeWithoutMods($home_id);
//Create the remote connection
$remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']);
//Reassign the server
$db->assignHomeTo( "user", $user_id, $home_id, $access_rights );
//Reenable the FTP account
if ($ftp == "enabled")
{
$remote->ftp_mgr("useradd", $home_info['home_id'], $home_info['ftp_password'], $home_info['home_path']);
$db->changeFtpStatus('enabled',$home_info['home_id']);
}
echo "<h4>Server Installed, Check your Email for Details</h4><br>";
//Panel Log
$db->logger( "RENEWED SERVER " . $home_id);
// SEND EMAIL
$settings = $db->getSettings();
$subject = "Gameserver Renewel at " . $settings['panel_name'];
$email = $db->resultQuery(" SELECT DISTINCT users_email
FROM {$table_prefix}users, {$table_prefix}billing_orders
WHERE {$table_prefix}users.user_id = $user_id")[0]["users_email"];
$message = "Your server, " . $home_name ." ID #". $home_id . " at " . $settings['panel_name'] . " has just been renewed.<br>
Thank You for your continued support.<br>
If you have any questions or requests, visit our website or contact us directly in our Discord Server.";
$mail = mymail($email, $subject, $message, $settings);
$rundate = date('d/M/y G:i', is_numeric($now) ? (int)$now : strtotime($now));
if (!$mail)
$db->logger( "Email FAILED - Server Renewed " . $home_id);
// END EMAIL
//WEBHOOK Discord
discordmsg(array('content' => "The ". $home_name ." server ID #". $home_id . " has just been renewed."), $settings['discord_webhook_main'] ?? '');
//end WEBHOOK Discord
}
else
{
//OPTIONS, change it at your choice;
$extra_params = "";//no extra params defined by default
$cpu_affinity = "NA";//Affinity to one core/thread of the cpu by number, use NA to disable it
$nice = "0";//Min priority=19 Max Priority=-19
// ---------------------------------------------------------------
// Resolve IP: find the first IP address configured for this
// remote server. The order.ip column stores remote_server_id.
// ---------------------------------------------------------------
$resolved_remote_server_id = intval($remote_server_id);
$ip_id = null;
$ipRows = $db->resultQuery(
"SELECT ip_id FROM `{$db_prefix}remote_server_ips`
WHERE remote_server_id=" . $resolved_remote_server_id . "
ORDER BY ip_id ASC LIMIT 1"
);
if (!empty($ipRows[0]['ip_id'])) {
$ip_id = intval($ipRows[0]['ip_id']);
}
if ($ip_id === null) {
$errMsg = "No IP address configured for remote server ID {$resolved_remote_server_id} (order_id={$order_id}). "
. "Please add an IP to that remote server in the panel.";
billing_log_provision_error($db, $db_prefix, intval($order_id), 0, intval($user_id), $resolved_remote_server_id, 0, 0, intval($mod_cfg_id), $errMsg);
echo "<div class='failure'><p><strong>Provisioning failed for order #" . intval($order_id) . ":</strong> " . htmlspecialchars($errMsg) . "</p></div>";
$failed_count++;
continue;
}
// ---------------------------------------------------------------
// Resolve mod/build in priority order:
// 1. Explicit mod_cfg_id from billing_services (if > 0 and valid)
// 2. Admin-configured is_default_for_billing on config_mods
// 3. Only one mod available for this game — use it automatically
// 4. Fail gracefully with an admin-visible error
// ---------------------------------------------------------------
$resolved_mod_cfg_id = null;
if (!empty($mod_cfg_id) && intval($mod_cfg_id) > 0) {
$modCheck = $db->resultQuery(
"SELECT mod_cfg_id FROM `{$db_prefix}config_mods`
WHERE mod_cfg_id=" . intval($mod_cfg_id) . "
AND home_cfg_id=" . intval($home_cfg_id)
);
if (!empty($modCheck[0]['mod_cfg_id'])) {
$resolved_mod_cfg_id = intval($modCheck[0]['mod_cfg_id']);
}
}
if ($resolved_mod_cfg_id === null) {
$defaultModRow = $db->resultQuery(
"SELECT mod_cfg_id FROM `{$db_prefix}config_mods`
WHERE home_cfg_id=" . intval($home_cfg_id) . "
AND is_default_for_billing=1
LIMIT 1"
);
if (!empty($defaultModRow[0]['mod_cfg_id'])) {
$resolved_mod_cfg_id = intval($defaultModRow[0]['mod_cfg_id']);
}
}
if ($resolved_mod_cfg_id === null) {
$allMods = $db->resultQuery(
"SELECT mod_cfg_id FROM `{$db_prefix}config_mods`
WHERE home_cfg_id=" . intval($home_cfg_id)
);
if (!empty($allMods) && count($allMods) === 1) {
$resolved_mod_cfg_id = intval($allMods[0]['mod_cfg_id']);
}
}
if ($resolved_mod_cfg_id === null) {
$errMsg = "No default mod/build configured for game type (home_cfg_id={$home_cfg_id}, order_id={$order_id}). "
. "Visit Admin \u{2192} Game Defaults to mark a mod/build as the billing default.";
billing_log_provision_error($db, $db_prefix, intval($order_id), 0, intval($user_id), $resolved_remote_server_id, $ip_id, 0, 0, $errMsg);
echo "<div class='failure'><p><strong>Provisioning failed for order #" . intval($order_id) . ":</strong> " . htmlspecialchars($errMsg) . "</p></div>";
$failed_count++;
continue;
}
// Use resolved values for the rest of the provisioning flow
$mod_cfg_id = $resolved_mod_cfg_id;
//Add Game home to database
//HARD CODE TO /home/gameserver/
$rserver = $db->getRemoteServer($resolved_remote_server_id);
$game_path = "/home/gameserver/";
$home_id = $db->addGameHome( $resolved_remote_server_id, $user_id, $home_cfg_id, $game_path, $home_name, $remote_control_password, $ftp_password);
if (!$home_id) {
$errMsg = "Failed to create game home record for order_id={$order_id}, user_id={$user_id}, home_cfg_id={$home_cfg_id}.";
billing_log_provision_error($db, $db_prefix, intval($order_id), 0, intval($user_id), $resolved_remote_server_id, $ip_id, 0, $mod_cfg_id, $errMsg);
echo "<div class='failure'><p><strong>Provisioning failed for order #" . intval($order_id) . ":</strong> " . htmlspecialchars($errMsg) . "</p></div>";
$failed_count++;
continue;
}
// ---------------------------------------------------------------
// Assign next available port to the new server home.
// ---------------------------------------------------------------
$next_port = $db->getNextAvailablePort($ip_id, $home_cfg_id);
if ($next_port === false || $next_port === null) {
$errMsg = "No available port for ip_id={$ip_id}, home_cfg_id={$home_cfg_id} (order_id={$order_id}). "
. "Configure a port range for this IP/game type in the panel.";
$db->deleteGameHome($home_id);
billing_log_provision_error($db, $db_prefix, intval($order_id), 0, intval($user_id), $resolved_remote_server_id, $ip_id, 0, $mod_cfg_id, $errMsg);
echo "<div class='failure'><p><strong>Provisioning failed for order #" . intval($order_id) . ":</strong> " . htmlspecialchars($errMsg) . "</p></div>";
$failed_count++;
continue;
}
$add_port = $db->addGameIpPort($home_id, $ip_id, $next_port);
if (!$add_port) {
$errMsg = "Failed to assign port {$next_port} to home_id={$home_id} (ip_id={$ip_id}, order_id={$order_id}).";
$db->deleteGameHome($home_id);
billing_log_provision_error($db, $db_prefix, intval($order_id), 0, intval($user_id), $resolved_remote_server_id, $ip_id, $next_port, $mod_cfg_id, $errMsg);
echo "<div class='failure'><p><strong>Provisioning failed for order #" . intval($order_id) . ":</strong> " . htmlspecialchars($errMsg) . "</p></div>";
$failed_count++;
continue;
}
//Assign the Game Mod to the Game Home
$mod_id = $db->addModToGameHome( $home_id, $mod_cfg_id );
if (!$mod_id) {
$errMsg = "Failed to assign mod_cfg_id={$mod_cfg_id} to home_id={$home_id} (order_id={$order_id}). The mod may already be assigned or does not exist.";
// Try to recover the mod_id if it already exists (e.g. duplicate provisioning attempt)
$existingMod = $db->resultQuery(
"SELECT mod_id FROM `{$db_prefix}game_mods`
WHERE home_id=" . intval($home_id) . "
AND mod_cfg_id=" . intval($mod_cfg_id) . "
LIMIT 1"
);
if (!empty($existingMod[0]['mod_id'])) {
$mod_id = intval($existingMod[0]['mod_id']);
} else {
$db->delGameIpPort($home_id, $ip_id, $next_port);
$db->deleteGameHome($home_id);
billing_log_provision_error($db, $db_prefix, intval($order_id), intval($home_id), intval($user_id), $resolved_remote_server_id, $ip_id, $next_port, $mod_cfg_id, $errMsg);
echo "<div class='failure'><p><strong>Provisioning failed for order #" . intval($order_id) . ":</strong> " . htmlspecialchars($errMsg) . "</p></div>";
$failed_count++;
continue;
}
}
$db->updateGameModParams( $max_players, $extra_params, $cpu_affinity, $nice, $home_id, $mod_cfg_id );
$db->assignHomeTo( "user", $user_id, $home_id, $access_rights );
//Get The home info without mods in 1 array (Necesary for remote connection).
$home_info = $db->getGameHomeWithoutMods($home_id);
//Create the remote connection
$remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']);
//Get Full home info in 1 array
$home_info = $db->getGameHome($home_id);
//Read the Game Config from the XML file
$server_xml = read_server_config(SERVER_CONFIG_LOCATION."/".$home_info['home_cfg_file']);
//Get Values from XML
$modkey = $home_info['mods'][$mod_id]['mod_key'];
$mod_xml = xml_get_mod($server_xml, $modkey);
$installer_name = $mod_xml->installer_name;
$mod_cfg_id = $home_info['mods'][$mod_id]['mod_cfg_id'];
//Get Preinstall commands from xml
$precmd = $server_xml->pre_install;
//Get Postinstall commands from xml
$postcmd = $server_xml->post_install;
//Enable FTP account in remote server
if ($ftp == "enabled")
{
$remote->ftp_mgr("useradd", $home_info['home_id'], $home_info['ftp_password'], $home_info['home_path']);
$db->changeFtpStatus('enabled',$home_info['home_id']);
}
//Install files for this service in the remote server
$exec_folder_path = clean_path($home_info['home_path'] . "/" . $server_xml->exe_location );
$exec_path = clean_path($exec_folder_path . "/" . $server_xml->server_exec_name );
if ( (string)$server_xml->installer === "steamcmd" && !empty((string)$installer_name) )
{
if( preg_match("/win32/", $server_xml->game_key) OR preg_match("/win64/", $server_xml->game_key) )
$cfg_os = "windows";
elseif( preg_match("/linux/", $server_xml->game_key) )
$cfg_os = "linux";
// Some games like L4D2 require anonymous login
if($mod_xml->installer_login){
$login = $mod_xml->installer_login;
$pass = '';
}else{
$login = $settings['steam_user'];
$pass = $settings['steam_pass'];
}
$modname = ( $installer_name == '90' and !preg_match("/(cstrike|valve)/", $modkey) ) ? $modkey : '';
$betaname = isset($mod_xml->betaname) ? $mod_xml->betaname : '';
$betapwd = isset($mod_xml->betapwd) ? $mod_xml->betapwd : '';
$arch = isset($mod_xml->steam_bitness) ? $mod_xml->steam_bitness : '';
$remote->steam_cmd( $home_id,$home_info['home_path'],$installer_name,$modname,
$betaname,$betapwd,$login,$pass,$settings['steam_guard'],
$exec_folder_path,$exec_path,$precmd,$postcmd,$cfg_os,'',$arch);
}
else
{
// No SteamCMD installer — run pre/post install scripts only.
if (!empty((string)$precmd)) {
$result = $remote->exec((string)$precmd);
if ($result === NULL)
$db->logger("Script-only install: pre_install script returned no output for home_id $home_id");
}
if (!empty((string)$postcmd)) {
$result = $remote->exec((string)$postcmd);
if ($result === NULL)
$db->logger("Script-only install: post_install script returned no output for home_id $home_id");
}
}
echo "<h4><br><p>".get_lang('starting_installations')."</p></h4><br>";
//PANEL LOG
$db->logger( "CREATED NEW SERVER " . $home_id);
// SEND EMAIL to new server only
if($order['end_date'] == 0){
$settings = $db->getSettings();
$subject = "New Gameserver installed at " . $settings['panel_name'];
$email = $db->resultQuery(" SELECT DISTINCT users_email
FROM {$table_prefix}users, {$table_prefix}billing_orders
WHERE {$table_prefix}users.user_id = $user_id")[0]["users_email"];
$message = "Your server, " . $home_name ." ID #". $home_id . " at " . $settings['panel_name'] . " has just been created.<br>
Thank You for your continued support.<br>
If you have any questions or requests, visit our website or contact us directly in our Discord Server.
You can login to the Game Panel and click on Game Monitor to see your server. <br><br>
Thank you!<br> ";
$mail = mymail($email, $subject, $message, $settings);
$rundate = date('d/M/y G:i', is_numeric($now) ? (int)$now : strtotime($now));
if (!$mail)
$db->logger( "Email FAILED - Server Created " . $home_id);
//WEBHOOK Discord
discordmsg(array('content' => "A new server, ". $home_name ." ID #". $home_id . ", has just been created."), $settings['discord_webhook_main'] ?? '');
//end WEBHOOK Discord
}
// END EMAIL
}
// Set expiration date in panel database
// Status values: Active (provisioned & current), Invoiced (renewal invoice open),
// Expired (past due and awaiting deletion)
// end_date / next_invoice_date: when the next renewal invoice should be generated
if ($alreadyProvisioned)
{
$existing_end = strtotime((string)($order['end_date'] ?? ''));
if ($existing_end === false || $existing_end <= 0) {
$existing_end = time();
}
$end_date_str = date('Y-m-d H:i:s', $existing_end);
}
elseif ($order['invoice_duration'] == "day")
{
if(empty($order['end_date']) || $order['end_date'] === NULL){
$end_date = strtotime('+'.$order['qty'].' day');
}
else{
//this is a renewel, start from end of previous order
$current_end = strtotime($order['end_date']);
if ($current_end === false) {
$current_end = time(); // fallback to now if date is invalid
}
$end_date = strtotime('+'.$order['qty'].' day', $current_end);
}
}
elseif ($order['invoice_duration'] == "month")
{
// this is a new order
if(empty($order['end_date']) || $order['end_date'] === NULL){
$end_date = strtotime('+'.(intval($order['qty']) * 31).' day');
}
else{
//this is a renewel, start from end of previous order
$current_end = strtotime($order['end_date']);
if ($current_end === false) {
$current_end = time(); // fallback to now if date is invalid
}
$end_date = strtotime('+'.(intval($order['qty']) * 31).' day', $current_end);
}
}
elseif ($order['invoice_duration'] == "year")
{
// this is a new order
if(empty($order['end_date']) || $order['end_date'] === NULL){
$end_date = strtotime('+'.$order['qty'].' year');
}
else{
//this is a renewel, start from end of previous order
$current_end = strtotime($order['end_date']);
if ($current_end === false) {
$current_end = time(); // fallback to now if date is invalid
}
$end_date = strtotime('+'.$order['qty'].' year', $current_end);
}
}
if (!isset($end_date_str)) {
$end_date_str = date('Y-m-d H:i:s', $end_date);
}
// Set order status to 'Active' (server provisioned and current)
$db->query("UPDATE `{$db_prefix}billing_orders`
SET status='Active'
WHERE order_id=".$db->realEscapeSingle($order_id));
// Set the order expiration / next renewal date
$db->query("UPDATE `{$db_prefix}billing_orders`
SET end_date='" . $db->realEscapeSingle($end_date_str) . "',
remote_control_password='" . $db->realEscapeSingle($remote_control_password) . "',
ftp_password='" . $db->realEscapeSingle($ftp_password) . "'
WHERE order_id=".$db->realEscapeSingle($order_id));
// Save home_id created by this order
$db->query("UPDATE `{$db_prefix}billing_orders`
SET home_id='" . $db->realEscapeSingle($home_id) . "' WHERE order_id=".$db->realEscapeSingle($order_id));
$db->query("UPDATE `{$db_prefix}billing_invoices`
SET home_id=" . $db->realEscapeSingle($home_id) . ",
billing_status='Active'
WHERE order_id=" . $db->realEscapeSingle($order_id));
$db->query("UPDATE `{$db_prefix}billing_transactions`
SET home_id=" . $db->realEscapeSingle($home_id) . "
WHERE invoice_id IN (SELECT invoice_id FROM `{$db_prefix}billing_invoices` WHERE order_id=" . $db->realEscapeSingle($order_id) . ")");
// Set billing_status and next_invoice_date on server_homes
$db->query("UPDATE `{$db_prefix}server_homes`
SET billing_status = 'Active',
next_invoice_date = '" . $db->realEscapeSingle($end_date_str) . "',
billing_enabled = 1
WHERE home_id = " . $db->realEscapeSingle($home_id));
$provisioned_count++;
}
$db->query( "UPDATE `{$db_prefix}game_mods` SET max_players= ".$order['max_players']." WHERE home_id=".$db->realEscapeSingle($home_id));
// Show results and redirect
if ($provisioned_count > 0) {
echo "<div class='success'>";
echo "<h3>Server Provisioning Complete</h3>";
echo "<p>Successfully provisioned $provisioned_count server(s). Your server(s) are now active.</p>";
echo "</div>";
echo "<p><a href='home.php?m=gamemanager&p=game_monitor' class='btn'>View My Servers</a></p>";
// Auto-redirect after 3 seconds
echo "<script>setTimeout(function(){ window.location.href='home.php?m=gamemanager&p=game_monitor'; }, 3000);</script>";
} else {
echo "<div class='info'>";
echo "<p>No servers to provision. All orders have already been processed.</p>";
echo "</div>";
echo "<p><a href='home.php?m=billing&p=my_orders' class='btn'>View My Orders</a></p>";
}
} else {
echo "<div class='failure'>";
echo "<p>No paid orders found to provision.</p>";
echo "</div>";
echo "<p><a href='home.php?m=billing&p=my_orders' class='btn'>View My Orders</a></p>";
$provisioned_count = 0;
$failed_count = 0;
}
$GLOBALS['BILLING_PROVISION_LAST_RESULT'] = array(
'provisioned_count' => isset($provisioned_count) ? $provisioned_count : 0,
'failed_count' => isset($failed_count) ? $failed_count : 0,
'orders' => $processed_orders,
);
}
?>
function exec_ogp_module()
{
global $db,$view,$settings,$table_prefix;

View file

@ -25,7 +25,7 @@
// Module general information
$module_title = "billing";
$module_version = "3.6";
$db_version = 7;
$db_version = 8;
$module_required = FALSE;
// Module description
$module_description = "Billing storefront / provisioning integration. Public ordering runs as a standalone site; panel pages provide provisioning and admin order management.";
@ -422,4 +422,39 @@ $install_queries[7] = array(
},
);
// -----------------------------------------------------------------------
// db_version 8 — Provisioning error logging and default mod/build support.
// (a) billing_provisioning_errors: records every failed auto-provision
// attempt so admins can diagnose port/mod issues without digging
// through PHP logs.
// (b) config_mods.is_default_for_billing: admins can mark exactly one
// mod/build per game as the automatic billing install default.
// Both changes are safe to re-run (IF NOT EXISTS / INFORMATION_SCHEMA).
// -----------------------------------------------------------------------
$install_queries[8] = array(
// (a) Create billing_provisioning_errors table
"CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."billing_provisioning_errors` (
`error_id` INT(11) NOT NULL AUTO_INCREMENT,
`billing_order_id` INT(11) NOT NULL DEFAULT 0,
`home_id` INT(11) NOT NULL DEFAULT 0,
`user_id` INT(11) NOT NULL DEFAULT 0,
`remote_server_id` INT(11) NOT NULL DEFAULT 0,
`ip_id` INT(11) NOT NULL DEFAULT 0,
`attempted_port` INT(11) NOT NULL DEFAULT 0,
`mod_cfg_id` INT(11) NOT NULL DEFAULT 0,
`failure_message` TEXT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`error_id`),
KEY `billing_order_id` (`billing_order_id`),
KEY `created_at` (`created_at`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;",
// (b) Add is_default_for_billing to config_mods if missing
function($db) {
$r = $db->resultQuery("SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'OGP_DB_PREFIXconfig_mods' AND COLUMN_NAME = 'is_default_for_billing'");
if ($r && isset($r[0]['cnt']) && (int)$r[0]['cnt'] > 0) return true;
return (bool)$db->query("ALTER TABLE `OGP_DB_PREFIXconfig_mods` ADD `is_default_for_billing` TINYINT(1) NOT NULL DEFAULT 0");
},
);
?>

View file

@ -13,6 +13,10 @@ These pages are accessible within the panel for server provisioning and manageme
<menu>My Orders</menu>
</page>
<page key="admin_game_defaults" file="admin_game_defaults.php" access="admin">
<menu>Game Mod Defaults</menu>
</page>
<page key="admin_orders" file="admin_orders.php" access="admin">
<menu>Manage All Orders</menu>
</page>