diff --git a/modules/gamemanager/home_handling_functions.php b/modules/gamemanager/home_handling_functions.php
index 0a5fb14f..af34295b 100644
--- a/modules/gamemanager/home_handling_functions.php
+++ b/modules/gamemanager/home_handling_functions.php
@@ -491,7 +491,14 @@ function get_monitor_buttons($server_home, $server_xml)
* Returns an HTML-formatted expiration label for the given server home_id.
*
* Source of truth: billing_orders.end_date (DATETIME, NULL means no date set).
- * The most recent billing order for the home is used (ORDER BY end_date DESC LIMIT 1).
+ * The most recent active billing order for the home is resolved via a LEFT JOIN
+ * from server_homes to billing_orders — the same relationship used by the billing
+ * cron (cron-shop.php) and order-provisioning logic (create_servers.php).
+ *
+ * billing_orders.home_id is VARCHAR(255); server_homes.home_id is INT.
+ * MySQL handles the implicit cast in the JOIN condition automatically.
+ * Only rows where home_id != '0' are considered (home_id = '0' means not yet
+ * provisioned).
*
* Color thresholds:
* green – more than 10 days remaining (shows actual date)
@@ -508,18 +515,34 @@ function get_server_billing_expiration_html(int $home_id): string
{
global $db;
- // Query billing_orders for the most recent end_date on an active order for this home.
- // Statuses 'Active' and 'Invoiced' represent live subscriptions (invoiced = awaiting renewal).
- // OGP_DB_PREFIX is replaced at runtime by the panel's DB wrapper.
+ // Use a LEFT JOIN from server_homes to billing_orders — the same join pattern
+ // used throughout the billing module (cron-shop.php, create_servers.php).
+ // billing_orders.home_id is VARCHAR; server_homes.home_id is INT. MySQL
+ // performs the implicit cast for the equality comparison.
+ // We exclude billing_orders rows where home_id = '0' (not yet provisioned).
+ // OGP_DB_PREFIX is replaced at runtime by the panel DB wrapper (str_replace).
$rows = $db->resultQuery(
- "SELECT end_date FROM OGP_DB_PREFIXbilling_orders
- WHERE home_id = " . intval($home_id) . "
- AND status IN ('Active','Invoiced')
- ORDER BY end_date DESC
- LIMIT 1"
+ "SELECT bo.end_date
+ FROM OGP_DB_PREFIXserver_homes sh
+ LEFT JOIN OGP_DB_PREFIXbilling_orders bo
+ ON bo.home_id = sh.home_id
+ AND bo.home_id != '0'
+ AND bo.status IN ('Active','Invoiced')
+ WHERE sh.home_id = " . intval($home_id) . "
+ ORDER BY bo.end_date DESC
+ LIMIT 1"
);
- if (empty($rows) || empty($rows[0]['end_date'])) {
+ // If the server_homes row itself does not exist, or the query failed, bail out.
+ // empty($rows) is true when resultQuery returns FALSE (0 rows or error).
+ if ($rows === false) {
+ // Query error — billing_orders table may be missing or schema mismatch.
+ return "No expiration date found";
+ }
+
+ // A LEFT JOIN row always comes back (sh row exists), but bo.end_date may be NULL
+ // when there is no matching billing_orders record for this server.
+ if (empty($rows[0]['end_date'])) {
return "No expiration date found";
}
diff --git a/modules/modulemanager/module_handling.php b/modules/modulemanager/module_handling.php
index 1b63ad23..d0b03993 100644
--- a/modules/modulemanager/module_handling.php
+++ b/modules/modulemanager/module_handling.php
@@ -207,6 +207,18 @@ function update_module($db, $module_id, $module)
{
foreach ((array)$install_queries[$i+1] as $query)
{
+ // Support PHP callables in addition to plain SQL strings.
+ // A callable receives $db as its only argument and must return
+ // true on success or false on failure.
+ if (is_callable($query))
+ {
+ if ( $query($db) )
+ continue;
+
+ print_failure("".get_lang("query_failed")." (callable migration step) ".get_lang("query_failed_2")."");
+ return -2;
+ }
+
if ( $db->query($query) )
continue;
diff --git a/modules/steam_workshop/module.php b/modules/steam_workshop/module.php
index 4e666c23..7195e625 100644
--- a/modules/steam_workshop/module.php
+++ b/modules/steam_workshop/module.php
@@ -124,31 +124,88 @@ $install_queries[0] = array(
);
// Migration: upgrade existing v1 installs to v2 schema.
+//
+// ADD COLUMN IF NOT EXISTS is not supported in MySQL 5.7 (MariaDB only).
+// Each column addition is therefore performed via a PHP closure that:
+// 1. Queries INFORMATION_SCHEMA.COLUMNS to check whether the column exists.
+// 2. Runs ALTER TABLE ADD COLUMN only when it does not exist.
+// This makes the migration safe to run multiple times without errors.
+// OGP_DB_PREFIX in SQL strings is replaced at runtime by the panel DB wrapper.
$install_queries[2] = array(
- // New columns on workshop_game_profiles
- "ALTER TABLE `".OGP_DB_PREFIX."workshop_game_profiles`
- ADD COLUMN IF NOT EXISTS `steam_app_id` VARCHAR(32) NOT NULL DEFAULT '' AFTER `game_name`,
- ADD COLUMN IF NOT EXISTS `steam_login_required` TINYINT(1) NOT NULL DEFAULT 0 AFTER `workshop_app_id`,
- ADD COLUMN IF NOT EXISTS `steamcmd_login_mode` ENUM('anonymous','account') NOT NULL DEFAULT 'anonymous' AFTER `steam_login_required`,
- ADD COLUMN IF NOT EXISTS `steamcmd_path` VARCHAR(512) NOT NULL DEFAULT '' AFTER `steamcmd_login_mode`,
- ADD COLUMN IF NOT EXISTS `folder_naming_format` ENUM('@%mod_name%','@%workshop_id%','custom') NOT NULL DEFAULT '@%workshop_id%' AFTER `install_path_template`,
- ADD COLUMN IF NOT EXISTS `mod_launch_param` VARCHAR(512) NOT NULL DEFAULT '' AFTER `folder_name_template`,
- ADD COLUMN IF NOT EXISTS `mod_separator` ENUM('semicolon','comma','space') NOT NULL DEFAULT 'semicolon' AFTER `mod_launch_param`,
- ADD COLUMN IF NOT EXISTS `copy_keys` TINYINT(1) NOT NULL DEFAULT 0 AFTER `copy_method`,
- ADD COLUMN IF NOT EXISTS `key_source_path` TEXT NULL AFTER `copy_keys`,
- ADD COLUMN IF NOT EXISTS `key_dest_path` TEXT NULL AFTER `key_source_path`,
- ADD COLUMN IF NOT EXISTS `pre_update_script` TEXT NULL AFTER `key_dest_path`,
- ADD COLUMN IF NOT EXISTS `post_update_script` TEXT NULL AFTER `install_script`,
- ADD COLUMN IF NOT EXISTS `validation_notes` TEXT NULL AFTER `requires_restart`",
- // Rename copy_method enum values to match new options (copy/rsync/symlink)
- // (existing 'rsync' stays valid; 'robocopy'/'custom_script' are legacy)
+ // Add new columns to workshop_game_profiles one-by-one (MySQL 5.7 safe).
+ function($db) {
+ // 'OGP_DB_PREFIX' is the literal token that $db->query() / $db->resultQuery()
+ // replaces with the configured table prefix (e.g. 'gsp_') via str_replace at
+ // runtime. Using it directly in string literals below is intentional and is
+ // the same mechanism used everywhere else in the panel.
+ $tbl_profiles = 'OGP_DB_PREFIXworkshop_game_profiles';
- // New column on server_workshop_mods
- "ALTER TABLE `".OGP_DB_PREFIX."server_workshop_mods`
- ADD COLUMN IF NOT EXISTS `custom_folder` VARCHAR(255) NOT NULL DEFAULT '' AFTER `title`",
+ // column_name => column definition (no AFTER clause for portability)
+ // $col is always a value from this hardcoded array — not from user input.
+ $columns = array(
+ 'steam_app_id' => "VARCHAR(32) NOT NULL DEFAULT ''",
+ 'steam_login_required' => "TINYINT(1) NOT NULL DEFAULT 0",
+ 'steamcmd_login_mode' => "ENUM('anonymous','account') NOT NULL DEFAULT 'anonymous'",
+ 'steamcmd_path' => "VARCHAR(512) NOT NULL DEFAULT ''",
+ 'folder_naming_format' => "ENUM('@%mod_name%','@%workshop_id%','custom') NOT NULL DEFAULT '@%workshop_id%'",
+ 'mod_launch_param' => "VARCHAR(512) NOT NULL DEFAULT ''",
+ 'mod_separator' => "ENUM('semicolon','comma','space') NOT NULL DEFAULT 'semicolon'",
+ 'copy_keys' => "TINYINT(1) NOT NULL DEFAULT 0",
+ 'key_source_path' => "TEXT NULL",
+ 'key_dest_path' => "TEXT NULL",
+ 'pre_update_script' => "TEXT NULL",
+ 'post_update_script' => "TEXT NULL",
+ 'validation_notes' => "TEXT NULL",
+ );
+ foreach ($columns as $col => $def) {
+ // INFORMATION_SCHEMA.COLUMNS always returns one row for COUNT(*),
+ // so resultQuery returns an array (never FALSE for this query form).
+ // Escape $col when embedding it in the SQL string literal.
+ $safe_col = $db->realEscapeSingle($col);
+ $check = $db->resultQuery(
+ "SELECT COUNT(*) AS n
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = '" . $tbl_profiles . "'
+ AND COLUMN_NAME = '" . $safe_col . "'"
+ );
+ // If n > 0 the column already exists; skip it.
+ if ($check !== false && isset($check[0]['n']) && (int)$check[0]['n'] > 0) {
+ continue;
+ }
+ // $col is backtick-quoted so it is safe as an identifier.
+ if (!$db->query(
+ "ALTER TABLE `" . $tbl_profiles . "`
+ ADD COLUMN `" . $col . "` " . $def
+ )) {
+ return false;
+ }
+ }
+ return true;
+ },
- // New server-level settings table
+ // Add custom_folder to server_workshop_mods (MySQL 5.7 safe).
+ function($db) {
+ // See note above: 'OGP_DB_PREFIX' is replaced by str_replace at runtime.
+ $tbl_mods = 'OGP_DB_PREFIXserver_workshop_mods';
+ $check = $db->resultQuery(
+ "SELECT COUNT(*) AS n
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = '" . $tbl_mods . "'
+ AND COLUMN_NAME = 'custom_folder'"
+ );
+ if ($check !== false && isset($check[0]['n']) && (int)$check[0]['n'] > 0) {
+ return true; // Column already exists.
+ }
+ return (bool)$db->query(
+ "ALTER TABLE `" . $tbl_mods . "`
+ ADD COLUMN `custom_folder` VARCHAR(255) NOT NULL DEFAULT ''"
+ );
+ },
+
+ // New server-level settings table (CREATE IF NOT EXISTS is safe to re-run).
"CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_workshop_settings` (
`home_id` INT NOT NULL,
`workshop_enabled` TINYINT(1) NOT NULL DEFAULT 0,