From 7af438339729ff64d7bc912b7ce1666232367b07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 14:55:44 +0000 Subject: [PATCH 1/8] feat: harden updater layout sync, patching, backups, and apache checks Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/93d8a20b-c675-4542-8c4d-667a576f7483 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .github/module-map.md | 2 +- Panel/CHANGELOG.md | 1 + Panel/docs/COPILOT_TODO.md | 1 + Panel/modules/administration/panel_update.php | 2697 +++++++++-------- Panel/modules/update/module.php | 12 +- Panel/modules/update/patch_manager.php | 183 ++ .../update/patches/001_layout_bootstrap.php | 36 + 7 files changed, 1635 insertions(+), 1297 deletions(-) create mode 100644 Panel/modules/update/patch_manager.php create mode 100644 Panel/modules/update/patches/001_layout_bootstrap.php diff --git a/.github/module-map.md b/.github/module-map.md index 990461ce..fae19d9f 100644 --- a/.github/module-map.md +++ b/.github/module-map.md @@ -33,7 +33,7 @@ This file captures how the control panel, storefront, agents, and helper scripts | `config_games` | `modules/config_games/add_mod.php`, `server_config_parser.php`, XML files under `server_configs/` | Admin UI for XML definitions. Controls what appears in storefront/service catalog. | Feeds `gamemanager`, billing catalog, cron installers. | | `steam_workshop` | `modules/steam_workshop/admin.php`, `user.php`, `Panel/includes/functions.php`, `navigation.xml` | Admin profile defaults + per-home mod management. Profile defaults can now be refreshed from game XML and the user route is explicitly exposed via `p=user`. | Uses `config_games` XML metadata + `server_homes`/assignment tables; feeds workshop agent updater. | | `user_games` | `modules/user_games/add_home.php`, `assign_home.php`, `edit_home.php` | Admin workflow to add homes manually or edit assignments. Shares DB tables with billing provisioner. | Uses `game_homes`, `remote_servers`, `billing_orders`. | -| `administration` / `user_admin` | CRUD around users, groups, permissions, expire dates. | Sets roles consumed by storefront admin guard and provisioning ACLs. | +| `administration` / `user_admin` | CRUD around users, groups, permissions, expire dates. `modules/administration/panel_update.php` now also runs repository-layout-aware panel updates, preflight checks, updater self-refresh, backup/rollback for Panel+Website, patch execution, and Apache path scan/fix helpers. | Sets roles consumed by storefront admin guard and provisioning ACLs; writes update lifecycle traces to root `logs/update_trace.log` and patch state via `modules/update/patches` + `update_patches` tracking. | | `server` | `modules/server/*` | Remote server management (agents, IPs, ports, reinstall keys). Billing uses these tables for available nodes/locations. | | `modulemanager` | Manage module install/uninstall/menus. Billing module registers `navigation.xml` to surface `create_servers.php` & admin pages. | | `tickets`, `support` | Support ticketing/email utilities. | Pulls user info and logger records. | diff --git a/Panel/CHANGELOG.md b/Panel/CHANGELOG.md index bf5d8a71..8f573d50 100644 --- a/Panel/CHANGELOG.md +++ b/Panel/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 2026-05-18 +- **Updater layout hardening + pre-update patch framework:** Reworked `modules/administration/panel_update.php` to resolve explicit GSP root/Panel/Website paths, run mandatory preflight checks, self-update updater files before main sync when drift is detected, and apply ordered required patches from `modules/update/patches/` with DB/local state tracking. Backup/rollback now includes both Panel + Website archives and root `version.json`, logs moved to root `logs/update_trace.log`, and the admin Update UI now exposes preflight, patch apply, Apache path scan/fix, backup, update, and rollback actions. - **Billing runtime relocation + portable path bootstrap:** Re-homed storefront runtime to `Panel/modules/billing`, added portable runtime helpers (`billing_bootstrap.php`, `site_config.php`, `site_config.example.php`) with env/local override support for base path and panel path, normalized critical storefront redirects/links to computed billing URLs, and added `Website` compatibility wrappers for key billing entrypoints. - **Panel updater panel-subtree safety:** Hardened updater logic to treat repository `/panel` as the update source when present (ZIP + git flows) so root-level docs/examples/scripts are no longer candidates for panel file overwrite during updates. - **Panel registration stability + captcha fallback hardening:** Fixed a fatal syntax error in `modules/register/register-exec.php`, removed hardcoded/legacy registration redirects, added structured registration logging to `modules/register/logs/register.log` (auto-creates missing log dir), added duplicate username checks, added optional `users_pass_hash` write for PHP 8.3-compatible auth upgrades, and implemented graceful reCAPTCHA fallback when keys are missing/legacy-invalid or the widget reports an error so the themed registration flow no longer crashes with raw PHP errors. diff --git a/Panel/docs/COPILOT_TODO.md b/Panel/docs/COPILOT_TODO.md index 47c0b90e..50e18511 100644 --- a/Panel/docs/COPILOT_TODO.md +++ b/Panel/docs/COPILOT_TODO.md @@ -16,3 +16,4 @@ - Add an admin/serverlist UI badge that shows detected service OS variant (Windows/Linux/Any) from XML metadata next to each purchasable service row. - Add a panel settings health check that validates reCAPTCHA site/secret keys against active panel/storefront domains and warns admins before registration users see widget errors. - Add an automated deployment check that fails when `Website/timestamp.txt` and `modules/billing/timestamp.txt` diverge after storefront/content changes. +- Add an admin preview/diff panel for Apache path repairs so staff can review exact vhost line changes before confirming `Fix Apache Paths`. diff --git a/Panel/modules/administration/panel_update.php b/Panel/modules/administration/panel_update.php index eeeeb755..808efe72 100644 --- a/Panel/modules/administration/panel_update.php +++ b/Panel/modules/administration/panel_update.php @@ -10,1398 +10,1505 @@ * of the License, or any later version. * * GSP Panel Update System - * Provides safe, admin-only panel updates from GitHub with pre-update backup - * and a revert capability. + * Repository-layout aware updater with backup, rollback, patching, and apache path helpers. * */ -// Panel root is two directories up from this file (modules/administration/panel_update.php) -defined('GSP_PANEL_DIR') || define('GSP_PANEL_DIR', realpath(dirname(__FILE__) . '/../../')); -defined('GSP_BACKUP_BASE') || define('GSP_BACKUP_BASE', GSP_PANEL_DIR . '/backups'); -defined('GSP_UPDATE_LOG') || define('GSP_UPDATE_LOG', GSP_PANEL_DIR . '/logs/panel_updates.log'); +defined('GSP_PANEL_DIR') || define('GSP_PANEL_DIR', realpath(dirname(__FILE__) . '/../../')); +defined('GSP_ROOT_DIR') || define('GSP_ROOT_DIR', realpath(dirname(GSP_PANEL_DIR)) ?: dirname(GSP_PANEL_DIR)); +defined('GSP_WEBSITE_DIR') || define('GSP_WEBSITE_DIR', GSP_ROOT_DIR . '/Website'); +defined('GSP_BACKUP_BASE') || define('GSP_BACKUP_BASE', GSP_ROOT_DIR . '/backups'); +defined('GSP_UPDATE_LOG') || define('GSP_UPDATE_LOG', GSP_ROOT_DIR . '/logs/update_trace.log'); defined('GSP_VERSION_FILE') || define('GSP_VERSION_FILE', GSP_PANEL_DIR . '/includes/panel_version.php'); -defined('GSP_VERSION_JSON') || define('GSP_VERSION_JSON', GSP_PANEL_DIR . '/version.json'); +defined('GSP_VERSION_JSON') || define('GSP_VERSION_JSON', GSP_ROOT_DIR . '/version.json'); +defined('GSP_PATCH_DIR') || define('GSP_PATCH_DIR', GSP_PANEL_DIR . '/modules/update/patches'); +defined('GSP_EXPECTED_ROOT') || define('GSP_EXPECTED_ROOT', '/var/www/html/GSP'); + +$gspPatchManager = GSP_PANEL_DIR . '/modules/update/patch_manager.php'; +if (file_exists($gspPatchManager)) { +require_once($gspPatchManager); +} + +function gsp_update_log($message) +{ +$log_dir = dirname(GSP_UPDATE_LOG); +if (!is_dir($log_dir)) { +@mkdir($log_dir, 0755, true); +} +$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL; +@file_put_contents(GSP_UPDATE_LOG, $line, FILE_APPEND | LOCK_EX); +} + +function gsp_log_update_to_db($channel, $branch, $status, $message, $backup_path = null, $db_backup_path = null, $file_backup_path = null, $started_at = null, $finished_at = null) +{ +global $db; +if (!isset($db) || !is_object($db)) { +return; +} +if ($started_at === null) { +$started_at = date('Y-m-d H:i:s'); +} +$channel = $db->real_escape_string((string)$channel); +$branch = $branch !== null ? "'" . $db->real_escape_string((string)$branch) . "'" : 'NULL'; +$status = $db->real_escape_string((string)$status); +$message_esc = $message !== null ? "'" . $db->real_escape_string((string)$message) . "'" : 'NULL'; +$backup_path_esc = $backup_path !== null ? "'" . $db->real_escape_string((string)$backup_path) . "'" : 'NULL'; +$db_backup_esc = $db_backup_path !== null ? "'" . $db->real_escape_string((string)$db_backup_path) . "'" : 'NULL'; +$file_backup_esc = $file_backup_path !== null ? "'" . $db->real_escape_string((string)$file_backup_path) . "'" : 'NULL'; +$started_esc = "'" . $db->real_escape_string((string)$started_at) . "'"; +$finished_esc = $finished_at !== null ? "'" . $db->real_escape_string((string)$finished_at) . "'" : 'NULL'; +$db->query( +"INSERT INTO OGP_DB_PREFIXpanel_update_log" +. " (channel, branch, status, message, backup_path, db_backup_path, file_backup_path, started_at, finished_at)" +. " VALUES ('{$channel}', {$branch}, '{$status}', {$message_esc}, {$backup_path_esc}, {$db_backup_esc}, {$file_backup_esc}, {$started_esc}, {$finished_esc})" +); +} + +function gsp_random_token($bytes = 16) +{ +if (function_exists('random_bytes')) { +try { +return bin2hex(random_bytes($bytes)); +} catch (Exception $e) { +} +} +if (function_exists('openssl_random_pseudo_bytes')) { +return bin2hex(openssl_random_pseudo_bytes($bytes)); +} +return sha1(uniqid('gsp', true) . mt_rand()); +} function gsp_detect_repo_root() { - $panelGit = GSP_PANEL_DIR . '/.git'; - if (is_dir($panelGit) || is_file($panelGit)) { - return GSP_PANEL_DIR; - } - $parent = dirname(GSP_PANEL_DIR); - $parentGit = $parent . '/.git'; - if (is_dir($parentGit) || is_file($parentGit)) { - return $parent; - } - return null; +$candidates = [ +GSP_ROOT_DIR, +GSP_PANEL_DIR, +dirname(GSP_ROOT_DIR), +]; +foreach ($candidates as $candidate) { +$gitPath = $candidate . '/.git'; +if (is_dir($gitPath) || is_file($gitPath)) { +return realpath($candidate) ?: $candidate; +} +} +return null; } -function gsp_locate_panel_source_dir($root_dir) -{ - $root = realpath($root_dir); - if ($root === false) { - return null; - } - // New layout: repo root contains /panel subtree. - if (is_dir($root . '/panel/includes') && is_dir($root . '/panel/modules')) { - return realpath($root . '/panel'); - } - // Legacy layout: panel is at repository root. - if (is_dir($root . '/includes') && is_dir($root . '/modules')) { - return $root; - } - return null; -} - -// --------------------------------------------------------------------------- -// Helper: write a line to the panel update log -// --------------------------------------------------------------------------- -function gsp_update_log($message) -{ - $log_dir = dirname(GSP_UPDATE_LOG); - if (!is_dir($log_dir)) { - @mkdir($log_dir, 0750, true); - } - $line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL; - @file_put_contents(GSP_UPDATE_LOG, $line, FILE_APPEND | LOCK_EX); -} - -// --------------------------------------------------------------------------- -// Helper: insert a row into gsp_panel_update_log (silently skips on failure) -// --------------------------------------------------------------------------- -function gsp_log_update_to_db($channel, $branch, $status, $message, $backup_path = null, $db_backup_path = null, $file_backup_path = null, $started_at = null, $finished_at = null) -{ - global $db; - if (!isset($db) || !is_object($db)) { - return; - } - if ($started_at === null) { - $started_at = date('Y-m-d H:i:s'); - } - $channel = $db->real_escape_string((string) $channel); - $branch = $branch !== null ? "'" . $db->real_escape_string((string) $branch) . "'" : 'NULL'; - $status = $db->real_escape_string((string) $status); - $message_esc = $message !== null ? "'" . $db->real_escape_string((string) $message) . "'" : 'NULL'; - $backup_path_esc = $backup_path !== null ? "'" . $db->real_escape_string((string) $backup_path) . "'" : 'NULL'; - $db_backup_esc = $db_backup_path !== null ? "'" . $db->real_escape_string((string) $db_backup_path) . "'" : 'NULL'; - $file_backup_esc = $file_backup_path !== null ? "'" . $db->real_escape_string((string) $file_backup_path) . "'" : 'NULL'; - $started_esc = "'" . $db->real_escape_string($started_at) . "'"; - $finished_esc = $finished_at !== null ? "'" . $db->real_escape_string((string) $finished_at) . "'" : 'NULL'; - $db->query( - "INSERT INTO OGP_DB_PREFIXpanel_update_log" - . " (channel, branch, status, message, backup_path, db_backup_path, file_backup_path, started_at, finished_at)" - . " VALUES ('{$channel}', {$branch}, '{$status}', {$message_esc}," - . " {$backup_path_esc}, {$db_backup_esc}, {$file_backup_esc}, {$started_esc}, {$finished_esc})" - ); -} - -// --------------------------------------------------------------------------- -// Helper: read the installed version / branch from panel_version.php -// --------------------------------------------------------------------------- function gsp_get_current_version() { - if (file_exists(GSP_VERSION_FILE)) { - $code = file_get_contents(GSP_VERSION_FILE); - if (preg_match("/define\('GSP_VERSION',\s*'([^']+)'\)/", $code, $m)) { - return $m[1]; - } - } - return 'unknown'; +if (file_exists(GSP_VERSION_FILE)) { +$code = @file_get_contents(GSP_VERSION_FILE); +if (preg_match("/define\('GSP_VERSION',\s*'([^']+)'\)/", (string)$code, $m)) { +return $m[1]; +} +} +return 'unknown'; } function gsp_get_current_branch() { - $repo_root = gsp_detect_repo_root(); - if ($repo_root && function_exists('exec')) { - $out = []; - $ret = 0; - exec('git -C ' . escapeshellarg($repo_root) . ' rev-parse --abbrev-ref HEAD 2>/dev/null', $out, $ret); - if ($ret === 0 && !empty($out[0])) { - return trim($out[0]); - } - } - if (file_exists(GSP_VERSION_FILE)) { - $code = file_get_contents(GSP_VERSION_FILE); - if (preg_match("/define\('GSP_BRANCH',\s*'([^']+)'\)/", $code, $m)) { - return $m[1]; - } - } - // Fall back to reading .git/HEAD - $repo_root = gsp_detect_repo_root() ?: GSP_PANEL_DIR; - $git_head = $repo_root . '/.git/HEAD'; - if (file_exists($git_head)) { - $content = trim(file_get_contents($git_head)); - if (preg_match('/^ref: refs\/heads\/(.+)$/', $content, $m)) { - return $m[1]; - } - } - return 'unknown'; +$repo_root = gsp_detect_repo_root(); +if ($repo_root && function_exists('exec')) { +$out = []; +$ret = 0; +exec('git -C ' . escapeshellarg($repo_root) . ' rev-parse --abbrev-ref HEAD 2>/dev/null', $out, $ret); +if ($ret === 0 && !empty($out[0])) { +return trim($out[0]); +} +} +return 'unknown'; } function gsp_get_git_commit() { - $repo_root = gsp_detect_repo_root() ?: GSP_PANEL_DIR; - $git_head = $repo_root . '/.git/HEAD'; - if (!file_exists($git_head)) { - return null; - } - $content = trim(file_get_contents($git_head)); - if (preg_match('/^ref: refs\/heads\/(.+)$/', $content, $m)) { - $branch_file = $repo_root . '/.git/refs/heads/' . $m[1]; - if (file_exists($branch_file)) { - return trim(file_get_contents($branch_file)); - } - } elseif (preg_match('/^[0-9a-f]{40}$/i', $content)) { - return $content; - } - return null; +$repo_root = gsp_detect_repo_root(); +if (!$repo_root || !function_exists('exec')) { +return null; +} +$out = []; +$ret = 0; +exec('git -C ' . escapeshellarg($repo_root) . ' rev-parse HEAD 2>/dev/null', $out, $ret); +if ($ret === 0 && !empty($out[0])) { +$sha = trim($out[0]); +if (preg_match('/^[0-9a-f]{40,64}$/i', $sha)) { +return $sha; +} +} +return null; } -// --------------------------------------------------------------------------- -// Helper: write/update includes/panel_version.php -// --------------------------------------------------------------------------- -function gsp_write_version_file($version, $branch_or_type) -{ - $content = " $installed_type, - 'installed_source' => $installed_source, - 'installed_version' => $installed_version, - 'installed_commit' => $installed_commit, - 'installed_at' => date('Y-m-d H:i:s'), - ]; - @file_put_contents(GSP_VERSION_JSON, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +$data = [ +'installed_type' => (string)$installed_type, +'installed_source' => (string)$installed_source, +'installed_version' => (string)$installed_version, +'installed_commit' => $installed_commit, +'installed_at' => date('Y-m-d H:i:s'), +]; +@file_put_contents(GSP_VERSION_JSON, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } -// --------------------------------------------------------------------------- -// Helper: generate a cryptographically strong random hex token -// --------------------------------------------------------------------------- -function gsp_random_token($bytes = 16) -{ - try { - return bin2hex(random_bytes($bytes)); - } catch (\Throwable $e) { - // Fallback for environments where random_bytes() is unavailable - return bin2hex(openssl_random_pseudo_bytes($bytes)); - } -} - -// --------------------------------------------------------------------------- -// GitHub API: fetch list of releases (newest first) -// --------------------------------------------------------------------------- function gsp_fetch_github_releases($repo_owner, $repo_name) { - $url = "https://api.github.com/repos/{$repo_owner}/{$repo_name}/releases?per_page=30"; - - if (function_exists('curl_init')) { - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'GSP-Panel-Updater'); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - $data = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($code === 200 && $data) { - return json_decode($data, true); - } - } else { - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: GSP-Panel-Updater\r\n", - 'timeout' => 10, - ], - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - $data = @file_get_contents($url, false, $ctx); - if ($data) { - return json_decode($data, true); - } - } - return false; +$url = "https://api.github.com/repos/{$repo_owner}/{$repo_name}/releases?per_page=30"; +if (function_exists('curl_init')) { +$ch = curl_init($url); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +curl_setopt($ch, CURLOPT_USERAGENT, 'GSP-Panel-Updater'); +curl_setopt($ch, CURLOPT_TIMEOUT, 15); +curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); +curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); +$data = curl_exec($ch); +$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +if ($code === 200 && $data) { +return json_decode($data, true); +} +} +$ctx = stream_context_create([ +'http' => [ +'method' => 'GET', +'header' => "User-Agent: GSP-Panel-Updater\r\n", +'timeout' => 15, +], +'ssl' => [ +'verify_peer' => true, +'verify_peer_name' => true, +], +]); +$data = @file_get_contents($url, false, $ctx); +if ($data) { +return json_decode($data, true); +} +return false; } -// --------------------------------------------------------------------------- -// Helper: read DB credentials from includes/config.inc.php using an -// isolated scope so the caller's variables are not polluted and side effects -// from the config file (e.g. debug.php inclusion) stay contained. -// Returns an array with keys host/port/user/pass/name, or null on failure. -// --------------------------------------------------------------------------- -function load_panel_db_config() +function gsp_preflight_check() { - $config_file = GSP_PANEL_DIR . '/includes/config.inc.php'; - if (!is_readable($config_file)) { - return null; - } +$errors = []; +$warnings = []; +$layout = [ +'cwd' => getcwd(), +'expected_root' => GSP_EXPECTED_ROOT, +'gsp_root' => GSP_ROOT_DIR, +'panel_dir' => GSP_PANEL_DIR, +'website_dir' => GSP_WEBSITE_DIR, +'backup_dir' => GSP_BACKUP_BASE, +'config_file' => GSP_PANEL_DIR . '/includes/config.inc.php', +]; - // Static closure gives an isolated variable scope; the @ suppresses any - // non-fatal errors from transitive includes (e.g. debug.php). - $capture = static function ($__file) { - $db_host = 'localhost'; - $db_port = '3306'; - $db_user = ''; - $db_pass = ''; - $db_name = ''; - @include $__file; - return [ - 'host' => (string) $db_host, - 'port' => (string) $db_port, - 'user' => (string) $db_user, - 'pass' => (string) $db_pass, - 'name' => (string) $db_name, - ]; - }; - - $cfg = $capture($config_file); - - if (empty($cfg['user']) || empty($cfg['name'])) { - return null; - } - - return $cfg; +if (!$layout['cwd']) { +$errors[] = 'Unable to read current working directory.'; +} elseif (strpos(realpath($layout['cwd']) ?: $layout['cwd'], GSP_ROOT_DIR) !== 0) { +$errors[] = 'Current working directory is outside detected GSP root.'; +} +if (!is_dir(GSP_ROOT_DIR)) { +$errors[] = 'Detected GSP root path is missing.'; +} +if (realpath(GSP_ROOT_DIR) !== false && realpath(GSP_ROOT_DIR) !== GSP_EXPECTED_ROOT) { +$warnings[] = 'Detected root differs from expected production path: ' . GSP_EXPECTED_ROOT; +} +if (!is_dir(GSP_PANEL_DIR)) { +$errors[] = 'Panel directory is missing.'; +} +if (!is_dir(GSP_WEBSITE_DIR)) { +$errors[] = 'Website directory is missing.'; +} +if (!file_exists($layout['config_file'])) { +$errors[] = 'Panel includes/config.inc.php was not found and cannot be preserved.'; +} +if (!is_dir(GSP_BACKUP_BASE)) { +if (!@mkdir(GSP_BACKUP_BASE, 0755, true) && !is_dir(GSP_BACKUP_BASE)) { +$errors[] = 'Backups directory is missing and cannot be created.'; +} } -// --------------------------------------------------------------------------- -// Backup: dump the MySQL database into $backup_dir/database.sql -// Returns ['success'=>bool, 'error'=>string, 'file'=>string] -// --------------------------------------------------------------------------- -function create_database_backup($backup_dir, $db_config) +foreach ([GSP_ROOT_DIR, GSP_PANEL_DIR, GSP_WEBSITE_DIR, GSP_BACKUP_BASE] as $path) { +if (!is_writable($path)) { +$errors[] = 'Path is not writable: ' . $path; +} +} + +gsp_update_log('Preflight layout: ' . json_encode($layout)); +foreach ($warnings as $warning) { +gsp_update_log('Preflight warning: ' . $warning); +} +foreach ($errors as $error) { +gsp_update_log('Preflight error: ' . $error); +} + +return [ +'success' => empty($errors), +'errors' => $errors, +'warnings' => $warnings, +'layout' => $layout, +]; +} + +function gsp_load_panel_db_config() { - // Verify mysqldump is available before attempting anything - $check_out = []; - $check_ret = 0; - exec('command -v mysqldump 2>/dev/null', $check_out, $check_ret); - if ($check_ret !== 0 || empty($check_out[0])) { - return [ - 'success' => false, - 'error' => 'mysqldump is not installed or not in PATH. ' - . 'Install the mysql-client package and ensure it is on the PATH.', - ]; - } - - $sql_file = $backup_dir . '/database.sql'; - $creds_file = tempnam(sys_get_temp_dir(), 'gsp_db_'); - if ($creds_file === false) { - return ['success' => false, 'error' => 'Cannot create temporary credentials file.']; - } - - // Build MySQL option-file content; addcslashes protects backslashes and - // newlines so values are safe inside the ini-style [client] section. - // The password never appears in the process list this way. - $creds = "[client]\n" - . "user=" . addcslashes($db_config['user'], "\\\n") . "\n" - . "password=" . addcslashes($db_config['pass'], "\\\n") . "\n"; - if (!empty($db_config['host'])) { - $creds .= "host=" . addcslashes($db_config['host'], "\\\n") . "\n"; - } - if (!empty($db_config['port']) && $db_config['port'] !== '3306') { - $creds .= "port=" . addcslashes($db_config['port'], "\\\n") . "\n"; - } - file_put_contents($creds_file, $creds); - chmod($creds_file, 0600); - - // Redirect stderr to a separate temp file so it never pollutes the SQL dump - $err_tmp = tempnam(sys_get_temp_dir(), 'gsp_db_err_'); - if ($err_tmp === false) { - @unlink($creds_file); - return ['success' => false, 'error' => 'Cannot create temporary file for mysqldump error capture.']; - } - $command = 'mysqldump --defaults-extra-file=' . escapeshellarg($creds_file) - . ' --skip-opt --single-transaction --add-drop-table' - . ' --create-options --extended-insert --quick --set-charset' - . ' ' . escapeshellarg($db_config['name']) - . ' > ' . escapeshellarg($sql_file) - . ' 2> ' . escapeshellarg($err_tmp); - - $unused = []; - $ret = 0; - exec($command, $unused, $ret); - @unlink($creds_file); - - // Collect stderr; strip lines mentioning "password" or "passwd" as a - // defensive measure (mysqldump error output does not normally include the - // password, but we filter anyway in case of unusual configurations). - $err_output = ''; - if (file_exists($err_tmp)) { - $raw = trim(file_get_contents($err_tmp)); - @unlink($err_tmp); - if ($raw !== '') { - $err_output = implode("\n", array_filter( - explode("\n", $raw), - static function ($line) { - return stripos($line, 'password') === false - && stripos($line, 'passwd') === false; - } - )); - } - } - - if ($ret !== 0) { - @unlink($sql_file); - $msg = 'mysqldump failed (exit code ' . $ret . ').'; - if ($err_output !== '') { - $msg .= ' Error: ' . $err_output; - } - return ['success' => false, 'error' => $msg]; - } - - if (!file_exists($sql_file) || filesize($sql_file) < 100) { - @unlink($sql_file); - return ['success' => false, 'error' => 'Database dump file is missing or empty after mysqldump.']; - } - - return ['success' => true, 'file' => $sql_file]; +$config_file = GSP_PANEL_DIR . '/includes/config.inc.php'; +if (!is_readable($config_file)) { +return null; +} +$capture = static function ($__file) { +$db_host = 'localhost'; +$db_port = '3306'; +$db_user = ''; +$db_pass = ''; +$db_name = ''; +@include $__file; +return [ +'host' => (string)$db_host, +'port' => (string)$db_port, +'user' => (string)$db_user, +'pass' => (string)$db_pass, +'name' => (string)$db_name, +]; +}; +$cfg = $capture($config_file); +if (empty($cfg['user']) || empty($cfg['name'])) { +return null; +} +return $cfg; } -// --------------------------------------------------------------------------- -// Backup: tar-gzip the panel root into $backup_dir/panel-files.tar.gz -// Returns ['success'=>bool, 'error'=>string, 'file'=>string] -// --------------------------------------------------------------------------- -function create_panel_files_archive($backup_dir, $panel_root) +function gsp_create_database_backup($backup_dir, $db_config) { - // Verify tar is available - $check_out = []; - $check_ret = 0; - exec('command -v tar 2>/dev/null', $check_out, $check_ret); - if ($check_ret !== 0 || empty($check_out[0])) { - return ['success' => false, 'error' => 'tar is not installed or not in PATH.']; - } - - $tar_file = $backup_dir . '/panel-files.tar.gz'; - - // Top-level directories are anchored with ./ so they only match at the - // archive root; wildcard patterns match at any depth. - $exclude_dirs = ['./backups', './.git', './logs', './cache', './tmp', './node_modules', './vendor']; - $exclude_globs = ['*.log', '*.sql']; - - $exclude_args = ''; - foreach ($exclude_dirs as $dir) { - $exclude_args .= ' --exclude=' . escapeshellarg($dir); - } - foreach ($exclude_globs as $glob) { - $exclude_args .= ' --exclude=' . escapeshellarg($glob); - } - - // -C panel_root . preserves relative paths (./home.php, ./modules/…) - $command = 'tar -czf ' . escapeshellarg($tar_file) - . $exclude_args - . ' -C ' . escapeshellarg($panel_root) - . ' . 2>&1'; - - $out = []; - $ret = 0; - exec($command, $out, $ret); - - if ($ret !== 0) { - @unlink($tar_file); - return [ - 'success' => false, - 'error' => 'tar failed (exit code ' . $ret . '). ' . implode(' | ', $out), - ]; - } - - if (!file_exists($tar_file) || filesize($tar_file) < 100) { - @unlink($tar_file); - return ['success' => false, 'error' => 'Panel archive file is missing or empty after tar.']; - } - - return ['success' => true, 'file' => $tar_file]; +$sql_file = $backup_dir . '/database.sql'; +$check = []; +$ret = 0; +exec('command -v mysqldump 2>/dev/null', $check, $ret); +if ($ret !== 0 || empty($check[0])) { +return ['success' => false, 'error' => 'mysqldump is not available.']; +} +$creds_file = tempnam(sys_get_temp_dir(), 'gsp_db_'); +if ($creds_file === false) { +return ['success' => false, 'error' => 'Cannot create temporary credential file.']; +} +$creds = "[client]\n" +. "user=" . addcslashes($db_config['user'], "\\\n") . "\n" +. "password=" . addcslashes($db_config['pass'], "\\\n") . "\n"; +if (!empty($db_config['host'])) { +$creds .= "host=" . addcslashes($db_config['host'], "\\\n") . "\n"; +} +if (!empty($db_config['port']) && $db_config['port'] !== '3306') { +$creds .= "port=" . addcslashes($db_config['port'], "\\\n") . "\n"; +} +@file_put_contents($creds_file, $creds); +@chmod($creds_file, 0600); +$err_tmp = tempnam(sys_get_temp_dir(), 'gsp_db_err_'); +if ($err_tmp === false) { +@unlink($creds_file); +return ['success' => false, 'error' => 'Cannot create temporary error capture file.']; +} +$cmd = 'mysqldump --defaults-extra-file=' . escapeshellarg($creds_file) +. ' --skip-opt --single-transaction --add-drop-table --create-options --extended-insert --quick --set-charset' +. ' ' . escapeshellarg($db_config['name']) +. ' > ' . escapeshellarg($sql_file) +. ' 2> ' . escapeshellarg($err_tmp); +$unused = []; +$ret = 0; +exec($cmd, $unused, $ret); +@unlink($creds_file); +$err = trim((string)@file_get_contents($err_tmp)); +@unlink($err_tmp); +if ($ret !== 0) { +@unlink($sql_file); +return ['success' => false, 'error' => 'mysqldump failed: ' . $err]; +} +if (!file_exists($sql_file) || filesize($sql_file) < 100) { +@unlink($sql_file); +return ['success' => false, 'error' => 'Database dump file missing or empty.']; +} +return ['success' => true, 'file' => $sql_file]; } -// --------------------------------------------------------------------------- -// Backup: write backup.json metadata into $backup_dir -// Returns true on success, false on failure. -// --------------------------------------------------------------------------- -function write_backup_metadata($backup_dir, $metadata) +function gsp_create_archive($source_dir, $archive_file, array $excludes = []) { - return file_put_contents( - $backup_dir . '/backup.json', - json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ) !== false; +$check = []; +$ret = 0; +exec('command -v tar 2>/dev/null', $check, $ret); +if ($ret !== 0 || empty($check[0])) { +return ['success' => false, 'error' => 'tar is not available.']; +} +if (!is_dir($source_dir)) { +return ['success' => false, 'error' => 'Source directory missing: ' . $source_dir]; +} +$exclude_args = ''; +foreach ($excludes as $exclude) { +$exclude_args .= ' --exclude=' . escapeshellarg($exclude); +} +$cmd = 'tar -czf ' . escapeshellarg($archive_file) +. $exclude_args +. ' -C ' . escapeshellarg($source_dir) +. ' . 2>&1'; +$out = []; +$ret = 0; +exec($cmd, $out, $ret); +if ($ret !== 0) { +@unlink($archive_file); +return ['success' => false, 'error' => 'tar failed: ' . implode(' | ', $out)]; +} +if (!file_exists($archive_file) || filesize($archive_file) < 100) { +@unlink($archive_file); +return ['success' => false, 'error' => 'Archive missing or empty: ' . $archive_file]; +} +return ['success' => true, 'file' => $archive_file]; } -// --------------------------------------------------------------------------- -// Backup: create a full timestamped backup (DB + files + metadata + log) -// --------------------------------------------------------------------------- -function gsp_create_full_backup($update_target_type, $update_target_version) +function gsp_backup_apache_configs($backup_dir) { - $ts = date('Y-m-d_H-i-s'); - $backup_dir = GSP_BACKUP_BASE . '/' . $ts; - - // Helper that writes a timestamped line to backup.log inside $backup_dir - $append_log = static function ($msg) use ($backup_dir) { - $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL; - @file_put_contents($backup_dir . '/backup.log', $line, FILE_APPEND | LOCK_EX); - }; - - // 1. Ensure backup base directory exists (0755 so the web server can write) - if (!is_dir(GSP_BACKUP_BASE)) { - $mkdir_ok = @mkdir(GSP_BACKUP_BASE, 0755, true); - if (!$mkdir_ok) { - $last_err = error_get_last(); - $detail = ($last_err && !empty($last_err['message'])) ? ' (' . $last_err['message'] . ')' : ''; - return [ - 'success' => false, - 'error' => 'Cannot create backup base directory: ' . GSP_BACKUP_BASE . $detail - . '. Ensure the web server user has write access to: ' - . dirname(GSP_BACKUP_BASE), - ]; - } - } - - // 2. Create timestamped backup directory - if (!@mkdir($backup_dir, 0755, true)) { - $last_err = error_get_last(); - $detail = ($last_err && !empty($last_err['message'])) ? ' (' . $last_err['message'] . ')' : ''; - return [ - 'success' => false, - 'error' => 'Cannot create backup directory: ' . $backup_dir . $detail - . '. Ensure the web server user has write access to: ' . GSP_BACKUP_BASE, - ]; - } - - $append_log("Backup started. Target: {$update_target_type} / {$update_target_version}"); - $append_log("Panel root: " . GSP_PANEL_DIR); - $append_log("Backup directory: {$backup_dir}"); - - // 3. Load DB configuration from includes/config.inc.php - $db_config = load_panel_db_config(); - if ($db_config === null) { - $append_log("ERROR: Cannot load database configuration from includes/config.inc.php"); - return [ - 'success' => false, - 'error' => 'Cannot load database configuration. ' - . 'Ensure includes/config.inc.php exists and contains valid DB credentials.', - ]; - } - $append_log("DB config loaded. Host: {$db_config['host']}, Database: {$db_config['name']}"); - - // 4. Database backup — stops the update if it fails - $append_log("Starting database backup (mysqldump)..."); - $db_result = create_database_backup($backup_dir, $db_config); - if (!$db_result['success']) { - $append_log("ERROR: Database backup failed: " . $db_result['error']); - return ['success' => false, 'error' => $db_result['error']]; - } - $append_log("Database backup complete: database.sql (" . filesize($db_result['file']) . " bytes)"); - - // 5. Panel files archive — stops the update if it fails - $append_log("Starting panel files archive (tar gzip)..."); - $tar_result = create_panel_files_archive($backup_dir, GSP_PANEL_DIR); - if (!$tar_result['success']) { - $append_log("ERROR: Panel files archive failed: " . $tar_result['error']); - return ['success' => false, 'error' => $tar_result['error']]; - } - $append_log("Panel files archive complete: panel-files.tar.gz (" . filesize($tar_result['file']) . " bytes)"); - - // 6. Write backup.json metadata - $vinfo = gsp_read_version_json(); - $metadata = [ - 'backup_timestamp' => $ts, - 'panel_root' => GSP_PANEL_DIR, - 'database_host' => $db_config['host'], - 'database_name' => $db_config['name'], - 'installed_version' => $vinfo - ? ($vinfo['installed_version'] ?? gsp_get_current_version()) - : gsp_get_current_version(), - 'git_branch' => gsp_get_current_branch(), - 'git_commit' => gsp_get_git_commit(), - 'update_target_type' => $update_target_type, - 'update_target_version' => $update_target_version, - 'backup_status' => 'complete', - ]; - write_backup_metadata($backup_dir, $metadata); - $append_log("Backup metadata written (backup.json)."); - - // 7. Final validation — both required files must be present and non-empty - $sql_file = $backup_dir . '/database.sql'; - $tar_file = $backup_dir . '/panel-files.tar.gz'; - - if (!file_exists($sql_file) || filesize($sql_file) < 100) { - $append_log("ERROR: Validation failed — database.sql is missing or empty."); - return ['success' => false, 'error' => 'Backup validation failed: database.sql is missing or empty.']; - } - if (!file_exists($tar_file) || filesize($tar_file) < 100) { - $append_log("ERROR: Validation failed — panel-files.tar.gz is missing or empty."); - return ['success' => false, 'error' => 'Backup validation failed: panel-files.tar.gz is missing or empty.']; - } - - $append_log("Backup validated and complete."); - - return [ - 'success' => true, - 'backup_dir' => $backup_dir, - 'backup_ts' => $ts, - ]; +$apache_source = '/etc/apache2/sites-available'; +if (!is_dir($apache_source)) { +return ['success' => false, 'error' => 'Apache sites-available path not found: ' . $apache_source]; +} +$dest = $backup_dir . '/apache-sites-available'; +if (!@mkdir($dest, 0755, true) && !is_dir($dest)) { +return ['success' => false, 'error' => 'Cannot create apache backup directory.']; +} +$files = glob($apache_source . '/*.conf') ?: []; +foreach ($files as $file) { +@copy($file, $dest . '/' . basename($file)); +} +return ['success' => true, 'path' => $dest, 'count' => count($files)]; +} + +function gsp_prune_old_backups($max_backups = 5) +{ +$entries = gsp_get_available_backups(); +if (count($entries) <= $max_backups) { +return; +} +$to_delete = array_slice($entries, $max_backups); +foreach ($to_delete as $entry) { +gsp_rmdir_recursive(GSP_BACKUP_BASE . '/' . $entry['ts']); +gsp_update_log('Pruned old backup: ' . $entry['ts']); +} +} + +function gsp_create_full_backup($update_target_type, $update_target_version, $include_apache = false) +{ +$ts = date('Y-m-d_H-i-s'); +$backup_dir = GSP_BACKUP_BASE . '/' . $ts; +if (!is_dir(GSP_BACKUP_BASE) && !@mkdir(GSP_BACKUP_BASE, 0755, true)) { +return ['success' => false, 'error' => 'Cannot create backup base directory.']; +} +if (!@mkdir($backup_dir, 0755, true)) { +return ['success' => false, 'error' => 'Cannot create backup directory: ' . $backup_dir]; +} + +$meta = [ +'backup_timestamp' => $ts, +'gsp_root' => GSP_ROOT_DIR, +'panel_root' => GSP_PANEL_DIR, +'website_root' => GSP_WEBSITE_DIR, +'update_target_type' => $update_target_type, +'update_target_version' => $update_target_version, +'created_at' => date('Y-m-d H:i:s'), +]; + +$db_config = gsp_load_panel_db_config(); +if ($db_config !== null) { +$db_backup = gsp_create_database_backup($backup_dir, $db_config); +if (!$db_backup['success']) { +return ['success' => false, 'error' => $db_backup['error']]; +} +$meta['database_backup'] = basename($db_backup['file']); +} + +$panel_backup = gsp_create_archive( +GSP_PANEL_DIR, +$backup_dir . '/panel-files.tar.gz', +['./backups', './logs', './cache', './tmp', './node_modules', './vendor'] +); +if (!$panel_backup['success']) { +return ['success' => false, 'error' => $panel_backup['error']]; +} +$meta['panel_archive'] = basename($panel_backup['file']); + +$website_backup = gsp_create_archive( +GSP_WEBSITE_DIR, +$backup_dir . '/website-files.tar.gz', +['./logs', './cache', './tmp'] +); +if (!$website_backup['success']) { +return ['success' => false, 'error' => $website_backup['error']]; +} +$meta['website_archive'] = basename($website_backup['file']); + +if (file_exists(GSP_VERSION_JSON)) { +@copy(GSP_VERSION_JSON, $backup_dir . '/version.json.bak'); +$meta['version_json_backup'] = 'version.json.bak'; +} + +if ($include_apache) { +$apache_backup = gsp_backup_apache_configs($backup_dir); +if ($apache_backup['success']) { +$meta['apache_backup'] = $apache_backup['path']; +} else { +$meta['apache_backup_error'] = $apache_backup['error']; +} +} + +@file_put_contents($backup_dir . '/backup.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +gsp_prune_old_backups(5); +gsp_update_log('Backup created: ' . $backup_dir); + +return [ +'success' => true, +'backup_dir' => $backup_dir, +'backup_ts' => $ts, +]; } -// --------------------------------------------------------------------------- -// Update: download the GitHub archive ZIP for a given ref (tag or branch) -// --------------------------------------------------------------------------- function gsp_download_zip($repo_owner, $repo_name, $ref, $temp_dir) { - // GitHub returns a redirect from /zipball/ to the actual download URL - $url = "https://api.github.com/repos/{$repo_owner}/{$repo_name}/zipball/{$ref}"; - $zip_file = $temp_dir . '/gsp_update.zip'; - - if (function_exists('curl_init')) { - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'GSP-Panel-Updater'); - curl_setopt($ch, CURLOPT_TIMEOUT, 180); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - $data = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($code !== 200 || !$data) { - return false; - } - file_put_contents($zip_file, $data); - } else { - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: GSP-Panel-Updater\r\n", - 'timeout' => 180, - 'follow_location' => 1, - ], - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - $data = @file_get_contents($url, false, $ctx); - if (!$data) { - return false; - } - file_put_contents($zip_file, $data); - } - - if (!file_exists($zip_file) || filesize($zip_file) < 1000) { - return false; - } - return $zip_file; +$url = "https://api.github.com/repos/{$repo_owner}/{$repo_name}/zipball/{$ref}"; +$zip_file = $temp_dir . '/gsp_update.zip'; +if (function_exists('curl_init')) { +$ch = curl_init($url); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +curl_setopt($ch, CURLOPT_USERAGENT, 'GSP-Panel-Updater'); +curl_setopt($ch, CURLOPT_TIMEOUT, 180); +curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); +curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); +curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); +$data = curl_exec($ch); +$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +if ($code !== 200 || !$data) { +return false; +} +@file_put_contents($zip_file, $data); +} else { +$ctx = stream_context_create([ +'http' => [ +'method' => 'GET', +'header' => "User-Agent: GSP-Panel-Updater\r\n", +'timeout' => 180, +], +'ssl' => [ +'verify_peer' => true, +'verify_peer_name' => true, +], +]); +$data = @file_get_contents($url, false, $ctx); +if (!$data) { +return false; +} +@file_put_contents($zip_file, $data); +} +if (!file_exists($zip_file) || filesize($zip_file) < 1000) { +return false; +} +return $zip_file; } -// --------------------------------------------------------------------------- -// Update: apply the downloaded zip to the panel directory -// --------------------------------------------------------------------------- -function gsp_apply_update($zip_file) +function gsp_extract_update_source($zip_file) { - $panel_dir = GSP_PANEL_DIR; - - // Files to never overwrite when applying an update - $preserve = [ - 'includes/config.inc.php', - 'modules/gamemanager/rsync_sites_local.list', - 'install.php', - ]; - - // Merge with the DB update-blacklist (strip leading slash from stored paths) - global $db; - $blacklisted = $db->resultQuery('SELECT file_path FROM `OGP_DB_PREFIXupdate_blacklist`;'); - if ($blacklisted !== false) { - foreach ((array)$blacklisted as $bf) { - $preserve[] = ltrim($bf['file_path'], '/'); - } - } - - // Extract ZIP to a temporary directory - $temp_dir = sys_get_temp_dir() . '/gsp_upd_' . time(); - if (!@mkdir($temp_dir, 0750)) { - return ['success' => false, 'error' => 'Cannot create temporary extraction directory.']; - } - - require_once($panel_dir . '/modules/update/unzip.php'); - $result = extractZip($zip_file, $temp_dir); - if (!is_array($result)) { - gsp_rmdir_recursive($temp_dir); - return ['success' => false, 'error' => 'ZIP extraction failed: ' . $result]; - } - - // GitHub archives place all files under a single subdirectory (e.g. "Owner-Repo-sha/") - // Detect that prefix directory - $src_dir = $temp_dir; - $subdirs = glob($temp_dir . '/*', GLOB_ONLYDIR); - if ($subdirs && count($subdirs) === 1) { - $src_dir = $subdirs[0]; - } - - $panel_src = gsp_locate_panel_source_dir($src_dir); - if ($panel_src === null) { - gsp_rmdir_recursive($temp_dir); - return ['success' => false, 'error' => 'Update archive does not contain a valid panel source directory.']; - } - $src_dir = $panel_src; - - // Copy files from the extracted panel source into the panel directory - $copied = 0; - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($src_dir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - - foreach ($iter as $item) { - $rel = str_replace('\\', '/', substr($item->getPathname(), strlen($src_dir) + 1)); - - // Skip preserved/blacklisted files - if (in_array($rel, $preserve)) { - continue; - } - - $dst = $panel_dir . '/' . $rel; - - if ($item->isDir()) { - if (!is_dir($dst)) { - @mkdir($dst, 0755, true); - } - } else { - $dst_parent = dirname($dst); - if (!is_dir($dst_parent)) { - @mkdir($dst_parent, 0755, true); - } - if (@copy($item->getPathname(), $dst)) { - $copied++; - } - } - } - - gsp_rmdir_recursive($temp_dir); - return ['success' => true, 'files_copied' => $copied]; +$temp_dir = sys_get_temp_dir() . '/gsp_upd_' . time() . '_' . mt_rand(1000, 9999); +if (!@mkdir($temp_dir, 0750, true)) { +return ['success' => false, 'error' => 'Cannot create temp extraction directory.']; +} +require_once(GSP_PANEL_DIR . '/modules/update/unzip.php'); +$result = extractZip($zip_file, $temp_dir); +if (!is_array($result)) { +gsp_rmdir_recursive($temp_dir); +return ['success' => false, 'error' => 'ZIP extraction failed: ' . $result]; +} +$source_root = $temp_dir; +$subdirs = glob($temp_dir . '/*', GLOB_ONLYDIR); +if ($subdirs && count($subdirs) === 1) { +$source_root = $subdirs[0]; +} +return ['success' => true, 'temp_dir' => $temp_dir, 'source_root' => $source_root]; } -// --------------------------------------------------------------------------- -// Helper: recursively remove a directory -// --------------------------------------------------------------------------- -function gsp_rmdir_recursive($dir) +function gsp_normalize_rel($path) { - if (!is_dir($dir)) { - return; - } - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($iter as $item) { - if ($item->isDir()) { - @rmdir($item->getPathname()); - } else { - @unlink($item->getPathname()); - } - } - @rmdir($dir); +$path = str_replace('\\', '/', $path); +$path = ltrim($path, '/'); +return $path; } -// --------------------------------------------------------------------------- -// Post-update helpers -// --------------------------------------------------------------------------- -function gsp_fix_permissions($panel_dir) +function gsp_is_preserved_path($relative_path) { - // Restore sane defaults in a single find traversal: files 644, directories 755 - @system( - 'find ' . escapeshellarg($panel_dir) - . ' -maxdepth 10 \( -name "*.php" -exec chmod 644 {} + \)' - . ' -o \( -type d -exec chmod 755 {} + \)' - . ' 2>/dev/null' - ); +$relative_path = gsp_normalize_rel($relative_path); +$exact = [ +'Panel/includes/config.inc.php', +]; +$prefixes = [ +'logs/', +'backups/', +'Panel/logs/', +'Panel/backups/', +'Website/logs/', +'Website/uploads/', +'Website/upload/', +'Panel/uploads/', +'Panel/upload/', +]; +if (in_array($relative_path, $exact, true)) { +return true; +} +foreach ($prefixes as $prefix) { +if (strpos($relative_path, $prefix) === 0) { +return true; +} +} +return false; +} + +function gsp_copy_file($src, $dst) +{ +$parent = dirname($dst); +if (!is_dir($parent)) { +@mkdir($parent, 0755, true); +} +return @copy($src, $dst); +} + +function gsp_copy_tree($src_root, $dst_root, $base_rel = '') +{ +$copied = 0; +$skipped = []; +$source = rtrim($src_root, '/'); +$iter = new RecursiveIteratorIterator( +new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), +RecursiveIteratorIterator::SELF_FIRST +); +foreach ($iter as $item) { +$rel = gsp_normalize_rel(($base_rel !== '' ? $base_rel . '/' : '') . substr($item->getPathname(), strlen($source) + 1)); +if (gsp_is_preserved_path($rel)) { +$skipped[] = $rel; +continue; +} +$dst = rtrim($dst_root, '/') . '/' . $rel; +if ($item->isDir()) { +if (!is_dir($dst)) { +@mkdir($dst, 0755, true); +} +continue; +} +if (gsp_copy_file($item->getPathname(), $dst)) { +$copied++; +} +} +return ['copied' => $copied, 'skipped' => $skipped]; +} + +function gsp_updater_watch_list() +{ +return [ +'Panel/modules/administration/panel_update.php', +'Panel/modules/update/update.php', +'Panel/modules/update/updating.php', +'Panel/modules/update/post_update.php', +'Panel/modules/update/module.php', +'Panel/modules/update/unzip.php', +'Panel/modules/update/blacklist.php', +'Panel/modules/update/patch_manager.php', +'Panel/modules/update/patches', +]; +} + +function gsp_collect_files_under($path, $base_rel) +{ +$list = []; +if (is_file($path)) { +$list[] = gsp_normalize_rel($base_rel); +return $list; +} +if (!is_dir($path)) { +return $list; +} +$iter = new RecursiveIteratorIterator( +new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), +RecursiveIteratorIterator::LEAVES_ONLY +); +foreach ($iter as $item) { +if ($item->isFile()) { +$list[] = gsp_normalize_rel($base_rel . '/' . substr($item->getPathname(), strlen($path) + 1)); +} +} +return $list; +} + +function gsp_detect_updater_drift_files($source_root, $target_root) +{ +$drift = []; +foreach (gsp_updater_watch_list() as $rel) { +$src = rtrim($source_root, '/') . '/' . $rel; +$files = gsp_collect_files_under($src, $rel); +foreach ($files as $fileRel) { +$s = rtrim($source_root, '/') . '/' . $fileRel; +$d = rtrim($target_root, '/') . '/' . $fileRel; +if (!file_exists($d)) { +$drift[] = $fileRel; +continue; +} +if (@hash_file('sha256', $s) !== @hash_file('sha256', $d)) { +$drift[] = $fileRel; +} +} +} +return array_values(array_unique($drift)); +} + +function gsp_apply_updater_files_only($source_root, $target_root, array $drift_files) +{ +$copied = 0; +foreach ($drift_files as $rel) { +$src = rtrim($source_root, '/') . '/' . $rel; +$dst = rtrim($target_root, '/') . '/' . $rel; +if (is_file($src) && gsp_copy_file($src, $dst)) { +$copied++; +} +} +return $copied; +} + +function gsp_apply_layout_sync($source_root) +{ +$top_level = scandir($source_root); +$skip = ['.', '..', '.git', '.github', '.gitignore', '.vscode']; +$copied = 0; +$skipped = []; +foreach ((array)$top_level as $entry) { +if (in_array($entry, $skip, true)) { +continue; +} +$src = rtrim($source_root, '/') . '/' . $entry; +$dst = GSP_ROOT_DIR . '/' . $entry; +if ($entry === 'backups') { +$skipped[] = 'backups/'; +continue; +} +if (is_file($src)) { +$rel = gsp_normalize_rel($entry); +if (gsp_is_preserved_path($rel)) { +$skipped[] = $rel; +continue; +} +if (gsp_copy_file($src, $dst)) { +$copied++; +} +continue; +} +if (is_dir($src)) { +$part = gsp_copy_tree($src, GSP_ROOT_DIR, $entry); +$copied += $part['copied']; +$skipped = array_merge($skipped, $part['skipped']); +} +} +return [ +'success' => true, +'files_copied' => $copied, +'skipped' => array_values(array_unique($skipped)), +]; +} + +function gsp_run_required_patches($updater_version) +{ +global $db; +if (!function_exists('gsp_patch_run_all')) { +return ['success' => false, 'error' => 'Patch manager helper is unavailable.']; +} +$run = gsp_patch_run_all($db, GSP_PATCH_DIR, 'gsp_update_log', $updater_version); +if (!$run['success']) { +return [ +'success' => false, +'error' => 'Patch failure at ' . $run['failed_patch'] . ': ' . $run['error'], +'run' => $run, +]; +} +return ['success' => true, 'run' => $run]; +} + +function gsp_apply_update_from_zip($zip_file, $restart_nonce = '') +{ +$extract = gsp_extract_update_source($zip_file); +if (!$extract['success']) { +return $extract; +} +$temp_dir = $extract['temp_dir']; +$source_root = $extract['source_root']; +$updater_version = substr((string)@hash_file('sha256', $source_root . '/Panel/modules/administration/panel_update.php'), 0, 12); + +$drift_files = gsp_detect_updater_drift_files($source_root, GSP_ROOT_DIR); +if (!empty($drift_files) && empty($restart_nonce)) { +$copied = gsp_apply_updater_files_only($source_root, GSP_ROOT_DIR, $drift_files); +$nonce = gsp_random_token(12); +$_SESSION['gsp_update_restart_nonce'] = $nonce; +gsp_update_log('Updater self-update applied (' . $copied . ' files); restart nonce=' . $nonce); +gsp_rmdir_recursive($temp_dir); +return [ +'success' => false, +'restart_required' => true, +'restart_nonce' => $nonce, +'updater_files_updated' => $copied, +'drift_files' => $drift_files, +]; +} +if (!empty($restart_nonce)) { +$expected = isset($_SESSION['gsp_update_restart_nonce']) ? $_SESSION['gsp_update_restart_nonce'] : null; +if ($expected === null || !hash_equals($expected, $restart_nonce)) { +gsp_rmdir_recursive($temp_dir); +return ['success' => false, 'error' => 'Invalid updater restart marker.']; +} +unset($_SESSION['gsp_update_restart_nonce']); +} + +$patches = gsp_run_required_patches($updater_version); +if (!$patches['success']) { +gsp_rmdir_recursive($temp_dir); +return ['success' => false, 'error' => $patches['error']]; +} + +$sync = gsp_apply_layout_sync($source_root); +gsp_rmdir_recursive($temp_dir); +if (!$sync['success']) { +return $sync; +} +gsp_update_log('Layout sync complete: copied=' . $sync['files_copied'] . ', skipped=' . count($sync['skipped'])); +if (!empty($sync['skipped'])) { +gsp_update_log('Preserved paths: ' . implode(', ', array_slice($sync['skipped'], 0, 50))); +} +return [ +'success' => true, +'files_copied' => $sync['files_copied'], +'preserved' => $sync['skipped'], +'patches' => $patches['run'], +]; +} + +function gsp_fix_permissions($root_dir) +{ +@system( +'find ' . escapeshellarg($root_dir) +. ' -maxdepth 12 \( -name "*.php" -exec chmod 644 {} + \)' +. ' -o \( -type d -exec chmod 755 {} + \) 2>/dev/null' +); } function gsp_clear_panel_cache($panel_dir) { - foreach (['cache', 'temp'] as $dir) { - $cache = $panel_dir . '/' . $dir; - if (!is_dir($cache)) { - continue; - } - foreach (glob($cache . '/*.php') ?: [] as $f) { - @unlink($f); - } - foreach (glob($cache . '/*.cache') ?: [] as $f) { - @unlink($f); - } - } +foreach (['cache', 'temp'] as $dir) { +$cache = $panel_dir . '/' . $dir; +if (!is_dir($cache)) { +continue; +} +foreach (glob($cache . '/*.php') ?: [] as $f) { +@unlink($f); +} +foreach (glob($cache . '/*.cache') ?: [] as $f) { +@unlink($f); +} +} +} + +function gsp_do_update($repo_owner, $repo_name, $ref, $update_type, $restart_nonce = '') +{ +global $db; +$preflight = gsp_preflight_check(); +if (!$preflight['success']) { +return ['success' => false, 'error' => 'Preflight failed: ' . implode(' | ', $preflight['errors'])]; +} + +$backup = gsp_create_full_backup($update_type, $ref, false); +if (!$backup['success']) { +return $backup; +} +gsp_update_log("Backup created at {$backup['backup_ts']} before {$update_type} update to {$ref}"); + +$temp_dir = sys_get_temp_dir() . '/gsp_dl_' . time() . '_' . mt_rand(1000, 9999); +@mkdir($temp_dir, 0750, true); +$zip_file = gsp_download_zip($repo_owner, $repo_name, $ref, $temp_dir); +if (!$zip_file) { +gsp_rmdir_recursive($temp_dir); +return ['success' => false, 'error' => 'Failed to download update ZIP from GitHub.']; +} + +$apply = gsp_apply_update_from_zip($zip_file, $restart_nonce); +@unlink($zip_file); +gsp_rmdir_recursive($temp_dir); +if (!empty($apply['restart_required'])) { +$apply['backup_dir'] = $backup['backup_dir']; +$apply['success'] = false; +return $apply; +} +if (!$apply['success']) { +return $apply; +} + +$commit_after = gsp_get_git_commit(); +gsp_fix_permissions(GSP_ROOT_DIR); +gsp_clear_panel_cache(GSP_PANEL_DIR); +gsp_write_version_file($ref, $update_type); +$vsource = ($update_type === 'release') ? 'GitHub Releases' : $ref; +$vversion = ($update_type === 'release') ? $ref : ($commit_after ?: $ref); +gsp_write_version_json($update_type, $vsource, $vversion, $commit_after); +$db->setSettings(['ogp_version' => $ref, 'version_type' => $update_type]); + +if (file_exists(GSP_PANEL_DIR . '/modules/modulemanager/module_handling.php')) { +require_once(GSP_PANEL_DIR . '/modules/modulemanager/module_handling.php'); +} +if (function_exists('updateAllPanelModules')) { +updateAllPanelModules(); +} +if (function_exists('runPostUpdateOperations')) { +runPostUpdateOperations(); +} + +gsp_update_log("Update to {$ref} (type={$update_type}) complete"); +return [ +'success' => true, +'files_copied' => $apply['files_copied'], +'backup_dir' => $backup['backup_dir'], +'preserved' => $apply['preserved'], +'patches' => $apply['patches'], +]; } -// --------------------------------------------------------------------------- -// List available backup timestamps under GSP_BACKUP_BASE -// --------------------------------------------------------------------------- function gsp_get_available_backups() { - $backups = []; - if (!is_dir(GSP_BACKUP_BASE)) { - return $backups; - } - foreach ((array)scandir(GSP_BACKUP_BASE) as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - if (!is_dir(GSP_BACKUP_BASE . '/' . $entry)) { - continue; - } - if (!preg_match('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/', $entry)) { - continue; - } - $meta_file = GSP_BACKUP_BASE . '/' . $entry . '/backup.json'; - $meta = []; - if (file_exists($meta_file)) { - $meta = json_decode(file_get_contents($meta_file), true) ?: []; - } - $backups[] = ['ts' => $entry, 'meta' => $meta]; - } - // Newest first - usort($backups, function ($a, $b) { - return strcmp($b['ts'], $a['ts']); - }); - return $backups; +$backups = []; +if (!is_dir(GSP_BACKUP_BASE)) { +return $backups; +} +foreach ((array)scandir(GSP_BACKUP_BASE) as $entry) { +if ($entry === '.' || $entry === '..') { +continue; +} +$dir = GSP_BACKUP_BASE . '/' . $entry; +if (!is_dir($dir)) { +continue; +} +if (!preg_match('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/', $entry)) { +continue; +} +$meta_file = $dir . '/backup.json'; +$meta = []; +if (file_exists($meta_file)) { +$meta = json_decode(@file_get_contents($meta_file), true) ?: []; +} +$backups[] = ['ts' => $entry, 'meta' => $meta]; +} +usort($backups, function ($a, $b) { +return strcmp($b['ts'], $a['ts']); +}); +return $backups; } -// --------------------------------------------------------------------------- -// Update: attempt a git-based update (fetch + checkout + reset --hard) -// Returns an array on success or null if git is unavailable / fails. -// --------------------------------------------------------------------------- -function gsp_try_git_update($branch) +function gsp_restore_archive($archive, $target) { - $repo_root = gsp_detect_repo_root(); - if (!function_exists('exec') || !$repo_root) { - return null; - } - - $panel_source = gsp_locate_panel_source_dir($repo_root); - if ($panel_source === null) { - gsp_update_log("Git update aborted: panel source directory not found under repo root {$repo_root}"); - return null; - } - - $panel_arg = escapeshellarg($repo_root); - $branch_arg = escapeshellarg($branch); - $origin_ref = escapeshellarg('origin/' . $branch); - - $out = []; - $ret = 0; - - exec("git -C {$panel_arg} fetch origin {$branch_arg} --tags 2>&1", $out, $ret); - if ($ret !== 0) { - gsp_update_log("Git fetch for branch {$branch} failed (exit {$ret}): " . implode(' | ', $out)); - return null; - } - - exec("git -C {$panel_arg} checkout {$branch_arg} 2>&1", $out, $ret); - exec("git -C {$panel_arg} reset --hard {$origin_ref} 2>&1", $out, $ret); - if ($ret !== 0) { - gsp_update_log("Git checkout/reset for branch {$branch} failed (exit {$ret}): " . implode(' | ', $out)); - return null; - } - - $commit_out = []; - $ret2 = 0; - exec("git -C {$panel_arg} rev-parse HEAD 2>&1", $commit_out, $ret2); - $commit = ($ret2 === 0 && !empty($commit_out[0]) && preg_match('/^[0-9a-f]{40,64}$/i', trim($commit_out[0]))) - ? trim($commit_out[0]) - : null; - - return [ - 'success' => true, - 'method' => 'git', - 'commit' => $commit, - 'output' => implode("\n", $out), - ]; +if (!file_exists($archive)) { +return ['success' => false, 'error' => 'Archive not found: ' . $archive]; +} +$out = []; +$ret = 0; +exec('tar -xzf ' . escapeshellarg($archive) . ' -C ' . escapeshellarg($target) . ' 2>&1', $out, $ret); +if ($ret !== 0) { +return ['success' => false, 'error' => 'tar extract failed: ' . implode(' | ', $out)]; +} +return ['success' => true]; } -// --------------------------------------------------------------------------- -// Orchestrate a full update -// --------------------------------------------------------------------------- -function gsp_do_update($repo_owner, $repo_name, $ref, $update_type) +function gsp_restore_database_from_backup($backup_dir) { - global $db; - $panel_dir = GSP_PANEL_DIR; - - // Step 1 — backup - $backup = gsp_create_full_backup($update_type, $ref); - if (!$backup['success']) { - return $backup; // contains 'error' - } - gsp_update_log("Backup created at {$backup['backup_ts']} before {$update_type} update to {$ref}"); - - // Step 2 — try git for branch updates; fall back to ZIP download - $commit_after = null; - $files_copied = 0; - $used_git = false; - - if ($update_type !== 'release') { - $git_result = gsp_try_git_update($ref); - if ($git_result && $git_result['success']) { - $commit_after = $git_result['commit']; - $used_git = true; - gsp_update_log("Updated via git to {$ref}: " . $git_result['output']); - } else { - gsp_update_log("Git update not available or failed for {$ref}; falling back to ZIP download"); - } - } - - if (!$used_git) { - $temp_dir = sys_get_temp_dir() . '/gsp_dl_' . time(); - @mkdir($temp_dir, 0750); - $zip_file = gsp_download_zip($repo_owner, $repo_name, $ref, $temp_dir); - if (!$zip_file) { - @rmdir($temp_dir); - return [ - 'success' => false, - 'error' => 'Failed to download update ZIP from GitHub. Check network connectivity.', - ]; - } - gsp_update_log("Downloaded update ZIP for ref={$ref}"); - - $apply = gsp_apply_update($zip_file); - @unlink($zip_file); - @rmdir($temp_dir); - if (!$apply['success']) { - return $apply; - } - $files_copied = $apply['files_copied']; - $commit_after = gsp_get_git_commit(); - gsp_update_log("Applied update via ZIP: {$apply['files_copied']} files written"); - } - - // Step 3 — housekeeping - gsp_fix_permissions($panel_dir); - gsp_clear_panel_cache($panel_dir); - gsp_write_version_file($ref, $update_type); - - // Write version.json with canonical type/source/version data - $vsource = ($update_type === 'release') ? 'GitHub Releases' : $ref; - $vversion = ($update_type === 'release') ? $ref : ($commit_after ?? $ref); - gsp_write_version_json($update_type, $vsource, $vversion, $commit_after); - - $db->setSettings(['ogp_version' => $ref, 'version_type' => $update_type]); - - // Step 4 — post-update module handling (mirrors updating.php behaviour) - if (file_exists($panel_dir . '/modules/modulemanager/module_handling.php')) { - require_once($panel_dir . '/modules/modulemanager/module_handling.php'); - } - if (function_exists('updateAllPanelModules')) { - updateAllPanelModules(); - } - if (function_exists('runPostUpdateOperations')) { - runPostUpdateOperations(); - } - - gsp_update_log("Update to {$ref} (type={$update_type}) complete"); - return ['success' => true, 'files_copied' => $files_copied, 'backup_dir' => $backup['backup_dir']]; +$sql_file = $backup_dir . '/database.sql'; +if (!file_exists($sql_file)) { +return ['success' => true, 'skipped' => true]; +} +$db_config = gsp_load_panel_db_config(); +if ($db_config === null) { +return ['success' => false, 'error' => 'Cannot load DB config for restore.']; +} +$creds_file = tempnam(sys_get_temp_dir(), 'gsp_db_'); +if ($creds_file === false) { +return ['success' => false, 'error' => 'Cannot create temporary DB credential file.']; +} +$creds = "[client]\n" +. "user=" . addcslashes($db_config['user'], "\\\n") . "\n" +. "password=" . addcslashes($db_config['pass'], "\\\n") . "\n"; +if (!empty($db_config['host'])) { +$creds .= "host=" . addcslashes($db_config['host'], "\\\n") . "\n"; +} +if (!empty($db_config['port']) && $db_config['port'] !== '3306') { +$creds .= "port=" . addcslashes($db_config['port'], "\\\n") . "\n"; +} +@file_put_contents($creds_file, $creds); +@chmod($creds_file, 0600); +$cmd = 'mysql --defaults-extra-file=' . escapeshellarg($creds_file) +. ' ' . escapeshellarg($db_config['name']) +. ' < ' . escapeshellarg($sql_file) +. ' 2>&1'; +$out = []; +$ret = 0; +exec($cmd, $out, $ret); +@unlink($creds_file); +if ($ret !== 0) { +return ['success' => false, 'error' => 'MySQL restore failed: ' . implode(' | ', $out)]; +} +return ['success' => true]; } -// --------------------------------------------------------------------------- -// Orchestrate a revert to a previous backup -// --------------------------------------------------------------------------- -function gsp_do_revert($backup_ts) +function gsp_do_revert($backup_ts, $restore_apache = false) { - global $db; - $panel_dir = GSP_PANEL_DIR; - $backup_dir = GSP_BACKUP_BASE . '/' . $backup_ts; - - if (!is_dir($backup_dir)) { - return ['success' => false, 'error' => 'Backup directory not found: ' . htmlspecialchars($backup_ts)]; - } - - // Detect backup format: new (panel-files.tar.gz) or legacy (files/ directory) - $tar_archive = $backup_dir . '/panel-files.tar.gz'; - $legacy_files_dir = $backup_dir . '/files'; - $use_tar = file_exists($tar_archive); - - if (!$use_tar && !is_dir($legacy_files_dir)) { - return ['success' => false, 'error' => 'Backup files not found (expected panel-files.tar.gz or files/ directory).']; - } - - // Detect SQL file: new (database.sql) or legacy glob *.sql - $sql_file = $backup_dir . '/database.sql'; - if (!file_exists($sql_file)) { - $sql_candidates = glob($backup_dir . '/*.sql') ?: []; - $sql_file = !empty($sql_candidates) ? $sql_candidates[0] : null; - } - if (!$sql_file || !file_exists($sql_file)) { - return ['success' => false, 'error' => 'No SQL dump found in backup.']; - } - - // Enable maintenance mode for the duration of the revert - $had_maintenance = isset($db->getSettings()['maintenance_mode']) - && $db->getSettings()['maintenance_mode'] == '1'; - if (!$had_maintenance) { - $db->setSettings([ - 'maintenance_mode' => '1', - 'maintenance_title' => 'Reverting...', - 'maintenance_message' => 'The panel is being reverted to a previous version. Please wait.', - ]); - } - - // Restore files - $files_restored = 0; - if ($use_tar) { - // New format: extract tar archive back to the panel root - $out = []; - $ret = 0; - exec('tar -xzf ' . escapeshellarg($tar_archive) . ' -C ' . escapeshellarg($panel_dir) . ' 2>&1', $out, $ret); - if ($ret === 0) { - // tar restores files in place; it does not provide a count, - // so use 0 here — callers should not rely on an exact file count for the tar path. - $files_restored = 0; - gsp_update_log("Revert: extracted panel-files.tar.gz to {$panel_dir}"); - } else { - gsp_update_log("Revert warning: tar extraction exited with code {$ret}: " . implode(' | ', $out)); - } - } else { - // Legacy format: recursive copy from files/ directory - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($legacy_files_dir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($iter as $item) { - $rel = substr($item->getPathname(), strlen($legacy_files_dir)); - $dst = $panel_dir . $rel; - if ($item->isDir()) { - if (!is_dir($dst)) { - @mkdir($dst, 0755, true); - } - } else { - $dst_parent = dirname($dst); - if (!is_dir($dst_parent)) { - @mkdir($dst_parent, 0755, true); - } - if (@copy($item->getPathname(), $dst)) { - $files_restored++; - } - } - } - gsp_update_log("Revert: restored {$files_restored} files from legacy backup {$backup_ts}"); - } - - // Restore database using credentials from config - $db_config = load_panel_db_config(); - if ($db_config !== null) { - $creds_file = tempnam(sys_get_temp_dir(), 'gsp_db_'); - if ($creds_file !== false) { - $creds = "[client]\n" - . "user=" . addcslashes($db_config['user'], "\\\n") . "\n" - . "password=" . addcslashes($db_config['pass'], "\\\n") . "\n"; - if (!empty($db_config['host'])) { - $creds .= "host=" . addcslashes($db_config['host'], "\\\n") . "\n"; - } - if (!empty($db_config['port']) && $db_config['port'] !== '3306') { - $creds .= "port=" . addcslashes($db_config['port'], "\\\n") . "\n"; - } - file_put_contents($creds_file, $creds); - chmod($creds_file, 0600); - $cmd = 'mysql --defaults-extra-file=' . escapeshellarg($creds_file) - . ' ' . escapeshellarg($db_config['name']) - . ' < ' . escapeshellarg($sql_file) - . ' 2>&1'; - $unused_out = []; - $ret = 0; - exec($cmd, $unused_out, $ret); - @unlink($creds_file); - if ($ret !== 0) { - gsp_update_log("Revert warning: database restore exited with code {$ret}"); - } - } - } else { - gsp_update_log("Revert warning: could not load DB config; database was not restored."); - } - - // Housekeeping - gsp_fix_permissions($panel_dir); - gsp_clear_panel_cache($panel_dir); - - // Turn off maintenance mode (unless it was already on before we started) - if (!$had_maintenance) { - $db->setSettings(['maintenance_mode' => '0']); - } - - gsp_update_log("Revert to backup {$backup_ts} complete"); - return ['success' => true, 'files_restored' => $files_restored]; +global $db; +$backup_dir = GSP_BACKUP_BASE . '/' . $backup_ts; +if (!is_dir($backup_dir)) { +return ['success' => false, 'error' => 'Backup not found: ' . $backup_ts]; +} + +$had_maintenance = isset($db->getSettings()['maintenance_mode']) && $db->getSettings()['maintenance_mode'] == '1'; +if (!$had_maintenance) { +$db->setSettings([ +'maintenance_mode' => '1', +'maintenance_title' => 'Reverting...', +'maintenance_message' => 'The panel is being reverted to a previous backup. Please wait.', +]); +} + +$panel_restore = gsp_restore_archive($backup_dir . '/panel-files.tar.gz', GSP_PANEL_DIR); +if (!$panel_restore['success']) { +if (!$had_maintenance) { +$db->setSettings(['maintenance_mode' => '0']); +} +return $panel_restore; +} +$website_archive = $backup_dir . '/website-files.tar.gz'; +if (file_exists($website_archive)) { +$website_restore = gsp_restore_archive($website_archive, GSP_WEBSITE_DIR); +if (!$website_restore['success']) { +if (!$had_maintenance) { +$db->setSettings(['maintenance_mode' => '0']); +} +return $website_restore; +} +} +if (file_exists($backup_dir . '/version.json.bak')) { +@copy($backup_dir . '/version.json.bak', GSP_VERSION_JSON); +} +$db_restore = gsp_restore_database_from_backup($backup_dir); +if (!$db_restore['success']) { +gsp_update_log('Revert warning: ' . $db_restore['error']); +} + +if ($restore_apache && is_dir($backup_dir . '/apache-sites-available')) { +$apache_restore = gsp_restore_apache_backup($backup_dir . '/apache-sites-available', true); +if (!$apache_restore['success']) { +gsp_update_log('Revert apache restore warning: ' . $apache_restore['error']); +} +} + +gsp_fix_permissions(GSP_ROOT_DIR); +gsp_clear_panel_cache(GSP_PANEL_DIR); +if (!$had_maintenance) { +$db->setSettings(['maintenance_mode' => '0']); +} + +gsp_update_log('Revert complete: ' . $backup_ts); +return ['success' => true, 'files_restored' => 0]; +} + +function gsp_scan_apache_configs() +{ +$base = '/etc/apache2/sites-available'; +$result = [ +'success' => true, +'available' => is_dir($base), +'base' => $base, +'files' => [], +'issues' => [], +'recommendations' => [], +]; +if (!$result['available']) { +$result['success'] = false; +$result['issues'][] = 'Apache sites-available directory not found.'; +return $result; +} +$stale = [ +'/var/www/html/panel', +'/var/www/html/GSP/Panel/GSP/Panel', +'/var/www/html/GSP/Panel/modules/billing', +]; +$files = glob($base . '/*.conf') ?: []; +foreach ($files as $file) { +$lines = @file($file, FILE_IGNORE_NEW_LINES); +if (!is_array($lines)) { +continue; +} +$file_info = ['file' => $file, 'document_roots' => [], 'directories' => [], 'stale_hits' => []]; +foreach ($lines as $line) { +if (preg_match('/^\s*DocumentRoot\s+(.+)$/i', $line, $m)) { +$path = trim($m[1], "\"' "); +$file_info['document_roots'][] = $path; +} +if (preg_match('/^\s*/i', $line, $m)) { +$path = trim($m[1], "\"' "); +$file_info['directories'][] = $path; +} +foreach ($stale as $stalePath) { +if (strpos($line, $stalePath) !== false) { +$file_info['stale_hits'][] = $stalePath; +$result['issues'][] = basename($file) . ' contains stale path: ' . $stalePath; +} +} +} +if (!empty($file_info['stale_hits'])) { +if (stripos($file, 'gameservers.world') !== false) { +$result['recommendations'][] = basename($file) . ' should target ' . GSP_WEBSITE_DIR; +} else { +$result['recommendations'][] = basename($file) . ' should target ' . GSP_PANEL_DIR; +} +} +$result['files'][] = $file_info; +} +return $result; +} + +function gsp_restore_apache_backup($backup_dir, $reload_apache) +{ +$target = '/etc/apache2/sites-available'; +if (!is_dir($backup_dir)) { +return ['success' => false, 'error' => 'Apache backup folder not found.']; +} +$files = glob($backup_dir . '/*.conf') ?: []; +foreach ($files as $file) { +@copy($file, $target . '/' . basename($file)); +} +$test = gsp_apache_configtest(); +if (!$test['success']) { +return ['success' => false, 'error' => 'apache2ctl configtest failed after restore: ' . $test['output']]; +} +if ($reload_apache) { +gsp_apache_reload(); +} +return ['success' => true, 'restored' => count($files)]; +} + +function gsp_apache_configtest() +{ +$out = []; +$ret = 0; +exec('apache2ctl configtest 2>&1', $out, $ret); +return [ +'success' => ($ret === 0), +'output' => trim(implode("\n", $out)), +]; +} + +function gsp_apache_reload() +{ +$out = []; +$ret = 0; +exec('apache2ctl graceful 2>&1', $out, $ret); +return [ +'success' => ($ret === 0), +'output' => trim(implode("\n", $out)), +]; +} + +function gsp_fix_apache_paths($confirmed, $reload_apache) +{ +if (!$confirmed) { +return ['success' => false, 'error' => 'Apache path fix requires explicit confirmation.']; +} +$scan = gsp_scan_apache_configs(); +if (!$scan['available']) { +return ['success' => false, 'error' => 'Apache config folder not available.']; +} + +$backup = gsp_create_full_backup('apache-fix', 'apache-path-repair', true); +if (!$backup['success']) { +return ['success' => false, 'error' => 'Could not create backup before apache fix: ' . $backup['error']]; +} + +$base = '/etc/apache2/sites-available'; +$files = glob($base . '/*.conf') ?: []; +$replace = [ +'/var/www/html/panel' => GSP_PANEL_DIR, +'/var/www/html/GSP/Panel/GSP/Panel' => GSP_PANEL_DIR, +'/var/www/html/GSP/Panel/modules/billing' => GSP_WEBSITE_DIR, +]; +$changed = []; +foreach ($files as $file) { +$orig = @file_get_contents($file); +if ($orig === false) { +continue; +} +$new = strtr($orig, $replace); +if ($new !== $orig) { +@file_put_contents($file, $new); +$changed[] = $file; +} +} + +$test = gsp_apache_configtest(); +if (!$test['success']) { +$restore = gsp_restore_apache_backup($backup['backup_dir'] . '/apache-sites-available', false); +return [ +'success' => false, +'error' => 'apache2ctl configtest failed; restored backup. Output: ' . $test['output'] +. ($restore['success'] ? '' : (' | restore failed: ' . $restore['error'])), +]; +} + +$reload = ['success' => true, 'output' => 'Apache reload skipped']; +if ($reload_apache) { +$reload = gsp_apache_reload(); +} + +gsp_update_log('Apache path fix changed ' . count($changed) . ' file(s).'); +return [ +'success' => true, +'changed_files' => $changed, +'backup_dir' => $backup['backup_dir'], +'configtest' => $test, +'reload' => $reload, +]; +} + +function gsp_get_patch_overview() +{ +global $db; +if (!function_exists('gsp_patch_load_definitions') || !function_exists('gsp_patch_get_applied_map') || !function_exists('gsp_patch_state_fallback_file')) { +return ['available' => [], 'applied' => [], 'pending' => []]; +} +$defs = gsp_patch_load_definitions(GSP_PATCH_DIR); +$applied = gsp_patch_get_applied_map($db, gsp_patch_state_fallback_file()); +$pending = []; +foreach ($defs as $def) { +$id = (string)$def['id']; +if (!isset($applied[$id]) || $applied[$id]['status'] !== 'applied') { +$pending[] = $id; +} +} +return [ +'available' => $defs, +'applied' => $applied, +'pending' => $pending, +]; +} + +function gsp_rmdir_recursive($dir) +{ +if (!is_dir($dir)) { +return; +} +$iter = new RecursiveIteratorIterator( +new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), +RecursiveIteratorIterator::CHILD_FIRST +); +foreach ($iter as $item) { +if ($item->isDir()) { +@rmdir($item->getPathname()); +} else { +@unlink($item->getPathname()); +} +} +@rmdir($dir); +} + +function gsp_render_restart_form($action, $csrf_token, $nonce, $release_version = '') +{ +echo "
"; +echo ""; +echo ""; +echo ""; +if ($release_version !== '') { +echo ""; +} +echo "
"; +echo ""; } -// --------------------------------------------------------------------------- -// Main entry point — render the "Panel Updates" section -// --------------------------------------------------------------------------- function gsp_panel_update_section() { - global $db, $settings; +global $db, $settings; +if ($_SESSION['users_group'] !== 'admin') { +return; +} - // Guard: admins only - if ($_SESSION['users_group'] !== 'admin') { - return; - } +$repo_owner = !empty($settings['gsp_repo_owner']) ? $settings['gsp_repo_owner'] : 'GameServerPanel'; +$repo_name = !empty($settings['gsp_repo_name']) ? $settings['gsp_repo_name'] : 'GSP'; +$stable_branch = !empty($settings['gsp_stable_branch']) ? $settings['gsp_stable_branch'] : 'Panel-stable'; +$unstable_branch = !empty($settings['gsp_unstable_branch']) ? $settings['gsp_unstable_branch'] : 'Panel-unstable'; - // GitHub repository settings (with GSP defaults) - $repo_owner = !empty($settings['gsp_repo_owner']) ? $settings['gsp_repo_owner'] : 'GameServerPanel'; - $repo_name = !empty($settings['gsp_repo_name']) ? $settings['gsp_repo_name'] : 'GSP'; - $stable_branch = !empty($settings['gsp_stable_branch']) ? $settings['gsp_stable_branch'] : 'Panel-stable'; - $unstable_branch= !empty($settings['gsp_unstable_branch'])? $settings['gsp_unstable_branch']: 'Panel-unstable'; +if (empty($_SESSION['gsp_update_csrf'])) { +$_SESSION['gsp_update_csrf'] = gsp_random_token(); +} +$csrf_token = $_SESSION['gsp_update_csrf']; - // Per-session CSRF token - if (empty($_SESSION['gsp_update_csrf'])) { - $_SESSION['gsp_update_csrf'] = gsp_random_token(); - } - $csrf_token = $_SESSION['gsp_update_csrf']; +$apache_scan_result = null; +$preflight_result = null; +$auto_restart_payload = null; - // ---- Handle POST actions ------------------------------------------------ - if (isset($_POST['gsp_update_action'])) { - $submitted_csrf = isset($_POST['gsp_update_csrf']) ? $_POST['gsp_update_csrf'] : ''; - if (!hash_equals($csrf_token, $submitted_csrf)) { - print_failure('Invalid security token. Please reload the page and try again.'); - } else { - $action = $_POST['gsp_update_action']; - set_time_limit(0); +if (isset($_POST['gsp_update_action'])) { +$submitted_csrf = isset($_POST['gsp_update_csrf']) ? $_POST['gsp_update_csrf'] : ''; +if (!hash_equals($csrf_token, $submitted_csrf)) { +print_failure('Invalid security token. Please reload and try again.'); +} else { +$action = $_POST['gsp_update_action']; +$started_at = date('Y-m-d H:i:s'); +$restart_nonce = isset($_POST['gsp_restart_nonce']) ? trim($_POST['gsp_restart_nonce']) : ''; +set_time_limit(0); - $user_label = htmlspecialchars($_SESSION['users_login']) - . ' (IP: ' . htmlspecialchars($_SERVER['REMOTE_ADDR']) . ')'; +if ($action === 'preflight') { +$preflight_result = gsp_preflight_check(); +if ($preflight_result['success']) { +print_success('Preflight check passed.'); +} else { +print_failure('Preflight failed: ' . htmlspecialchars(implode(' | ', $preflight_result['errors']))); +} +} elseif ($action === 'apply_patches') { +$run = gsp_run_required_patches((string)gsp_get_current_version()); +if ($run['success']) { +print_success('Required patches applied successfully.'); +} else { +print_failure('Patch application failed: ' . htmlspecialchars($run['error'])); +} +} elseif ($action === 'fix_apache') { +$apache_fix = gsp_fix_apache_paths(true, true); +if ($apache_fix['success']) { +print_success('Apache paths fixed successfully. Updated files: ' . intval(count($apache_fix['changed_files'])) . '.'); +} else { +print_failure('Apache path fix failed: ' . htmlspecialchars($apache_fix['error'])); +} +} elseif ($action === 'backup_only') { +$result = gsp_create_full_backup('backup-only', 'manual', false); +if ($result['success']) { +print_success('Backup created: ' . htmlspecialchars($result['backup_dir']) . ''); +} else { +print_failure('Backup failed: ' . htmlspecialchars($result['error'])); +} +} elseif ($action === 'update_release') { +$version = isset($_POST['gsp_release_version']) ? trim($_POST['gsp_release_version']) : ''; +if (!preg_match('/^[a-zA-Z0-9._\-]+$/', $version) || strlen($version) > 80) { +print_failure('Invalid release tag selected.'); +} else { +$result = gsp_do_update($repo_owner, $repo_name, $version, 'release', $restart_nonce); +if (!empty($result['restart_required'])) { +print_success('Updater files changed and were updated first. Restarting update with refreshed updater...'); +$auto_restart_payload = ['action' => 'update_release', 'nonce' => $result['restart_nonce'], 'version' => $version]; +} elseif ($result['success']) { +print_success('Panel updated to release ' . htmlspecialchars($version) . '. ' +. intval($result['files_copied']) . ' file(s) copied.'); +} else { +print_failure('Update failed: ' . htmlspecialchars($result['error'])); +} +} +} elseif ($action === 'update_stable') { +$result = gsp_do_update($repo_owner, $repo_name, $stable_branch, 'development', $restart_nonce); +if (!empty($result['restart_required'])) { +print_success('Updater files changed and were updated first. Restarting stable update...'); +$auto_restart_payload = ['action' => 'update_stable', 'nonce' => $result['restart_nonce']]; +} elseif ($result['success']) { +print_success('Panel updated to GitHub Stable (' . htmlspecialchars($stable_branch) . '). ' +. intval($result['files_copied']) . ' file(s) copied.'); +} else { +print_failure('Update failed: ' . htmlspecialchars($result['error'])); +} +} elseif ($action === 'update_unstable') { +$result = gsp_do_update($repo_owner, $repo_name, $unstable_branch, 'cutting-edge', $restart_nonce); +if (!empty($result['restart_required'])) { +print_success('Updater files changed and were updated first. Restarting unstable update...'); +$auto_restart_payload = ['action' => 'update_unstable', 'nonce' => $result['restart_nonce']]; +} elseif ($result['success']) { +print_success('Panel updated to GitHub Unstable (' . htmlspecialchars($unstable_branch) . '). ' +. intval($result['files_copied']) . ' file(s) copied.'); +} else { +print_failure('Update failed: ' . htmlspecialchars($result['error'])); +} +} elseif ($action === 'revert') { +$backup_ts = isset($_POST['gsp_revert_backup']) ? trim($_POST['gsp_revert_backup']) : ''; +$restore_apache = !empty($_POST['gsp_restore_apache']); +if (!preg_match('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/', $backup_ts)) { +print_failure('Invalid backup timestamp selected.'); +} else { +$result = gsp_do_revert($backup_ts, $restore_apache); +if ($result['success']) { +print_success('Reverted to backup ' . htmlspecialchars($backup_ts) . '.'); +} else { +print_failure('Revert failed: ' . htmlspecialchars($result['error'])); +} +} +} - if ($action === 'backup_only') { - $started_at = date('Y-m-d H:i:s'); - $result = gsp_create_full_backup('backup-only', 'manual'); - $finished_at = date('Y-m-d H:i:s'); - if ($result['success']) { - $bk_dir = htmlspecialchars($result['backup_dir']); - print_success('Backup created successfully at ' . $bk_dir . '.'); - gsp_update_log("Admin {$user_label} created manual backup at {$result['backup_dir']}"); - gsp_log_update_to_db( - 'backup-only', null, 'success', - 'Manual backup by ' . $_SESSION['users_login'], - $result['backup_dir'], - $result['backup_dir'] . '/database.sql', - $result['backup_dir'] . '/panel-files.tar.gz', - $started_at, $finished_at - ); - } else { - print_failure('Backup failed: ' . htmlspecialchars($result['error'])); - gsp_update_log("Admin {$user_label} manual backup FAILED: {$result['error']}"); - gsp_log_update_to_db( - 'backup-only', null, 'failed', - 'Manual backup failed: ' . $result['error'], - null, null, null, $started_at, $finished_at - ); - } +$finished_at = date('Y-m-d H:i:s'); +gsp_log_update_to_db('panel-update', null, 'info', 'Admin action: ' . $action, null, null, null, $started_at, $finished_at); +} +$_SESSION['gsp_update_csrf'] = gsp_random_token(); +$csrf_token = $_SESSION['gsp_update_csrf']; +} - } elseif ($action === 'update_release') { - $version = isset($_POST['gsp_release_version']) ? trim($_POST['gsp_release_version']) : ''; - if (!preg_match('/^[a-zA-Z0-9._\-]+$/', $version) || strlen($version) > 80) { - print_failure('Invalid release tag selected.'); - } else { - $started_at = date('Y-m-d H:i:s'); - $result = gsp_do_update($repo_owner, $repo_name, $version, 'release'); - $finished_at = date('Y-m-d H:i:s'); - if ($result['success']) { - print_success( - 'Panel updated to release ' . htmlspecialchars($version) . '. ' - . intval($result['files_copied']) . ' file(s) updated. Source: GitHub Releases' - ); - gsp_update_log("Admin {$user_label} updated panel to release {$version}"); - gsp_log_update_to_db( - 'release', $version, 'success', - 'Updated to release ' . $version . ' by ' . $_SESSION['users_login'], - $result['backup_dir'] ?? null, - isset($result['backup_dir']) ? $result['backup_dir'] . '/database.sql' : null, - isset($result['backup_dir']) ? $result['backup_dir'] . '/panel-files.tar.gz': null, - $started_at, $finished_at - ); - } else { - print_failure('Update failed: ' . htmlspecialchars($result['error'])); - gsp_update_log("Admin {$user_label} update to release {$version} FAILED: {$result['error']}"); - gsp_log_update_to_db( - 'release', $version, 'failed', - 'Update to release ' . $version . ' failed: ' . $result['error'], - null, null, null, $started_at, $finished_at - ); - } - } +$current_version = gsp_get_current_version(); +$current_branch = gsp_get_current_branch(); +$git_commit = gsp_get_git_commit(); +$vinfo = gsp_read_version_json(); +$releases = gsp_fetch_github_releases($repo_owner, $repo_name); +$latest_release = (is_array($releases) && !empty($releases)) ? htmlspecialchars($releases[0]['tag_name']) : 'N/A'; +$backups = gsp_get_available_backups(); +$patch_overview = gsp_get_patch_overview(); +if ($apache_scan_result === null) { +$apache_scan_result = gsp_scan_apache_configs(); +} +if ($preflight_result === null) { +$preflight_result = gsp_preflight_check(); +} - } elseif ($action === 'update_stable') { - $started_at = date('Y-m-d H:i:s'); - $result = gsp_do_update($repo_owner, $repo_name, $stable_branch, 'development'); - $finished_at = date('Y-m-d H:i:s'); - if ($result['success']) { - print_success( - 'Panel updated to GitHub Stable (' . htmlspecialchars($stable_branch) . '). ' - . intval($result['files_copied']) . ' file(s) updated. Source: ' - . htmlspecialchars($stable_branch) . '' - ); - gsp_update_log("Admin {$user_label} updated panel to GitHub Stable branch {$stable_branch}"); - gsp_log_update_to_db( - 'development', $stable_branch, 'success', - 'Updated to GitHub Stable branch ' . $stable_branch . ' by ' . $_SESSION['users_login'], - $result['backup_dir'] ?? null, - isset($result['backup_dir']) ? $result['backup_dir'] . '/database.sql' : null, - isset($result['backup_dir']) ? $result['backup_dir'] . '/panel-files.tar.gz': null, - $started_at, $finished_at - ); - } else { - print_failure('Update failed: ' . htmlspecialchars($result['error'])); - gsp_update_log("Admin {$user_label} update to GitHub Stable branch {$stable_branch} FAILED: {$result['error']}"); - gsp_log_update_to_db( - 'development', $stable_branch, 'failed', - 'Update to GitHub Stable branch ' . $stable_branch . ' failed: ' . $result['error'], - null, null, null, $started_at, $finished_at - ); - } +echo "

Panel Updates

\n"; +echo "
\n"; +echo "

Detected Layout

\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
Expected Root:" . htmlspecialchars(GSP_EXPECTED_ROOT) . "
Detected GSP Root:" . htmlspecialchars(GSP_ROOT_DIR) . "
Panel Path:" . htmlspecialchars(GSP_PANEL_DIR) . "
Website Path:" . htmlspecialchars(GSP_WEBSITE_DIR) . "
Update Trace Log:" . htmlspecialchars(GSP_UPDATE_LOG) . "

\n"; - } elseif ($action === 'update_unstable') { - $started_at = date('Y-m-d H:i:s'); - $result = gsp_do_update($repo_owner, $repo_name, $unstable_branch, 'cutting-edge'); - $finished_at = date('Y-m-d H:i:s'); - if ($result['success']) { - print_success( - 'Panel updated to GitHub Unstable (' . htmlspecialchars($unstable_branch) . '). ' - . intval($result['files_copied']) . ' file(s) updated. Source: ' - . htmlspecialchars($unstable_branch) . '' - ); - gsp_update_log("Admin {$user_label} updated panel to GitHub Unstable branch {$unstable_branch}"); - gsp_log_update_to_db( - 'cutting-edge', $unstable_branch, 'success', - 'Updated to GitHub Unstable branch ' . $unstable_branch . ' by ' . $_SESSION['users_login'], - $result['backup_dir'] ?? null, - isset($result['backup_dir']) ? $result['backup_dir'] . '/database.sql' : null, - isset($result['backup_dir']) ? $result['backup_dir'] . '/panel-files.tar.gz': null, - $started_at, $finished_at - ); - } else { - print_failure('Update failed: ' . htmlspecialchars($result['error'])); - gsp_update_log("Admin {$user_label} update to GitHub Unstable branch {$unstable_branch} FAILED: {$result['error']}"); - gsp_log_update_to_db( - 'cutting-edge', $unstable_branch, 'failed', - 'Update to GitHub Unstable branch ' . $unstable_branch . ' failed: ' . $result['error'], - null, null, null, $started_at, $finished_at - ); - } +echo "

Current Installation

\n"; +echo "\n"; +if ($vinfo) { +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +} else { +echo "\n"; +echo "\n"; +} +if ($git_commit) { +echo "\n"; +} +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
Installed Type:" . htmlspecialchars($vinfo['installed_type']) . "
Installed Source:" . htmlspecialchars($vinfo['installed_source']) . "
Installed Version:" . htmlspecialchars($vinfo['installed_version']) . "
Installed At:" . htmlspecialchars($vinfo['installed_at']) . "
Installed Version:" . htmlspecialchars($current_version) . "
Current Branch:" . htmlspecialchars($current_branch) . "
Git Commit:" . htmlspecialchars(substr($git_commit, 0, 12)) . "
Latest Release:" . $latest_release . "
Repository:" . htmlspecialchars($repo_owner . '/' . $repo_name) . "
Backup Directory:" . htmlspecialchars(GSP_BACKUP_BASE) . "
Backups Stored:" . intval(count($backups)) . " (retention: 5)

\n"; - } elseif ($action === 'revert') { - $backup_ts = isset($_POST['gsp_revert_backup']) ? trim($_POST['gsp_revert_backup']) : ''; - if (!preg_match('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/', $backup_ts)) { - print_failure('Invalid backup timestamp selected.'); - } else { - $started_at = date('Y-m-d H:i:s'); - $result = gsp_do_revert($backup_ts); - $finished_at = date('Y-m-d H:i:s'); - if ($result['success']) { - print_success( - 'Panel reverted to backup from ' . htmlspecialchars($backup_ts) . '. ' - . intval($result['files_restored']) . ' file(s) restored.' - ); - gsp_update_log("Admin {$user_label} reverted panel to backup {$backup_ts}"); - gsp_log_update_to_db( - 'revert', $backup_ts, 'success', - 'Reverted to backup ' . $backup_ts . ' by ' . $_SESSION['users_login'], - GSP_BACKUP_BASE . '/' . $backup_ts, - GSP_BACKUP_BASE . '/' . $backup_ts . '/database.sql', - GSP_BACKUP_BASE . '/' . $backup_ts . '/panel-files.tar.gz', - $started_at, $finished_at - ); - } else { - print_failure('Revert failed: ' . htmlspecialchars($result['error'])); - gsp_update_log("Admin {$user_label} revert to backup {$backup_ts} FAILED: {$result['error']}"); - gsp_log_update_to_db( - 'revert', $backup_ts, 'failed', - 'Revert to backup ' . $backup_ts . ' failed: ' . $result['error'], - null, null, null, $started_at, $finished_at - ); - } - } - } - } +echo "

Updater Preflight & Patches

\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
Preflight Status:" . ($preflight_result['success'] ? 'PASS' : 'FAIL') . "
Pending Patches:" . intval(count($patch_overview['pending'])) . "
Patch Directory:" . htmlspecialchars(GSP_PATCH_DIR) . "
\n"; +if (!empty($preflight_result['warnings'])) { +echo "

Preflight warnings:
" . implode('
', array_map('htmlspecialchars', $preflight_result['warnings'])) . "

\n"; +} +if (!empty($preflight_result['errors'])) { +echo "

Preflight errors:
" . implode('
', array_map('htmlspecialchars', $preflight_result['errors'])) . "

\n"; +} - // Rotate CSRF token after every submission - $_SESSION['gsp_update_csrf'] = gsp_random_token(); - $csrf_token = $_SESSION['gsp_update_csrf']; - } - // ---- End POST handling -------------------------------------------------- +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
\n"; +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
\n"; - // Gather display data - $current_version = gsp_get_current_version(); - $current_branch = gsp_get_current_branch(); - $git_commit = gsp_get_git_commit(); - $vinfo = gsp_read_version_json(); - $releases = gsp_fetch_github_releases($repo_owner, $repo_name); - $latest_release = (is_array($releases) && !empty($releases)) - ? htmlspecialchars($releases[0]['tag_name'] ?? 'N/A') - : 'N/A (could not reach GitHub)'; - $backups = gsp_get_available_backups(); +echo "

Apache Configuration Status

\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
Config Directory:" . htmlspecialchars($apache_scan_result['base']) . "
Configs Found:" . intval(count($apache_scan_result['files'])) . "
Stale Path Hits:" . intval(count($apache_scan_result['issues'])) . "
Recommended Panel Path:" . htmlspecialchars(GSP_PANEL_DIR) . "
Recommended Website Path:" . htmlspecialchars(GSP_WEBSITE_DIR) . "
\n"; +if (!empty($apache_scan_result['issues'])) { +echo "

Apache path issues:
" . implode('
', array_map('htmlspecialchars', array_unique($apache_scan_result['issues']))) . "

\n"; +} +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
\n"; - // ---- Render UI ---------------------------------------------------------- - echo "

Panel Updates

\n"; - echo "
\n"; +echo "

Backup

\n"; +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
\n"; - // Current status table - echo "

Current Installation

\n"; - echo "\n"; - if ($vinfo) { - echo "\n"; - echo "\n"; - echo "\n"; - if (!empty($vinfo['installed_commit'])) { - echo "\n"; - } - echo "\n"; - } else { - echo "\n"; - echo "\n"; - if ($git_commit) { - echo "\n"; - } - } - echo "\n"; - echo "\n"; - // Backup status rows - echo "\n"; - if (!empty($backups)) { - $last_bk = $backups[0]; - $last_ts = htmlspecialchars($last_bk['ts']); - $last_status = !empty($last_bk['meta']['backup_status']) - ? htmlspecialchars($last_bk['meta']['backup_status']) - : 'unknown'; - echo "\n"; - } else { - echo "\n"; - } - echo "
Installed Type:" . htmlspecialchars($vinfo['installed_type'] ?? 'N/A') . "
Installed Source:" . htmlspecialchars($vinfo['installed_source'] ?? 'N/A') . "
Installed Version:" . htmlspecialchars($vinfo['installed_version'] ?? 'N/A') . "
Installed Commit:" - . htmlspecialchars(substr($vinfo['installed_commit'], 0, 12)) . "
Installed / Updated At:" . htmlspecialchars($vinfo['installed_at'] ?? 'N/A') . "
Installed Version:" . htmlspecialchars($current_version) . "
Current Branch / Type:" . htmlspecialchars($current_branch) . "
Git Commit:" - . htmlspecialchars(substr($git_commit, 0, 12)) . "
Latest Release on GitHub:" . $latest_release . "
Repository:" - . htmlspecialchars("{$repo_owner}/{$repo_name}") . "
Backup Directory:" - . htmlspecialchars(GSP_BACKUP_BASE) . "
Last Backup:" - . $last_ts . " — status: " . $last_status . "
Last Backup:None yet
\n
\n"; +echo "

Update Panel

\n"; +if (is_array($releases) && !empty($releases)) { +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo " \n"; +echo "
\n"; +} +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
\n"; +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo "
\n"; - // ---- Backup Only -------------------------------------------------------- - echo "

Create Backup

\n"; - echo "
\n"; - echo "\n"; - echo "\n"; - echo "\n"; - echo "Saves to: " - . htmlspecialchars(GSP_BACKUP_BASE) . "\n"; - echo "
\n"; +if (!empty($backups)) { +echo "

Rollback

\n"; +echo "
\n"; +echo "\n"; +echo "\n"; +echo "\n"; +echo " \n"; +echo " \n"; +echo "
\n"; +} - echo "
\n"; +echo "
\n"; - // ---- Numbered Releases -------------------------------------------------- - echo "

Numbered Releases

\n"; - if (is_array($releases) && !empty($releases)) { - echo "
\n"; - echo "\n"; - echo "\n"; - echo "\n"; - echo " \n"; - echo "
\n"; - } else { - echo "

No releases available (GitHub API unreachable or no releases published).

\n"; - } - - echo "
\n"; - - // ---- GitHub Stable ------------------------------------------------------- - echo "

GitHub Stable

\n"; - echo "

GitHub Stable should always match the latest official numbered release.

\n"; - echo "
\n"; - echo "\n"; - echo "\n"; - echo ""; - echo " Branch: " - . htmlspecialchars($stable_branch) . "\n"; - echo "
\n"; - - echo "
\n"; - - // ---- GitHub Unstable ----------------------------------------------------- - echo "

GitHub Unstable

\n"; - echo "

GitHub Unstable represents the latest development branch and may be unstable.

\n"; - echo "

" - . "⚠ Cutting-edge updates may include unfinished changes. Use stable releases for production.


\n"; - echo "
\n"; - echo "\n"; - echo "\n"; - echo ""; - echo " Branch: " - . htmlspecialchars($unstable_branch) . "\n"; - echo "
\n"; - - // ---- Revert Section ----------------------------------------------------- - if (!empty($backups)) { - echo "
\n

Revert Panel Update

\n"; - echo "

Available backups in " . htmlspecialchars(GSP_BACKUP_BASE) . ":

\n"; - echo "
\n"; - echo "\n"; - echo "\n"; - echo "\n"; - echo " \n"; - echo "
\n"; - } - - echo "
\n"; +if ($auto_restart_payload) { +$version = isset($auto_restart_payload['version']) ? $auto_restart_payload['version'] : ''; +gsp_render_restart_form($auto_restart_payload['action'], $csrf_token, $auto_restart_payload['nonce'], $version); +} } ?> diff --git a/Panel/modules/update/module.php b/Panel/modules/update/module.php index c303fa51..1e889729 100644 --- a/Panel/modules/update/module.php +++ b/Panel/modules/update/module.php @@ -25,7 +25,7 @@ // Module general information $module_title = "Update"; $module_version = "1.0"; -$db_version = 3; // avoid 'duplicate table' error message. +$db_version = 4; $module_required = TRUE; $module_menus = array( array( 'subpage' => '', 'name'=>'Update', 'group'=>'admin' ) @@ -62,4 +62,14 @@ $install_queries[3] = array( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" ); +$install_queries[4] = array( +"CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."update_patches` ( + `patch_id` varchar(191) NOT NULL, + `status` varchar(32) NOT NULL, + `details` text DEFAULT NULL, + `updater_version` varchar(80) DEFAULT NULL, + `applied_at` datetime NOT NULL, + PRIMARY KEY (`patch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" +); ?> diff --git a/Panel/modules/update/patch_manager.php b/Panel/modules/update/patch_manager.php new file mode 100644 index 00000000..94368333 --- /dev/null +++ b/Panel/modules/update/patch_manager.php @@ -0,0 +1,183 @@ + date('Y-m-d H:i:s'), +'patches' => $patches, +]; +@file_put_contents($state_file, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +} + +function gsp_patch_load_definitions($patch_dir) +{ +$patches = []; +if (!is_dir($patch_dir)) { +return $patches; +} +$files = glob(rtrim($patch_dir, '/') . '/*.php') ?: []; +sort($files, SORT_NATURAL); +foreach ($files as $file) { +$def = include $file; +if (!is_array($def) || empty($def['id']) || empty($def['runner'])) { +continue; +} +$def['file'] = $file; +$patches[] = $def; +} +return $patches; +} + +function gsp_patch_get_applied_map($db, $state_file) +{ +$map = []; +if (isset($db) && is_object($db)) { +$rows = $db->resultQuery("SELECT patch_id, status, details, applied_at FROM `OGP_DB_PREFIXupdate_patches`"); +if (is_array($rows)) { +foreach ($rows as $row) { +$map[$row['patch_id']] = [ +'status' => $row['status'], +'details' => $row['details'], +'applied_at' => $row['applied_at'], +]; +} +} +} +foreach (gsp_patch_state_load_local($state_file) as $id => $row) { +if (!isset($map[$id])) { +$map[$id] = $row; +} +} +return $map; +} + +function gsp_patch_record($db, $state_file, $patch_id, $status, $details, $updater_version) +{ +$patch_id = (string)$patch_id; +$status = (string)$status; +$details = (string)$details; +$updater_version = (string)$updater_version; +$applied_at = date('Y-m-d H:i:s'); + +if (isset($db) && is_object($db)) { +$pid = $db->real_escape_string($patch_id); +$st = $db->real_escape_string($status); +$dt = $db->real_escape_string($details); +$uv = $db->real_escape_string($updater_version); +$at = $db->real_escape_string($applied_at); +$db->query( +"INSERT INTO `OGP_DB_PREFIXupdate_patches` (patch_id, status, details, updater_version, applied_at) " +. "VALUES ('{$pid}','{$st}','{$dt}','{$uv}','{$at}') " +. "ON DUPLICATE KEY UPDATE status=VALUES(status), details=VALUES(details), updater_version=VALUES(updater_version), applied_at=VALUES(applied_at)" +); +} + +$state = gsp_patch_state_load_local($state_file); +$state[$patch_id] = [ +'status' => $status, +'details' => $details, +'applied_at' => $applied_at, +'updater_version' => $updater_version, +]; +gsp_patch_state_save_local($state_file, $state); +} + +function gsp_patch_run_all($db, $patch_dir, callable $logger, $updater_version) +{ +$state_file = gsp_patch_state_fallback_file(); +$definitions = gsp_patch_load_definitions($patch_dir); +$applied = gsp_patch_get_applied_map($db, $state_file); +$result = [ +'success' => true, +'patches_available' => count($definitions), +'applied' => [], +'skipped' => [], +'failed_patch' => null, +'error' => null, +]; + +foreach ($definitions as $patch) { +$id = (string)$patch['id']; +$title = !empty($patch['title']) ? (string)$patch['title'] : $id; +if (isset($applied[$id]) && $applied[$id]['status'] === 'applied') { +$result['skipped'][] = $id; +$logger("Patch {$id} ({$title}) already applied; skipping."); +continue; +} + +$runner = $patch['runner']; +if (!is_callable($runner)) { +$msg = "Patch {$id} runner is not callable."; +gsp_patch_record($db, $state_file, $id, 'failed', $msg, $updater_version); +$result['success'] = false; +$result['failed_patch'] = $id; +$result['error'] = $msg; +$logger($msg); +break; +} + +$logger("Running patch {$id} ({$title})."); +$run = call_user_func($runner, [ +'root_dir' => defined('GSP_ROOT_DIR') ? GSP_ROOT_DIR : null, +'panel_dir' => defined('GSP_PANEL_DIR') ? GSP_PANEL_DIR : null, +'website_dir' => defined('GSP_WEBSITE_DIR') ? GSP_WEBSITE_DIR : null, +]); +if (!is_array($run)) { +$run = ['success' => false, 'details' => 'Patch runner returned invalid result.']; +} +$ok = !empty($run['success']); +$details = !empty($run['details']) ? (string)$run['details'] : ($ok ? 'Applied.' : 'Failed.'); +gsp_patch_record($db, $state_file, $id, $ok ? 'applied' : 'failed', $details, $updater_version); + +if ($ok) { +$result['applied'][] = $id; +$logger("Patch {$id} applied: {$details}"); +} else { +$result['success'] = false; +$result['failed_patch'] = $id; +$result['error'] = $details; +$logger("Patch {$id} failed: {$details}"); +break; +} +} + +return $result; +} diff --git a/Panel/modules/update/patches/001_layout_bootstrap.php b/Panel/modules/update/patches/001_layout_bootstrap.php new file mode 100644 index 00000000..9aa7a56c --- /dev/null +++ b/Panel/modules/update/patches/001_layout_bootstrap.php @@ -0,0 +1,36 @@ + false, 'details' => 'Failed to create directory: ' . $dir]; +} +$created[] = $dir; +} +} +$details = empty($created) +? 'Layout already in expected state.' +: 'Created directories: ' . implode(', ', $created); +return ['success' => true, 'details' => $details]; +} +} + +return [ +'id' => '001_layout_bootstrap', +'title' => 'Ensure baseline GSP root directories exist', +'runner' => 'gsp_patch_001_layout_bootstrap', +]; From d562d849b7bef7d8a40983683882f8dcdb7e2746 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 21:27:35 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat(addonsmanager):=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20Server=20Content=20Manager=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename UI labels from "Addons Manager" to "Server Content Manager" - Add server_content_categories.php: central category map with 8 types - Bump module db_version 1→2 with safe VARCHAR(32) migration for addon_type - addons_manager.php: use category map for type list + comments - addons_installer.php: use category map + full TODO blocks for Phase 2+ - user_addons.php: dynamic category iteration via category map - monitor_buttons.php: updated header comment - English lang: updated all user-visible strings, added new type labels - global.php + gamemanager.php: updated LANG_addons_manager, LANG_user_addons, LANG_addons - Created SERVER_CONTENT_ROADMAP.md with full review and migration plan Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/1e8f1b08-96d6-4c47-8f27-efe972d7cf17 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- Panel/lang/English/global.php | 4 +- Panel/lang/English/modules/addonsmanager.php | 68 ++-- Panel/lang/English/modules/gamemanager.php | 2 +- .../addonsmanager/SERVER_CONTENT_ROADMAP.md | 297 ++++++++++++++++++ .../addonsmanager/addons_installer.php | 122 +++++-- .../modules/addonsmanager/addons_manager.php | 95 +++--- Panel/modules/addonsmanager/module.php | 66 ++-- .../modules/addonsmanager/monitor_buttons.php | 22 +- .../server_content_categories.php | 82 +++++ Panel/modules/addonsmanager/user_addons.php | 112 +++---- 10 files changed, 672 insertions(+), 198 deletions(-) create mode 100644 Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md create mode 100644 Panel/modules/addonsmanager/server_content_categories.php diff --git a/Panel/lang/English/global.php b/Panel/lang/English/global.php index 20636a00..af8054a4 100644 --- a/Panel/lang/English/global.php +++ b/Panel/lang/English/global.php @@ -80,7 +80,7 @@ define('LANG_gamemanager', "Game Manager"); define('LANG_game_monitor', "Game Monitor"); define('LANG_steam_workshop', "Steam Workshop"); define('LANG_dashboard', "Dashboard"); -define('LANG_user_addons', "Addons"); +define('LANG_user_addons', "Server Content"); define('LANG_ftp', "FTP"); define('LANG_shop', "Shop"); define('LANG_shop_guest', "Shop"); @@ -95,7 +95,7 @@ define('LANG_user_admin', "Users"); define('LANG_sub_users', "Sub Users"); define('LANG_show_groups', "Groups"); define('LANG_user_games', "Game Servers"); -define('LANG_addons_manager', "Addons Manager"); +define('LANG_addons_manager', "Server Content Manager"); define('LANG_ftp_admin', "FTP users"); define('LANG_orders', "Orders"); define('LANG_services', "Services"); diff --git a/Panel/lang/English/modules/addonsmanager.php b/Panel/lang/English/modules/addonsmanager.php index cc46971c..3219da51 100644 --- a/Panel/lang/English/modules/addonsmanager.php +++ b/Panel/lang/English/modules/addonsmanager.php @@ -22,49 +22,61 @@ * */ -define('LANG_install_plugin', "Install Plugins"); -define('LANG_install_mappack', "Install Maps"); -define('LANG_install_config', "Install Configs"); +// --- Server Content Manager (formerly Addons Manager) --- +// UI labels are updated to use "Server Content" terminology. +// Internal keys remain unchanged for backward compatibility with other languages. + +define('LANG_install_plugin', "Install Plugin / Mod"); +define('LANG_install_mappack', "Install Map Pack"); +define('LANG_install_config', "Install Config Pack"); define('LANG_game_name', "Game Name"); define('LANG_directory', "Directory Path"); define('LANG_remote_server', "Remote server"); -define('LANG_select_addon', "Select Addon"); +define('LANG_select_addon', "Select Server Content Item"); define('LANG_install', "Install"); define('LANG_failed_to_start_file_download', "Failed to start file download."); define('LANG_no_games_servers_available', "There are no game servers available in your account."); -define('LANG_addon_installed_successfully', "Addon installed successfully"); +define('LANG_addon_installed_successfully', "Server content item installed successfully"); define('LANG_path', "Path"); define('LANG_wait_while_decompressing', "Wait while the file %s is decompressed."); -define('LANG_addon_name', "Addon Name"); +define('LANG_addon_name', "Content Item Name"); define('LANG_url', "URL"); define('LANG_select_game_type', "Select Game Type"); -define('LANG_plugin', "Plugin"); -define('LANG_mappack', "MapPack"); -define('LANG_config', "Config"); -define('LANG_type', "Addon Type"); +define('LANG_plugin', "Plugins / Mods"); +define('LANG_mappack', "Map Packs"); +define('LANG_config', "Config Packs"); +// Additional category labels (for future content types already defined in server_content_categories.php) +define('LANG_version', "Server Versions"); +define('LANG_modpack', "Modpacks"); +define('LANG_workshop', "Workshop Content"); +define('LANG_script', "Scripted Installer"); +define('LANG_profile', "Server Profiles"); +define('LANG_type', "Content Type"); define('LANG_game', "Game"); -define('LANG_show_all_addons', "Show All Addons"); -define('LANG_show_addons_for_selected_type', "Show Addons For Selected Type"); -define('LANG_show_addons_for_selected_game', "Show Addons For Selected Game"); +define('LANG_show_all_addons', "Show All Server Content"); +define('LANG_show_addons_for_selected_type', "Show Content For Selected Type"); +define('LANG_show_addons_for_selected_game', "Show Content For Selected Game"); define('LANG_linux_games', "Linux Games:"); define('LANG_windows_games', "Windows Games:"); -define('LANG_create_addon', "Create Addon"); -define('LANG_addons_db', "Addons Database"); -define('LANG_addon_has_been_created', "The addon %s has been created."); -define('LANG_remove_addon', "Remove Addon"); -define('LANG_fill_the_url_address_to_a_compressed_file', "Please, fill an URL address for a compressed file."); -define('LANG_fill_the_addon_name', "Please, fill a name for the addon package."); -define('LANG_select_an_addon_type', "Please, select an addon type."); -define('LANG_select_a_game_type', "Please, select a game type."); -define('LANG_edit_addon', "Edit Addon"); -define('LANG_post-script', "Post-install script(bash)"); +define('LANG_create_addon', "Create Server Content Item"); +define('LANG_addons_db', "Server Content Database"); +define('LANG_addon_has_been_created', "The server content item \"%s\" has been created."); +define('LANG_remove_addon', "Remove"); +define('LANG_fill_the_url_address_to_a_compressed_file', "Please enter a URL for the compressed file to download."); +define('LANG_fill_the_addon_name', "Please enter a name for the server content item."); +define('LANG_select_an_addon_type', "Please select a content type."); +define('LANG_select_a_game_type', "Please select a game type."); +define('LANG_edit_addon', "Edit"); +define('LANG_invalid_addon', "Invalid server content item or access denied."); +define('LANG_invalid_addon_type', "Invalid content type selected."); +define('LANG_post-script', "Post-install script (bash)"); define('LANG_replacements', "Replacements:"); -define('LANG_addon_name_info', "Enter a name for this addon, this is the name that the user sees."); -define('LANG_url_info', "Enter a web address that contains a file to download, if compressed in zip or tar.gz will be unpacked in the root directory of the server or on the path given below."); -define('LANG_path_info', "The path must be relative to the server folder and contain no slashes at the beginning or end, eg: cstrike/cfg. If left blank will use the server root path."); -define('LANG_post-script_info', "Enter Bash language code, this will be executed as a script, you can use text replacements to customize the installation, they will be replaced by data from the server on which you install the addon. The script will start from the root folder of the server or the specified path."); +define('LANG_addon_name_info', "Enter a display name for this server content item."); +define('LANG_url_info', "Enter a download URL for a compressed file (.zip or .tar.gz). It will be extracted into the server root or the path specified below."); +define('LANG_path_info', "Path relative to the server folder, with no leading or trailing slashes (e.g. cstrike/cfg). Leave blank to use the server root."); +define('LANG_post-script_info', "Enter a Bash script to run after installation. Use the replacement variables listed on the left to inject server-specific values. The script runs from the server root or the specified path."); define('LANG_show_to_group', "Show to group"); define('LANG_all_groups', "All groups"); -define('LANG_show_addons_for_selected_group', "Show addons for selected group"); +define('LANG_show_addons_for_selected_group', "Show content for selected group"); define('LANG_group', "Group"); ?> \ No newline at end of file diff --git a/Panel/lang/English/modules/gamemanager.php b/Panel/lang/English/modules/gamemanager.php index 9f119f5f..c7cf246b 100644 --- a/Panel/lang/English/modules/gamemanager.php +++ b/Panel/lang/English/modules/gamemanager.php @@ -128,7 +128,7 @@ define('LANG_server_cant_start', "server can not start"); define('LANG_server_cant_stop', "server can not stop"); define('LANG_error_occured_remote_host', "Error occurred on the remote host"); define('LANG_follow_server_status', "You can follow the server status from"); -define('LANG_addons', "Addons"); +define('LANG_addons', "Server Content"); define('LANG_hostname', "Hostname"); define('LANG_ping', "Ping"); define('LANG_team', "Team"); diff --git a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md new file mode 100644 index 00000000..ec596aa4 --- /dev/null +++ b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md @@ -0,0 +1,297 @@ +# Server Content Manager — Roadmap & Safety Review + +> **Module:** `Panel/modules/addonsmanager` +> **Status:** Phase 1 complete — UI/language cleanup, category map, VARCHAR(32) migration, installer documentation +> **Branch:** Panel-unstable +> **Maintained by:** WDS (GSP is a heavily customized fork of OGP) + +--- + +## 1. Current Behaviour Summary + +The **Addons Manager** (now labelled "Server Content Manager" in the UI) lets +admins define downloadable content items that can be pushed to game server +homes by users. + +### Flow + +``` +Admin creates Server Content item (addons_manager.php) + └─> stored in OGP_DB_PREFIXaddons + +User visits game monitor + └─> monitor_buttons.php checks for content items for that game type + └─> "Server Content (N)" button appears + +User clicks button + └─> user_addons.php — shows available category links + └─> addons_installer.php?addon_type= + └─> user picks a specific item + └─> state=start → agent.start_file_download(url, path, filename, "uncompress") + └─> optional post_script runs on the agent after extraction + └─> page auto-refreshes to show download/script progress +``` + +--- + +## 2. Existing Database Fields (`OGP_DB_PREFIXaddons`) + +| Column | Type | Description | +|---------------|-----------------|--------------------------------------------------| +| addon_id | INT UNSIGNED PK | Auto-increment primary key | +| name | VARCHAR(80) | Display name shown to users | +| url | VARCHAR(200) | Download URL (zip / tar.gz) | +| path | VARCHAR(80) | Relative target path inside server home | +| addon_type | VARCHAR(32)* | Content category key (plugin / mappack / config / …) | +| home_cfg_id | VARCHAR(7) | Linked game configuration ID | +| post_script | LONGTEXT | Bash script run by agent after install | +| group_id | INT(11) NULL | Restrict visibility to a specific user group | + +\* Expanded from VARCHAR(7) to VARCHAR(32) in db_version 2 + (migration runs automatically via the module update system). + +--- + +## 3. Existing Flow: user_addons.php → addons_installer.php + +1. `user_addons.php` queries all content items for the server's `home_cfg_id`. +2. It groups items by `addon_type` and renders one link per category. +3. `addons_installer.php` (page key: `addons`) receives `addon_type` and + `home_id` in the query string. +4. On first load (no `state`), it renders a dropdown of available items. +5. On submit (`state=start`), it calls `$remote->start_file_download()` and + begins polling. +6. Subsequent loads with `state=refresh` poll the agent for download progress + and script log output. + +--- + +## 4. Current post_script Replacement Variables + +| Variable | Replaced with | +|--------------------|--------------------------------------------------------------| +| `%home_path%` | Absolute filesystem path of the server home directory | +| `%home_name%` | Human-readable name of the server home | +| `%control_password%` | RCON / control password for this server instance | +| `%max_players%` | Maximum player count for this mod slot | +| `%ip%` | IP address bound to this server | +| `%port%` | Game port | +| `%query_port%` | Query/status port (derived from game XML rules) | +| `%incremental%` | Internal incremental counter for this mod/home combination | + +All replacements are case-insensitive (`preg_replace … /i`). + +--- + +## 5. Security Concerns + +### Current risks + +1. **No path validation in the panel** — the `path` field is passed directly + to the agent without checking for `../`. The agent is the last line of + defence. A malicious admin could craft a path that escapes the home + directory if the agent's validation is insufficient. + +2. **SQL injection in filter queries** — `addon_type` is interpolated into + SQL strings in several places. A whitelist check via `in_array()` against + the registered category keys prevents injection, but this must remain in + place whenever new query sites are added. + +3. **post_script is admin-only but powerful** — admins write arbitrary bash. + This is intentional; users cannot supply scripts. However, the variable + substitution should be audited to ensure no user-controlled value (e.g. + a server name containing shell metacharacters) can affect the script. + +### Recommended hardening (next phase) + +- Add explicit `../` stripping / validation of `path` on the panel side before + sending to the agent. +- Sanitise all `%variable%` substitution inputs (strip shell metacharacters + from home_name, ip, port before substitution). +- Consider signing or hashing the post_script blob to detect tampering. +- Rate-limit install actions per user to prevent abuse. + +--- + +## 6. Proposed Next Database Fields + +```sql +ALTER TABLE OGP_DB_PREFIXaddons + MODIFY addon_type VARCHAR(32) NOT NULL, -- already applied in db_version 2 + ADD COLUMN install_method VARCHAR(32) NOT NULL DEFAULT 'download_zip', + ADD COLUMN content_version VARCHAR(64) NULL, + ADD COLUMN requires_stop TINYINT(1) NOT NULL DEFAULT 1, + ADD COLUMN backup_before_install TINYINT(1) NOT NULL DEFAULT 1, + ADD COLUMN restart_after_install TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN is_profile TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN description TEXT NULL; +``` + +Apply this as `$install_queries[2]` (db_version 3) in `module.php` when ready. + +--- + +## 7. Proposed Install Methods + +| install_method | Description | +|-------------------|---------------------------------------------------------------------| +| `download_zip` | Download a .zip / .tar.gz and extract into the server path (current default) | +| `download_file` | Download a single file (no extraction) into the server path | +| `post_script` | Run only the post_script — no download, no extraction | +| `steam_workshop` | Pass Workshop item IDs to the agent's `steamcmd +workshop_download_item` helper | +| `minecraft_jar` | Download a server jar from Mojang / Paper / Purpur / Fabric APIs | +| `profile_copy` | Copy a stored profile directory tree into the server home | + +--- + +## 8. Proposed Categories (server_content_categories.php) + +| addon_type | Display label | Notes | +|-------------|---------------------|------------------------------------| +| `plugin` | Plugins / Mods | Original — always present | +| `mappack` | Map Packs | Original — always present | +| `config` | Config Packs | Original — always present | +| `version` | Server Versions | e.g. Minecraft jar switcher | +| `modpack` | Modpacks | CurseForge / ATLauncher packs | +| `workshop` | Workshop Content | Steam Workshop (requires VARCHAR(32)) | +| `script` | Scripted Installer | Admin-defined script only | +| `profile` | Server Profiles | Full profile: configs + mods + scripts | + +--- + +## 9. Recommended Phased Migration Plan + +### Phase 1 (complete — this PR) +- [x] UI labels renamed to "Server Content Manager / Server Content". +- [x] Central category map created (`server_content_categories.php`). +- [x] `addon_type` column expanded to VARCHAR(32) via db_version 2 migration. +- [x] `addons_installer.php` and `user_addons.php` use category map for validation. +- [x] Full TODO/comment blocks added to installer for next phase work. +- [x] Module folder, table names, URL routes, function names unchanged. + +### Phase 2 — Schema & install_method support +- [ ] Apply the `$install_queries[2]` schema above (db_version 3). +- [ ] Add `install_method` dropdown to admin create/edit form. +- [ ] Implement `requires_stop` check in installer before download. +- [ ] Implement `backup_before_install` using agent tar/zip helper. +- [ ] Implement `restart_after_install` using existing server start logic. +- [ ] Add install history table and log writes. + +### Phase 3 — Steam Workshop integration +See Part 6 below. + +### Phase 4 — Minecraft jar / version switcher +See Part 7 below. + +### Phase 5 — DayZ / Arma profile switcher +See Part 8 below. + +--- + +## 10. Part 6: Steam Workshop Integration + +### Concept +Steam Workshop content is treated as a Server Content type (`addon_type=workshop`, +`install_method=steam_workshop`). + +### Browser UI +- A "Workshop Browser" page within the module fetches the workshop item list + from Steam's Web API and lets users select items. +- Selected item IDs are stored as server content selections linked to the home. + +### Agent side +- The agent runs `steamcmd +login anonymous +workshop_download_item +quit` + for each selected item. +- Downloaded content is moved into the correct server mod directory. +- The agent reports progress back to the panel via the existing rsync_progress mechanism + or a new workshop_progress RPC. + +### Restart behaviour (configurable per content item) +| Mode | Description | +|------|-------------| +| 1 | Install immediately if server is stopped | +| 2 | Queue installation to run on next restart | +| 3 | Restart automatically if updates are available | +| 4 | Notify only — do not install automatically | + +--- + +## 11. Part 7: Minecraft Example + +### Base game: Minecraft + +### Server Content options (addon_type=version, install_method=minecraft_jar) + +| Content Item | Source API / URL | +|-------------------|---------------------------------------------------------------| +| Vanilla 1.21.x | Mojang version manifest API | +| Paper 1.21.x | papermc.io API | +| Purpur 1.21.x | purpurmc.org API | +| Forge 1.20.1 | files.minecraftforge.net | +| Fabric 1.20.1 | meta.fabricmc.net | +| Modpack installer | CurseForge / ATLauncher / FTB API (addon_type=modpack) | + +### Install flow +1. Admin creates a content item with `install_method=minecraft_jar` and sets + `url` to the download endpoint (or a version ID for API-resolved URLs). +2. User selects the version from the Server Content page. +3. Installer downloads the jar to the server home path. +4. post_script patches the startup command line with the new jar filename. +5. If `restart_after_install=1`, the server restarts with the new jar. + +--- + +## 12. Part 8: DayZ / Arma Example + +### Base game: Arma 2 / DayZ-capable server + +### Server Content options + +| Content Item | Type | Description | +|--------------|---------|--------------------------------------------------| +| DayZ Vanilla | config | Vanilla DayZ config + mission files | +| DayZ Epoch | profile | Epoch mod files + config profile | +| Overpoch | profile | Combined Overwatch + Epoch profile | +| Map Pack | mappack | Additional map files (Chernarus, Lingor, etc.) | +| Config Pack | config | Server config preset (difficulty, loot tables) | + +### Install flow +1. Admin defines each option as a Server Content item with `install_method=download_zip` + or `install_method=profile_copy`. +2. post_script copies required files, patches `mission.sqm`, `server.cfg`, etc. +3. If `requires_stop=1`, the server is stopped before applying changes. +4. If `restart_after_install=1`, the server starts with the new profile. + +--- + +## 13. Part 9: Security Direction + +### Core principles + +1. **Users must not be allowed to enter arbitrary commands.** + Admins define Server Content items including scripts. + Users only select from the approved list. + +2. **Script execution is scoped to the assigned server.** + The post_script runs with only the target server home path and the approved + replacement variables. It cannot reference paths outside the home directory. + +3. **All paths must be validated against the home directory boundary.** + - Strip or reject any `../` sequences in the `path` field. + - Reject absolute paths unless the content item is explicitly marked + admin-only and the admin has been warned. + - The agent enforces path containment at the OS level; the panel should + add a redundant check as defence-in-depth. + +4. **Replacement variable values must be shell-safe.** + - Escape shell metacharacters in `home_name`, `ip`, `port`, `home_path`, + etc. before substitution into post_script. + - Consider wrapping each value in single quotes in the substituted script. + +5. **Workshop and external API downloads must be verified.** + - Check Content-Type and file signature/hash where possible. + - Reject downloads that exceed a configurable size limit. + +6. **Install history must be logged.** + - Record who installed what, when, and the script exit code. + - This log must be readable by admins but not modifiable by users. diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index 1f7bcdec..826adf7b 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -1,25 +1,93 @@ . + * 2. User picks a specific content item from a dropdown. + * 3. On form submit, state=start is set and start_file_download() is called + * on the remote agent with the configured URL and target path. + * 4. The agent downloads and extracts the archive. + * 5. If a post_script is defined it is run on the agent after extraction. + * 6. The page auto-refreshes (state=refresh) to show download/script progress. * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. + * POST-INSTALL SCRIPT REPLACEMENT VARIABLES: + * %home_path% – absolute path of the game server home directory + * %home_name% – display name of the game server home + * %control_password% – RCON / control password for this server instance + * %max_players% – maximum player count configured for this mod slot + * %ip% – IP address bound to this server instance + * %port% – game port bound to this server instance + * %query_port% – query/status port (derived from game XML rules) + * %incremental% – internal incremental run counter for this mod/home * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * SECURITY NOTES: + * - Users CANNOT supply arbitrary scripts; only the admin-defined post_script + * is executed. Users only pick from the approved list. + * - Paths are passed to the agent which is responsible for enforcing that + * all paths stay inside the assigned home directory. + * - TODO (next phase): add explicit server-side path validation before + * sending the command to the agent to block ../ traversal at the panel. * + * ─── FUTURE WORK (TODO – next phase) ──────────────────────────────────────── + * The items below are intentionally NOT implemented here yet. They are + * documented so the next contributor knows exactly where to add them. + * + * TODO: requires_stop flag + * If the content item sets requires_stop=1, stop the server before + * initiating the download. Poll is_server_running() and abort if it + * cannot be stopped within a timeout. + * + * TODO: backup_before_install flag + * If backup_before_install=1, call the agent's backup function or + * compress the target path into a timestamped .tar.gz before extraction. + * + * TODO: restart_after_install flag + * If restart_after_install=1, trigger a server start after a successful + * install (i.e. after post_script completes with exit code 0). + * + * TODO: install_method field + * Current method is always 'download_zip'. Future methods: + * 'download_file' – single-file download, no extraction + * 'post_script' – run only the post_script, no download + * 'steam_workshop' – pass workshop item IDs to the agent's workshop helper + * 'minecraft_jar' – download a Minecraft server jar + update start script + * 'profile_copy' – copy a profile directory tree into the server home + * + * TODO: content_version field + * Store the installed version tag so the UI can display "installed: 1.21.1" + * and detect whether an update is available. + * + * TODO: safe script templates + * Provide a set of admin-approved script templates so admins do not have to + * write raw bash from scratch. Templates are stored in the DB and referenced + * by content items. + * + * TODO: install history / logging + * Write a row to a new install_history table (or log file) each time a + * content item is installed: + * home_id, addon_id, installed_by (user_id), installed_at, result, log_output + * + * TODO: user-friendly status output + * Replace the raw progress-bar with a card-style status block showing: + * content item name, version, download progress, script output, final status. + * + * TODO: Steam Workshop integration + * When install_method='steam_workshop', pass the workshop item ID list to + * the agent. See SERVER_CONTENT_ROADMAP.md – Part 6 for the full design. + * + * TODO: Minecraft jar / version switching + * When install_method='minecraft_jar', download the jar from Mojang/Paper/ + * Purpur/Fabric API, place it at the configured server path, and patch the + * startup command line. See SERVER_CONTENT_ROADMAP.md – Part 7. + * ───────────────────────────────────────────────────────────────────────────── */ function do_progress($kbytes,$totalsize) @@ -41,14 +109,16 @@ function do_progress($kbytes,$totalsize) require_once("includes/lib_remote.php"); require_once("modules/config_games/server_config_parser.php"); require_once("protocol/lgsl/lgsl_protocol.php"); +// Central category map — all valid addon_type values and their labels. +require_once(dirname(__FILE__) . '/server_content_categories.php'); function exec_ogp_module() { global $db,$view; $home_id = $_REQUEST['home_id']; - $mod_id = $_REQUEST['mod_id']; - $ip = $_REQUEST['ip']; - $port = $_REQUEST['port']; + $mod_id = $_REQUEST['mod_id']; + $ip = $_REQUEST['ip']; + $port = $_REQUEST['port']; $user_id = $_SESSION['user_id']; $isAdmin = $db->isAdmin( $_SESSION['user_id'] ); @@ -76,13 +146,15 @@ function exec_ogp_module() { } $home_cfg_id = $home_info['home_cfg_id']; - $server_xml = read_server_config(SERVER_CONFIG_LOCATION."/".$home_info['home_cfg_file']); + $server_xml = read_server_config(SERVER_CONFIG_LOCATION."/".$home_info['home_cfg_file']); - $addon_types = array('plugin', 'mappack', 'config'); - $addon_type = isset($_REQUEST['addon_type']) ? $_REQUEST['addon_type'] : ""; + // Use the full category map so newly added types are accepted without + // editing this file. The original three types are always present. + $addon_types = get_server_content_type_keys(); + $addon_type = isset($_REQUEST['addon_type']) ? $_REQUEST['addon_type'] : ""; $state = isset($_REQUEST['state']) ? $_REQUEST['state'] : ""; - $pid = isset($_REQUEST['pid']) ? $_REQUEST['pid'] : -1; + $pid = isset($_REQUEST['pid']) ? $_REQUEST['pid'] : -1; if ( $state != "" ) { @@ -104,7 +176,11 @@ function exec_ogp_module() { $addon_info = $addons_rows[0]; $url = $addon_info['url']; $filename = basename($url); - #### This makes replacements to the bash script: + #### Replace template variables in the post-install script with + #### live server data before sending to the agent. + #### Each variable is replaced case-insensitively. + #### SECURITY: only admin-defined variables are substituted; users + #### cannot inject additional commands through these fields. if($addon_info['post_script'] != "") { $addon_info['post_script'] = strip_real_escape_string($addon_info['post_script']); @@ -153,7 +229,7 @@ function exec_ogp_module() { } } - #### end of replacememnts + #### end of replacements if ( $state == "start" AND $addon_id != "" ) $pid = $remote->start_file_download( $addon_info['url'], $home_info['home_path']."/".$addon_info['path'], $filename, "uncompress", $post_script); diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index 5e5446e0..0527f987 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -2,31 +2,40 @@ label + if (isset($_POST['create_addon']) AND isset($_POST['name']) AND $_POST['url']=="") { print_failure(get_lang("fill_the_url_address_to_a_compressed_file")); @@ -45,13 +54,13 @@ function exec_ogp_module() { } elseif (isset($_POST['create_addon']) AND isset($_POST['name']) AND isset($_POST['url']) AND isset($_POST['addon_type']) and isset($_POST['home_cfg_id']) ) { - $fields['name'] = $_POST['name']; - $fields['url'] = $_POST['url']; - $fields['path'] = $_POST['path']; - $fields['addon_type'] = $_POST['addon_type']; + $fields['name'] = $_POST['name']; + $fields['url'] = $_POST['url']; + $fields['path'] = $_POST['path']; + $fields['addon_type'] = $_POST['addon_type']; $fields['home_cfg_id'] = $_POST['home_cfg_id']; $fields['post_script'] = $_POST['post_script']; - $fields['group_id'] = $_POST['group_id']; + $fields['group_id'] = $_POST['group_id']; if( is_numeric($db->resultInsertId( 'addons', $fields )) ) { print_success(get_lang_f("addon_has_been_created",$_POST['name'])); @@ -61,14 +70,13 @@ function exec_ogp_module() { } echo "

".get_lang('addons_manager')."

"; - $name = isset($_POST['name']) ? $_POST['name'] : ""; - $url = isset($_POST['url']) ? $_POST['url'] : ""; - $path = isset($_POST['path']) ? $_POST['path'] : ""; + $name = isset($_POST['name']) ? $_POST['name'] : ""; + $url = isset($_POST['url']) ? $_POST['url'] : ""; + $path = isset($_POST['path']) ? $_POST['path'] : ""; $post_script = isset($_POST['post_script']) ? $_POST['post_script'] : ""; $home_cfg_id = isset($_POST['home_cfg_id']) ? $_POST['home_cfg_id'] : ""; - $addon_type = isset($_POST['addon_type']) ? $_POST['addon_type'] : ""; - $group_id = isset($_POST['group_id']) ? $_POST['group_id'] : ""; - $addon_types = array('plugin', 'mappack', 'config'); + $addon_type = isset($_POST['addon_type']) ? $_POST['addon_type'] : ""; + $group_id = isset($_POST['group_id']) ? $_POST['group_id'] : ""; if (isset($_POST['addon_id']) && (int)$_POST['addon_id'] > 0 && isset($_POST['edit'])) { @@ -76,14 +84,14 @@ function exec_ogp_module() { if (!is_array($addons_rows)) { $addons_rows = []; } - $addon_info = $addons_rows[0]; - $name = isset($addon_info['name']) ? $addon_info['name'] : ""; - $url = isset($addon_info['url']) ? $addon_info['url'] : ""; - $path = isset($addon_info['path']) ? $addon_info['path'] : ""; + $addon_info = $addons_rows[0]; + $name = isset($addon_info['name']) ? $addon_info['name'] : ""; + $url = isset($addon_info['url']) ? $addon_info['url'] : ""; + $path = isset($addon_info['path']) ? $addon_info['path'] : ""; $post_script = isset($addon_info['post_script']) ? $addon_info['post_script'] : ""; $home_cfg_id = isset($addon_info['home_cfg_id']) ? $addon_info['home_cfg_id'] : ""; - $addon_type = isset($addon_info['addon_type']) ? $addon_info['addon_type'] : ""; - $group_id = isset($addon_info['group_id']) ? $addon_info['group_id'] : ""; + $addon_type = isset($addon_info['addon_type']) ? $addon_info['addon_type'] : ""; + $group_id = isset($addon_info['group_id']) ? $addon_info['group_id'] : ""; } ?>
@@ -104,7 +112,8 @@ function exec_ogp_module() { - + @@ -174,11 +183,12 @@ function exec_ogp_module() { $type_label) { - $checked = ( isset($addon_type) AND $type == $addon_type) ? 'checked' : ''; - echo ''.get_lang($type); + $checked = ( isset($addon_type) AND $type_key == $addon_type) ? 'checked' : ''; + echo ''.htmlspecialchars($type_label).'   '; } ?> @@ -276,14 +286,14 @@ function exec_ogp_module() { $label) { $option .= ''.get_lang($k).''; + $option .= ' value="'. htmlspecialchars($k) .'">'.htmlspecialchars($label).''; } echo $option; @@ -324,8 +334,9 @@ function exec_ogp_module() { } $home_cfg_id = !empty($_GET['home_cfg_id']) && (int)$_GET['home_cfg_id'] > 0 ? (int)$_GET['home_cfg_id'] : 0; - $addon_type = !empty($_GET['addon_type']) && is_array($addon_types) && in_array($_GET['addon_type'], $addon_types) ? $_GET['addon_type'] : ""; - $group_id = isset($_GET['group_id']) && is_numeric($_GET['group_id']) ? (int)$_GET['group_id'] : 0; + // Validate the requested addon_type against the full category map so new types are accepted. + $addon_type = !empty($_GET['addon_type']) && in_array($_GET['addon_type'], $addon_types) ? $_GET['addon_type'] : ""; + $group_id = isset($_GET['group_id']) && is_numeric($_GET['group_id']) ? (int)$_GET['group_id'] : 0; if ( isset($_GET['show']) ) { diff --git a/Panel/modules/addonsmanager/module.php b/Panel/modules/addonsmanager/module.php index 45c8dcd6..8da16b21 100644 --- a/Panel/modules/addonsmanager/module.php +++ b/Panel/modules/addonsmanager/module.php @@ -1,45 +1,51 @@ 'addons_manager', 'name'=>'Addons Manager', 'group'=>'admin' ) ); +$module_menus = array( + array( 'subpage' => 'addons_manager', 'name' => 'Server Content Manager', 'group' => 'admin' ) +); -$install_queries = array(); +// ── db_version 1 : initial install ─────────────────────────────────────────── +$install_queries = array(); $install_queries[0] = array( "CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."addons` ( - `addon_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - `name` VARCHAR(80) NOT NULL, - `url` VARCHAR(200) NOT NULL, - `path` VARCHAR(80) NOT NULL, - `addon_type` VARCHAR(7) NOT NULL, - `home_cfg_id` VARCHAR(7) NOT NULL, - `post_script` longtext NOT NULL, - `group_id` int(11) NULL + `addon_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(80) NOT NULL, + `url` VARCHAR(200) NOT NULL, + `path` VARCHAR(80) NOT NULL, + `addon_type` VARCHAR(7) NOT NULL, + `home_cfg_id` VARCHAR(7) NOT NULL, + `post_script` longtext NOT NULL, + `group_id` int(11) NULL ) ENGINE=MyISAM;" ); -?> \ No newline at end of file + +// ── db_version 2 : expand addon_type to VARCHAR(32) ────────────────────────── +// Required so extended content types such as 'workshop' (8 chars) can be stored. +// MODIFY is safe on existing installs; existing 'plugin'/'mappack'/'config' +// values are preserved without alteration. +$install_queries[1] = array( + "ALTER TABLE `".OGP_DB_PREFIX."addons` + MODIFY `addon_type` VARCHAR(32) NOT NULL;" +); +?> diff --git a/Panel/modules/addonsmanager/monitor_buttons.php b/Panel/modules/addonsmanager/monitor_buttons.php index ec8bcf5f..a50b9a8c 100644 --- a/Panel/modules/addonsmanager/monitor_buttons.php +++ b/Panel/modules/addonsmanager/monitor_buttons.php @@ -1,24 +1,12 @@ + */ +function get_server_content_categories() +{ + return array( + // ── Original types (must remain for backward compatibility) ────────── + 'plugin' => 'Plugins / Mods', + 'mappack' => 'Map Packs', + 'config' => 'Config Packs', + + // ── Extended types (require addon_type VARCHAR(32) – db_version 2) ── + 'version' => 'Server Versions', // e.g. Minecraft jar switcher + 'modpack' => 'Modpacks', // e.g. CurseForge / ATLauncher packs + 'workshop' => 'Workshop Content', // Steam Workshop item bundles + 'script' => 'Scripted Installer', // Admin-defined install-only scripts + 'profile' => 'Server Profiles', // Full profile: configs + mods + scripts + ); +} + +/** + * Returns only the original three types that existed before db_version 2. + * Use this when you need to restrict to legacy values, e.g. for + * installs that have not yet run the VARCHAR(32) migration. + * + * @return array + */ +function get_legacy_addon_types() +{ + return array( + 'plugin' => 'Plugins / Mods', + 'mappack' => 'Map Packs', + 'config' => 'Config Packs', + ); +} + +/** + * Returns an ordered list of addon_type keys only (no labels). + * Useful as a whitelist for input validation. + * + * @return string[] + */ +function get_server_content_type_keys() +{ + return array_keys(get_server_content_categories()); +} diff --git a/Panel/modules/addonsmanager/user_addons.php b/Panel/modules/addonsmanager/user_addons.php index abc6257a..d5c84bbf 100644 --- a/Panel/modules/addonsmanager/user_addons.php +++ b/Panel/modules/addonsmanager/user_addons.php @@ -1,33 +1,29 @@ isAdmin( $user_id ); @@ -51,43 +47,49 @@ function exec_ogp_module() { $home_cfg_id = $home_info['home_cfg_id']; echo "

".get_lang('user_addons').": ".htmlentities($home_info['home_name'])."

\n". "\n". - "\n"; + + // Iterate all registered content types. Each type that has at least + // one item for this game generates a link to the installer page. + // New types added to server_content_categories.php automatically + // appear here without any further code changes. + $categories = get_server_content_categories(); // key => label + $printed_any_cell = false; + + foreach ((array)$categories as $type_key => $type_label) + { + $items = $db->resultQuery( + "SELECT DISTINCT addon_id, name, game_name " . + "FROM OGP_DB_PREFIXaddons " . + "NATURAL JOIN OGP_DB_PREFIXconfig_homes " . + "WHERE addon_type='" . $db->realEscapeSingle($type_key) . "' " . + "AND home_cfg_id=" . (int)$home_cfg_id . $query_groups + ); + $items_qty = is_array($items) ? count((array)$items) : 0; + if ($items && $items_qty >= 1) + { + if ($printed_any_cell) + echo "\n". + + if ($printed_any_cell) + echo "\n"; + + echo "\n". "
\n"; - $plugins = $db->resultQuery("SELECT DISTINCT addon_id, name, game_name ". - "FROM OGP_DB_PREFIXaddons ". - "NATURAL JOIN OGP_DB_PREFIXconfig_homes ". - "WHERE addon_type='plugin' ". - "AND home_cfg_id=".$home_cfg_id.$query_groups); - $plugins_qty = is_array($plugins) ? count((array)$plugins) : 0; - if($plugins and $plugins_qty >= 1) - echo "".get_lang('install_plugin')."(".$plugins_qty.")\n"; - - $mappacks = $db->resultQuery("SELECT DISTINCT addon_id, name, game_name ". - "FROM OGP_DB_PREFIXaddons ". - "NATURAL JOIN OGP_DB_PREFIXconfig_homes ". - "WHERE addon_type='mappack' ". - "AND home_cfg_id=".$home_cfg_id.$query_groups); - $mappacks_qty = is_array($mappacks) ? count((array)$mappacks) : 0; - if($mappacks and $mappacks_qty >= 1){ - echo ""; - echo "".get_lang('install_mappack')."(".$mappacks_qty.")\n"; + "
\n"; + else + echo "\n"; + $printed_any_cell = true; + // Display label comes from the category map; the internal + // addon_type key is passed in the URL for backward compatibility. + echo "" . + htmlspecialchars($type_label) . " (" . $items_qty . ")" . + "\n"; + } } - $configs = $db->resultQuery("SELECT DISTINCT addon_id, name, game_name ". - "FROM OGP_DB_PREFIXaddons ". - "NATURAL JOIN OGP_DB_PREFIXconfig_homes ". - "WHERE addon_type='config' ". - "AND home_cfg_id=".$home_cfg_id.$query_groups); - $configs_qty = is_array($configs) ? count((array)$configs) : 0; - if($configs and $configs_qty >= 1){ - echo ""; - echo "".get_lang('install_config')."(".$configs_qty.")\n"; - } - echo "
\n". "\n". "\n". From 7a80812fe7554a4a102d542ab8c24af2bd5aff69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 21:40:24 +0000 Subject: [PATCH 3/8] Add Phase 1 Workshop Content flow to addonsmanager Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/7643e55f-473c-4084-baa0-cf8ae8c9a10a Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- Panel/CHANGELOG.md | 1 + Panel/docs/COPILOT_TODO.md | 1 + .../SERVER_CONTENT_WORKSHOP_PHASE1.md | 52 ++++ .../addonsmanager/addons_installer.php | 6 + Panel/modules/addonsmanager/module.php | 28 +- Panel/modules/addonsmanager/navigation.xml | 1 + .../workshop/generic_steam_workshop_linux.sh | 63 ++++ .../generic_steam_workshop_windows_cygwin.sh | 58 ++++ .../addonsmanager/server_content_helpers.php | 200 +++++++++++++ Panel/modules/addonsmanager/user_addons.php | 22 +- .../modules/addonsmanager/workshop_action.php | 277 ++++++++++++++++++ .../addonsmanager/workshop_content.php | 148 ++++++++++ 12 files changed, 854 insertions(+), 3 deletions(-) create mode 100644 Panel/modules/addonsmanager/SERVER_CONTENT_WORKSHOP_PHASE1.md create mode 100644 Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh create mode 100644 Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh create mode 100644 Panel/modules/addonsmanager/server_content_helpers.php create mode 100644 Panel/modules/addonsmanager/workshop_action.php create mode 100644 Panel/modules/addonsmanager/workshop_content.php diff --git a/Panel/CHANGELOG.md b/Panel/CHANGELOG.md index 8f573d50..921ec2a3 100644 --- a/Panel/CHANGELOG.md +++ b/Panel/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 2026-05-18 +- **Server Content Workshop Phase 1 in addonsmanager:** Added a new `Workshop Content` flow under Server Content with per-home Workshop ID storage, ID validation/deduplication, install/update/remove/update-all actions, manifest-based script handoff (`gsp_server_content/workshop_manifest.json`), safe placeholder workshop scripts for Linux/Cygwin, and schema support via `server_content_workshop` plus `addons.addon_type VARCHAR(32)`. - **Updater layout hardening + pre-update patch framework:** Reworked `modules/administration/panel_update.php` to resolve explicit GSP root/Panel/Website paths, run mandatory preflight checks, self-update updater files before main sync when drift is detected, and apply ordered required patches from `modules/update/patches/` with DB/local state tracking. Backup/rollback now includes both Panel + Website archives and root `version.json`, logs moved to root `logs/update_trace.log`, and the admin Update UI now exposes preflight, patch apply, Apache path scan/fix, backup, update, and rollback actions. - **Billing runtime relocation + portable path bootstrap:** Re-homed storefront runtime to `Panel/modules/billing`, added portable runtime helpers (`billing_bootstrap.php`, `site_config.php`, `site_config.example.php`) with env/local override support for base path and panel path, normalized critical storefront redirects/links to computed billing URLs, and added `Website` compatibility wrappers for key billing entrypoints. - **Panel updater panel-subtree safety:** Hardened updater logic to treat repository `/panel` as the update source when present (ZIP + git flows) so root-level docs/examples/scripts are no longer candidates for panel file overwrite during updates. diff --git a/Panel/docs/COPILOT_TODO.md b/Panel/docs/COPILOT_TODO.md index 50e18511..038459e7 100644 --- a/Panel/docs/COPILOT_TODO.md +++ b/Panel/docs/COPILOT_TODO.md @@ -17,3 +17,4 @@ - Add a panel settings health check that validates reCAPTCHA site/secret keys against active panel/storefront domains and warns admins before registration users see widget errors. - Add an automated deployment check that fails when `Website/timestamp.txt` and `modules/billing/timestamp.txt` diverge after storefront/content changes. - Add an admin preview/diff panel for Apache path repairs so staff can review exact vhost line changes before confirming `Fix Apache Paths`. +- Add Phase 2 Workshop Content UX in `addonsmanager`: browse/search/select Workshop items with metadata while reusing the Phase 1 per-home saved-ID action pipeline. diff --git a/Panel/modules/addonsmanager/SERVER_CONTENT_WORKSHOP_PHASE1.md b/Panel/modules/addonsmanager/SERVER_CONTENT_WORKSHOP_PHASE1.md new file mode 100644 index 00000000..3068e84d --- /dev/null +++ b/Panel/modules/addonsmanager/SERVER_CONTENT_WORKSHOP_PHASE1.md @@ -0,0 +1,52 @@ +# Server Content Workshop – Phase 1 + +Phase 1 adds manual Workshop ID support inside the existing `addonsmanager` module (user-facing label: **Server Content**). + +## Scope (Phase 1) + +- No Steam Workshop browser/search UI yet. +- No Steam scraping. +- User enters comma-separated numeric Workshop IDs. +- Panel validates IDs, removes duplicates, and stores them per server home. +- Panel lists saved IDs and supports: + - Install New + - Update Selected + - Remove Selected + - Update All +- Panel generates a per-server manifest at: + - `%home_path%/gsp_server_content/workshop_manifest.json` +- Panel runs an approved script path (safe default or game-specific config), never user-supplied command/path. + +## Security model + +- Ownership check: non-admin users can only access homes assigned to them; admins can access any home. +- Actions are scoped to one `home_id`. +- IDs must be numeric only. +- Script path is not user-editable. +- Manifest path is validated to remain under server home. +- Remove is non-destructive in the generic scripts (preserve/move behavior for Phase 1). +- All actions are logged through panel logging. + +## Database + +Phase 1 introduces: + +- `OGP_DB_PREFIXserver_content_workshop` + +and keeps `OGP_DB_PREFIXaddons.addon_type` at `VARCHAR(32)` so `workshop` is valid. + +## Game/admin config TODO (next phase hardening) + +Each game should define and document: + +- `workshop_app_id` +- Linux workshop script path +- Windows/Cygwin workshop script path +- target install location +- restart/update behavior + +## Phase 2 (not included here) + +- Workshop browsing/search/select UI +- richer metadata/title lookups +- per-game install adapters and deeper status reporting diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index 826adf7b..7e86b645 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -111,6 +111,7 @@ require_once("modules/config_games/server_config_parser.php"); require_once("protocol/lgsl/lgsl_protocol.php"); // Central category map — all valid addon_type values and their labels. require_once(dirname(__FILE__) . '/server_content_categories.php'); +require_once(dirname(__FILE__) . '/server_content_helpers.php'); function exec_ogp_module() { @@ -325,6 +326,11 @@ function exec_ogp_module() { return; } + if ($addon_type === 'workshop') { + scm_ensure_workshop_schema($db); + $view->refresh('?m=addonsmanager&p=workshop_content&home_id='.(int)$home_id.'&mod_id='.(int)$mod_id.'&ip='.urlencode((string)$ip).'&port='.urlencode((string)$port), 0); + return; + } ?>

diff --git a/Panel/modules/addonsmanager/module.php b/Panel/modules/addonsmanager/module.php index 8da16b21..8b625779 100644 --- a/Panel/modules/addonsmanager/module.php +++ b/Panel/modules/addonsmanager/module.php @@ -18,8 +18,8 @@ // Module general information $module_title = "Server Content Manager"; -$module_version = "2.0"; -$db_version = 2; +$module_version = "2.1"; +$db_version = 3; $module_required = TRUE; $module_menus = array( array( 'subpage' => 'addons_manager', 'name' => 'Server Content Manager', 'group' => 'admin' ) @@ -48,4 +48,28 @@ $install_queries[1] = array( "ALTER TABLE `".OGP_DB_PREFIX."addons` MODIFY `addon_type` VARCHAR(32) NOT NULL;" ); + +// ── db_version 3 : workshop item selections per server home ─────────────────── +$install_queries[2] = array( + "CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_content_workshop` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `home_cfg_id` INT NOT NULL, + `remote_server_id` INT NULL, + `workshop_app_id` VARCHAR(32) NULL, + `workshop_item_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'selected', + `last_installed_at` DATETIME NULL, + `last_updated_at` DATETIME NULL, + `last_error` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL, + UNIQUE KEY `uniq_home_workshop_item` (`home_id`, `workshop_item_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_home_cfg_id` (`home_cfg_id`), + KEY `idx_install_state` (`install_state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" +); ?> diff --git a/Panel/modules/addonsmanager/navigation.xml b/Panel/modules/addonsmanager/navigation.xml index 9e75f1d3..da0b4cc5 100644 --- a/Panel/modules/addonsmanager/navigation.xml +++ b/Panel/modules/addonsmanager/navigation.xml @@ -2,4 +2,5 @@ + diff --git a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh new file mode 100644 index 00000000..ab6b5271 --- /dev/null +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +MANIFEST_PATH="${1:-}" +if [[ -z "$MANIFEST_PATH" ]]; then + echo "Usage: $0 " + exit 1 +fi + +if [[ ! -f "$MANIFEST_PATH" ]]; then + echo "Manifest not found: $MANIFEST_PATH" + exit 1 +fi + +MANIFEST_DIR="$(dirname "$MANIFEST_PATH")" +WORKSHOP_DIR="${MANIFEST_DIR}/workshop" +REMOVED_DIR="${WORKSHOP_DIR}/removed" +LOG_FILE="${MANIFEST_DIR}/workshop_phase1.log" + +mkdir -p "$WORKSHOP_DIR" "$REMOVED_DIR" + +ACTION="$(python3 - <<'PY' "$MANIFEST_PATH" +import json,sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + data=json.load(f) +print(data.get("action","")) +PY +)" + +ITEMS="$(python3 - <<'PY' "$MANIFEST_PATH" +import json,sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + data=json.load(f) +items=data.get("items",[]) +print(",".join(str(x) for x in items if str(x).isdigit())) +PY +)" + +{ + echo "[$(date '+%Y-%m-%d %H:%M:%S')] workshop_phase1 action=${ACTION} manifest=${MANIFEST_PATH}" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] workshop_phase1 items=${ITEMS}" +} >> "$LOG_FILE" + +case "$ACTION" in + install|update) + # TODO: Replace with game-specific SteamCMD workshop install/update logic. + # Example flow: + # 1) Use workshop_app_id + item IDs from the manifest. + # 2) Download/refresh content into a controlled staging folder. + # 3) Copy/sync approved files into the game server content path. + ;; + remove) + # Phase 1 safety behavior: avoid destructive delete. + # TODO: move/disable per-item content using game-specific mapping rules. + echo "[$(date '+%Y-%m-%d %H:%M:%S')] remove requested; preserving files (non-destructive phase 1)." >> "$LOG_FILE" + ;; + *) + echo "Unknown workshop action: ${ACTION}" >> "$LOG_FILE" + exit 1 + ;; +esac + +exit 0 diff --git a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh new file mode 100644 index 00000000..c9b419ac --- /dev/null +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +MANIFEST_PATH="${1:-}" +if [[ -z "$MANIFEST_PATH" ]]; then + echo "Usage: $0 " + exit 1 +fi + +if [[ ! -f "$MANIFEST_PATH" ]]; then + echo "Manifest not found: $MANIFEST_PATH" + exit 1 +fi + +MANIFEST_DIR="$(dirname "$MANIFEST_PATH")" +WORKSHOP_DIR="${MANIFEST_DIR}/workshop" +REMOVED_DIR="${WORKSHOP_DIR}/removed" +LOG_FILE="${MANIFEST_DIR}/workshop_phase1_windows.log" + +mkdir -p "$WORKSHOP_DIR" "$REMOVED_DIR" + +ACTION="$(python3 - <<'PY' "$MANIFEST_PATH" +import json,sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + data=json.load(f) +print(data.get("action","")) +PY +)" + +ITEMS="$(python3 - <<'PY' "$MANIFEST_PATH" +import json,sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + data=json.load(f) +items=data.get("items",[]) +print(",".join(str(x) for x in items if str(x).isdigit())) +PY +)" + +{ + echo "[$(date '+%Y-%m-%d %H:%M:%S')] workshop_phase1_windows action=${ACTION} manifest=${MANIFEST_PATH}" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] workshop_phase1_windows items=${ITEMS}" +} >> "$LOG_FILE" + +case "$ACTION" in + install|update) + # TODO: Replace with game-specific SteamCMD workshop install/update logic for Cygwin environments. + ;; + remove) + # Phase 1 safety behavior: avoid destructive delete. + echo "[$(date '+%Y-%m-%d %H:%M:%S')] remove requested; preserving files (non-destructive phase 1)." >> "$LOG_FILE" + ;; + *) + echo "Unknown workshop action: ${ACTION}" >> "$LOG_FILE" + exit 1 + ;; +esac + +exit 0 diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php new file mode 100644 index 00000000..cb5c1d38 --- /dev/null +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -0,0 +1,200 @@ +query("ALTER TABLE `".OGP_DB_PREFIX."addons` MODIFY `addon_type` VARCHAR(32) NOT NULL"); + return (bool)$db->query( + "CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_content_workshop` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `home_cfg_id` INT NOT NULL, + `remote_server_id` INT NULL, + `workshop_app_id` VARCHAR(32) NULL, + `workshop_item_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'selected', + `last_installed_at` DATETIME NULL, + `last_updated_at` DATETIME NULL, + `last_error` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL, + UNIQUE KEY `uniq_home_workshop_item` (`home_id`, `workshop_item_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_home_cfg_id` (`home_cfg_id`), + KEY `idx_install_state` (`install_state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); +} + +function scm_get_home_for_user($db, $home_id, $user_id) +{ + $home_id = (int)$home_id; + $user_id = (int)$user_id; + if ($home_id <= 0 || $user_id <= 0) { + return false; + } + if ($db->isAdmin($user_id)) { + return $db->getGameHome($home_id); + } + return $db->getUserGameHome($user_id, $home_id); +} + +function scm_get_workshop_saved_count($db, $home_id) +{ + $home_id = (int)$home_id; + if ($home_id <= 0 || !scm_ensure_workshop_schema($db)) { + return 0; + } + $rows = $db->resultQuery( + "SELECT COUNT(*) AS cnt FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." AND install_state<>'removed'" + ); + if (!is_array($rows) || !isset($rows[0]['cnt'])) { + return 0; + } + return (int)$rows[0]['cnt']; +} + +function scm_get_workshop_rows($db, $home_id) +{ + $home_id = (int)$home_id; + if ($home_id <= 0 || !scm_ensure_workshop_schema($db)) { + return array(); + } + $rows = $db->resultQuery( + "SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." ORDER BY created_at DESC, workshop_item_id ASC" + ); + return is_array($rows) ? $rows : array(); +} + +function scm_parse_workshop_ids($raw, &$invalid = array()) +{ + $invalid = array(); + $ids = array(); + $parts = explode(',', (string)$raw); + foreach ((array)$parts as $part) { + $value = trim((string)$part); + if ($value === '') { + continue; + } + if (!preg_match('/^[0-9]+$/', $value)) { + $invalid[] = $value; + continue; + } + $ids[$value] = $value; + } + return array_values($ids); +} + +function scm_parse_selected_workshop_ids($selected) +{ + $ids = array(); + if (!is_array($selected)) { + return $ids; + } + foreach ($selected as $item_id) { + $item_id = trim((string)$item_id); + if ($item_id !== '' && preg_match('/^[0-9]+$/', $item_id)) { + $ids[$item_id] = $item_id; + } + } + return array_values($ids); +} + +function scm_h($value) +{ + return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); +} + +function scm_is_windows_home(array $home_info) +{ + $game_key = isset($home_info['game_key']) ? strtolower((string)$home_info['game_key']) : ''; + $cfg_file = isset($home_info['home_cfg_file']) ? strtolower((string)$home_info['home_cfg_file']) : ''; + return (strpos($game_key, 'win') !== false) || (strpos($cfg_file, 'win') !== false); +} + +function scm_path_is_under_home($home_path, $candidate_path) +{ + $home_path = rtrim(clean_path((string)$home_path), '/'); + $candidate_path = clean_path((string)$candidate_path); + if ($home_path === '' || $candidate_path === '') { + return false; + } + return strpos($candidate_path.'/', $home_path.'/') === 0; +} + +function scm_get_workshop_manifest_path(array $home_info) +{ + $home_path = rtrim(clean_path((string)$home_info['home_path']), '/'); + $manifest_path = clean_path($home_path . '/gsp_server_content/workshop_manifest.json'); + if (!scm_path_is_under_home($home_path, $manifest_path)) { + return false; + } + return $manifest_path; +} + +function scm_extract_workshop_app_id($server_xml) +{ + $candidates = array( + 'workshop_app_id', + 'workshop_appid', + 'steam_workshop_app_id', + 'steam_workshop_appid', + ); + foreach ((array)$candidates as $candidate) { + if (isset($server_xml->$candidate)) { + $value = trim((string)$server_xml->$candidate); + if ($value !== '' && preg_match('/^[0-9]+$/', $value)) { + return $value; + } + } + } + return ""; +} + +function scm_get_workshop_script_path(array $home_info, $server_xml) +{ + $key = scm_is_windows_home($home_info) ? 'workshop_script_windows' : 'workshop_script_linux'; + if (isset($server_xml->$key)) { + $xml_path = trim((string)$server_xml->$key); + if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) { + return $xml_path; + } + } + return scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT; +} + +function scm_get_csrf_token() +{ + if (empty($_SESSION['addonsmanager_workshop_csrf'])) { + $_SESSION['addonsmanager_workshop_csrf'] = md5(uniqid((string)mt_rand(), true)); + } + return $_SESSION['addonsmanager_workshop_csrf']; +} + +function scm_validate_csrf_token($token) +{ + if (!isset($_SESSION['addonsmanager_workshop_csrf'])) { + return false; + } + return hash_equals((string)$_SESSION['addonsmanager_workshop_csrf'], (string)$token); +} + diff --git a/Panel/modules/addonsmanager/user_addons.php b/Panel/modules/addonsmanager/user_addons.php index d5c84bbf..77b1c2a6 100644 --- a/Panel/modules/addonsmanager/user_addons.php +++ b/Panel/modules/addonsmanager/user_addons.php @@ -17,6 +17,7 @@ // Central category map — load so we can iterate all types dynamically. require_once(dirname(__FILE__) . '/server_content_categories.php'); +require_once(dirname(__FILE__) . '/server_content_helpers.php'); function exec_ogp_module() { global $db; @@ -44,8 +45,9 @@ function exec_ogp_module() { } if ($home_info) { + scm_ensure_workshop_schema($db); $home_cfg_id = $home_info['home_cfg_id']; - echo "

".get_lang('user_addons').": ".htmlentities($home_info['home_name'])."

\n". + echo "

Server Content: ".htmlentities($home_info['home_name'])."

\n". "\n". "\n"; @@ -58,6 +60,24 @@ function exec_ogp_module() { foreach ((array)$categories as $type_key => $type_label) { + if ($type_key === 'workshop') + { + $workshop_count = scm_get_workshop_saved_count($db, (int)$home_id); + if ($printed_any_cell) + echo " + + + + + + + + + + + + + + + + + + + + + +
\n"; + else + echo "\n"; + $printed_any_cell = true; + echo "" . + "Workshop Content (" . (int)$workshop_count . ")" . + "\n"; + continue; + } + $items = $db->resultQuery( "SELECT DISTINCT addon_id, name, game_name " . "FROM OGP_DB_PREFIXaddons " . diff --git a/Panel/modules/addonsmanager/workshop_action.php b/Panel/modules/addonsmanager/workshop_action.php new file mode 100644 index 00000000..5a865957 --- /dev/null +++ b/Panel/modules/addonsmanager/workshop_action.php @@ -0,0 +1,277 @@ +logger("server_content_workshop home_id=".(int)$home_id." user_id=".(int)$user_id." ".$message); +} + +function scm_workshop_update_rows_state($db, $home_id, array $item_ids, $state, $error = null, $mark_install = false, $mark_update = false) +{ + if (empty($item_ids)) { + return true; + } + $escaped_ids = array(); + foreach ($item_ids as $item_id) { + $escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'"; + } + $set = array( + "install_state='" . $db->realEscapeSingle($state) . "'", + "updated_at=NOW()", + ); + if ($mark_install) { + $set[] = "last_installed_at=NOW()"; + } + if ($mark_update) { + $set[] = "last_updated_at=NOW()"; + } + if ($error === null) { + $set[] = "last_error=NULL"; + } else { + $set[] = "last_error='" . $db->realEscapeSingle($error) . "'"; + } + + $query = "UPDATE `".OGP_DB_PREFIX."server_content_workshop` + SET ".implode(", ", $set)." + WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")"; + return (bool)$db->query($query); +} + +function scm_workshop_filter_existing_ids($db, $home_id, array $item_ids) +{ + if (empty($item_ids)) { + return array(); + } + $escaped_ids = array(); + foreach ($item_ids as $item_id) { + $escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'"; + } + $rows = $db->resultQuery( + "SELECT workshop_item_id FROM `".OGP_DB_PREFIX."server_content_workshop` + WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")" + ); + $allowed = array(); + if (is_array($rows)) { + foreach ((array)$rows as $row) { + $allowed[(string)$row['workshop_item_id']] = (string)$row['workshop_item_id']; + } + } + return array_values($allowed); +} + +function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $action, array $item_ids, &$error = '') +{ + $error = ''; + if (empty($item_ids)) { + $error = 'No Workshop IDs were selected for this action.'; + return false; + } + + $manifest_path = scm_get_workshop_manifest_path($home_info); + if ($manifest_path === false) { + $error = 'Manifest path validation failed for this server home.'; + return false; + } + + $script_path = scm_get_workshop_script_path($home_info, $server_xml); + $script_path = trim((string)$script_path); + if ($script_path === '' || !preg_match('/^[^\\r\\n\\0]+$/', $script_path)) { + $error = 'Workshop script path is invalid.'; + return false; + } + + $home_path = rtrim(clean_path((string)$home_info['home_path']), '/'); + if (!scm_path_is_under_home($home_path, $manifest_path)) { + $error = 'Manifest path is outside of the server home.'; + return false; + } + + $manifest_dir = dirname($manifest_path); + $manifest = array( + 'action' => (string)$action, + 'home_id' => (int)$home_info['home_id'], + 'home_cfg_id' => (int)$home_info['home_cfg_id'], + 'workshop_app_id' => scm_extract_workshop_app_id($server_xml), + 'items' => array_values($item_ids), + ); + $manifest_json = json_encode($manifest); + if ($manifest_json === false) { + $error = 'Failed to encode workshop manifest JSON.'; + return false; + } + + $remote = new OGPRemoteLibrary( + $home_info['agent_ip'], + $home_info['agent_port'], + $home_info['encryption_key'], + $home_info['timeout'] + ); + + $remote->exec("mkdir -p " . escapeshellarg($manifest_dir)); + if ((int)$remote->remote_writefile($manifest_path, $manifest_json) !== 1) { + $error = 'Failed to write workshop manifest to remote server.'; + return false; + } + if ((int)$remote->rfile_exists($script_path) !== 1) { + $error = 'Configured workshop script not found on agent host: ' . $script_path; + return false; + } + + $command = "bash " . escapeshellarg($script_path) . " " . escapeshellarg($manifest_path) . " ; echo __GSP_WORKSHOP_EXIT:$?"; + $output = $remote->exec($command); + if (!is_string($output) || $output === '') { + $error = 'Workshop script did not return an execution status.'; + return false; + } + if (!preg_match('/__GSP_WORKSHOP_EXIT:(\d+)/', $output, $matches)) { + $error = 'Workshop script exit marker not found in output.'; + return false; + } + $exit_code = (int)$matches[1]; + if ($exit_code !== 0) { + $error = 'Workshop script failed (exit '.$exit_code.'): '.trim($output); + return false; + } + return true; +} + +function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $raw_ids, array $selected_ids, &$message, &$is_error) +{ + $message = ''; + $is_error = true; + if (!scm_ensure_workshop_schema($db)) { + $message = 'Workshop schema migration failed.'; + return false; + } + + $home_id = (int)$home_info['home_id']; + $user_id = (int)$user_id; + $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); + if ($server_xml === false) { + $message = 'Unable to read server configuration for workshop action.'; + return false; + } + + if ($action === 'install_new') { + $invalid = array(); + $item_ids = scm_parse_workshop_ids($raw_ids, $invalid); + if (!empty($invalid)) { + $message = 'Invalid Workshop IDs: ' . implode(', ', $invalid); + return false; + } + if (empty($item_ids)) { + $message = 'Enter at least one numeric Workshop ID.'; + return false; + } + + foreach ($item_ids as $item_id) { + $query = "INSERT INTO `".OGP_DB_PREFIX."server_content_workshop` + (home_id, home_cfg_id, remote_server_id, workshop_app_id, workshop_item_id, install_state, created_by, created_at, updated_at) + VALUES ( + ".$home_id.", + ".(int)$home_info['home_cfg_id'].", + ".(int)$home_info['remote_server_id'].", + '".$db->realEscapeSingle(scm_extract_workshop_app_id($server_xml))."', + '".$db->realEscapeSingle($item_id)."', + 'selected', + ".$user_id.", + NOW(), + NOW() + ) + ON DUPLICATE KEY UPDATE + home_cfg_id=VALUES(home_cfg_id), + remote_server_id=VALUES(remote_server_id), + workshop_app_id=VALUES(workshop_app_id), + install_state='selected', + last_error=NULL, + updated_at=NOW()"; + $db->query($query); + } + + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false); + $error = ''; + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', $item_ids, $error); + if ($ok) { + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, true, true); + scm_workshop_log_action($db, $home_id, $user_id, "install_new ids=".implode(',', $item_ids)." status=success"); + $is_error = false; + $message = 'Workshop IDs installed successfully.'; + return true; + } + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); + scm_workshop_log_action($db, $home_id, $user_id, "install_new ids=".implode(',', $item_ids)." status=failed error=".$error); + $message = $error; + return false; + } + + if ($action === 'update_selected' || $action === 'remove_selected') { + $item_ids = scm_workshop_filter_existing_ids($db, $home_id, scm_parse_selected_workshop_ids($selected_ids)); + if (empty($item_ids)) { + $message = 'Select one or more saved Workshop IDs.'; + return false; + } + $target_action = ($action === 'remove_selected') ? 'remove' : 'update'; + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false); + $error = ''; + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, $target_action, $item_ids, $error); + if ($ok) { + if ($target_action === 'remove') { + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'removed', null, false, true); + } else { + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true); + } + scm_workshop_log_action($db, $home_id, $user_id, $action." ids=".implode(',', $item_ids)." status=success"); + $is_error = false; + $message = ($target_action === 'remove') ? 'Selected Workshop IDs marked removed.' : 'Selected Workshop IDs updated successfully.'; + return true; + } + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); + scm_workshop_log_action($db, $home_id, $user_id, $action." ids=".implode(',', $item_ids)." status=failed error=".$error); + $message = $error; + return false; + } + + if ($action === 'update_all') { + $rows = $db->resultQuery( + "SELECT workshop_item_id FROM `".OGP_DB_PREFIX."server_content_workshop` + WHERE home_id=".$home_id." AND install_state<>'removed'" + ); + $item_ids = array(); + if (is_array($rows)) { + foreach ((array)$rows as $row) { + $item_ids[] = (string)$row['workshop_item_id']; + } + } + $item_ids = scm_parse_selected_workshop_ids($item_ids); + if (empty($item_ids)) { + $message = 'No Workshop IDs are currently saved for this server.'; + return false; + } + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false); + $error = ''; + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'update', $item_ids, $error); + if ($ok) { + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true); + scm_workshop_log_action($db, $home_id, $user_id, "update_all ids=".implode(',', $item_ids)." status=success"); + $is_error = false; + $message = 'All saved Workshop IDs updated successfully.'; + return true; + } + scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); + scm_workshop_log_action($db, $home_id, $user_id, "update_all ids=".implode(',', $item_ids)." status=failed error=".$error); + $message = $error; + return false; + } + + $message = 'Invalid workshop action.'; + return false; +} + diff --git a/Panel/modules/addonsmanager/workshop_content.php b/Panel/modules/addonsmanager/workshop_content.php new file mode 100644 index 00000000..b5f915a3 --- /dev/null +++ b/Panel/modules/addonsmanager/workshop_content.php @@ -0,0 +1,148 @@ +Workshop Content: ".scm_h($home_info['home_name']).""; + if ($message !== '') { + if ($is_error) { + print_failure($message); + } else { + print_success($message); + } + } + ?> + + + +
Server Name:
Game Name:
+ + + + + + + + + + + + + + + + +
Enter Workshop IDs + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Workshop IDTitleStateLast InstalledLast UpdatedLast Error
No Workshop IDs saved for this server yet.
'>
+
+ + + + + + +
+ + +
+ + + + + + + +
+ Date: Mon, 18 May 2026 22:00:22 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20Server=20Content=20Phase=202=20?= =?UTF-8?q?=E2=80=93=20schema,=20cache=20mode=20setting,=20install=20histo?= =?UTF-8?q?ry,=20manifest,=20requires=5Fstop"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/117ba84d-d347-410b-a634-69df74f31061 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .../addonsmanager/SERVER_CONTENT_ROADMAP.md | 160 ++++++++-- .../addonsmanager/addons_installer.php | 49 ++- .../modules/addonsmanager/addons_manager.php | 144 +++++++-- Panel/modules/addonsmanager/module.php | 97 +++++- .../addonsmanager/server_content_helpers.php | 292 ++++++++++++++++++ Panel/modules/settings/settings.php | 22 +- 6 files changed, 715 insertions(+), 49 deletions(-) diff --git a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md index ec596aa4..d6a616f1 100644 --- a/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md +++ b/Panel/modules/addonsmanager/SERVER_CONTENT_ROADMAP.md @@ -34,21 +34,68 @@ User clicks button --- -## 2. Existing Database Fields (`OGP_DB_PREFIXaddons`) +## 2. Database Schema (db_version 4 — Phase 2) -| Column | Type | Description | -|---------------|-----------------|--------------------------------------------------| -| addon_id | INT UNSIGNED PK | Auto-increment primary key | -| name | VARCHAR(80) | Display name shown to users | -| url | VARCHAR(200) | Download URL (zip / tar.gz) | -| path | VARCHAR(80) | Relative target path inside server home | -| addon_type | VARCHAR(32)* | Content category key (plugin / mappack / config / …) | -| home_cfg_id | VARCHAR(7) | Linked game configuration ID | -| post_script | LONGTEXT | Bash script run by agent after install | -| group_id | INT(11) NULL | Restrict visibility to a specific user group | +### `OGP_DB_PREFIXaddons` (content item catalogue) -\* Expanded from VARCHAR(7) to VARCHAR(32) in db_version 2 - (migration runs automatically via the module update system). +| Column | Type | Default | Description | +|-----------------------|-----------------|---------------|-------------| +| addon_id | INT UNSIGNED PK | auto | Primary key | +| name | VARCHAR(80) | | Display name | +| url | VARCHAR(200) | | Download URL (zip / tar.gz / API endpoint) | +| path | VARCHAR(80) | | Relative target path inside server home | +| addon_type | VARCHAR(32) | | Category key (plugin / mappack / config / …) | +| home_cfg_id | VARCHAR(7) | | Linked game config ID | +| post_script | LONGTEXT | | Bash script run by agent after install | +| group_id | INT(11) NULL | | Restrict to a specific user group | +| install_method ¹ | VARCHAR(32) | download_zip | How the content is delivered | +| content_version ¹ | VARCHAR(64) NULL| | Version tag shown in manifest | +| requires_stop ¹ | TINYINT(1) | 1 | Must server be stopped before install? | +| backup_before_install¹| TINYINT(1) | 1 | Back up target path before installing? | +| restart_after_install¹| TINYINT(1) | 0 | Restart server after successful install? | +| is_cacheable ¹ | TINYINT(1) | 0 | Safe for cross-server copy / shared cache? | +| description ¹ | TEXT NULL | | Short description shown to users | + +¹ Added in db_version 4 (Phase 2). + +### `OGP_DB_PREFIXserver_content_manifest` (Phase 2 — per-server installed state) + +One row per (home_id, addon_id) pair. Updated on every successful install. + +| Column | Type | Description | +|-----------------|------------------|-------------| +| id | INT UNSIGNED PK | Auto primary key | +| home_id | INT | Server home | +| addon_id | INT | Installed content item | +| install_method | VARCHAR(32) | Method used for the install | +| content_version | VARCHAR(64) NULL | Version tag at time of install | +| install_state | VARCHAR(32) | installed / failed / removed | +| checksum_sha256 | VARCHAR(64) NULL | SHA-256 of the installed archive | +| source_url | VARCHAR(255) NULL| URL used for the install | +| installed_at | DATETIME | Timestamp of last successful install | +| installed_by | INT NULL | User ID who performed the install | +| updated_at | DATETIME NULL | Last update time | +| notes | TEXT NULL | Free-text admin notes | + +### `OGP_DB_PREFIXserver_content_install_history` (Phase 2 — install log) + +One row per install attempt. Never deleted (audit trail). + +| Column | Type | Description | +|-----------------|-------------------|-------------| +| id | INT UNSIGNED PK | Auto primary key | +| home_id | INT | Server home | +| addon_id | INT | Content item installed | +| installed_by | INT NULL | User who triggered the install | +| started_at | DATETIME | When the install was initiated | +| completed_at | DATETIME NULL | When the install completed | +| install_state | VARCHAR(32) | started / installed / failed / cancelled | +| install_method | VARCHAR(32) NULL | Method used | +| content_version | VARCHAR(64) NULL | Version tag | +| source_url | VARCHAR(255) NULL | Source URL used | +| cache_mode_used | VARCHAR(32) NULL | Value of server_content_cache_mode at install time | +| result_code | INT NULL | Script exit code (0 = success) | +| log_output | MEDIUMTEXT NULL | Script / download log excerpt | --- @@ -161,21 +208,38 @@ Apply this as `$install_queries[2]` (db_version 3) in `module.php` when ready. ## 9. Recommended Phased Migration Plan -### Phase 1 (complete — this PR) +### Phase 1 (complete) - [x] UI labels renamed to "Server Content Manager / Server Content". - [x] Central category map created (`server_content_categories.php`). - [x] `addon_type` column expanded to VARCHAR(32) via db_version 2 migration. - [x] `addons_installer.php` and `user_addons.php` use category map for validation. - [x] Full TODO/comment blocks added to installer for next phase work. - [x] Module folder, table names, URL routes, function names unchanged. +- [x] Workshop Content Phase 1: manual Workshop ID entry, per-server manifest, + agent script runner, install/update/remove actions (`db_version 3`). ### Phase 2 — Schema & install_method support -- [ ] Apply the `$install_queries[2]` schema above (db_version 3). -- [ ] Add `install_method` dropdown to admin create/edit form. -- [ ] Implement `requires_stop` check in installer before download. -- [ ] Implement `backup_before_install` using agent tar/zip helper. -- [ ] Implement `restart_after_install` using existing server start logic. -- [ ] Add install history table and log writes. +- [x] Apply Phase 2 schema: `install_method`, `content_version`, `requires_stop`, + `backup_before_install`, `restart_after_install`, `is_cacheable`, `description` + columns added to `addons` table via `$install_queries[3]` (db_version 4). +- [x] `server_content_manifest` table created – tracks one row per installed + content item per server home (version, checksum, state, installed_by). +- [x] `server_content_install_history` table created – one row per install + attempt with result code, log snippet, and cache_mode_used. +- [x] `install_method` dropdown added to admin create/edit form. +- [x] `content_version`, `description`, `is_cacheable` fields added to form. +- [x] `requires_stop` / `backup_before_install` / `restart_after_install` + checkboxes added to admin form. +- [x] `requires_stop` check implemented in installer: blocks install if server + is running (Phase 3 will add automatic stop/start). +- [x] Install history row written on `state=start`; completed on success. +- [x] Manifest row upserted on successful install. +- [x] `server_content_cache_mode` panel setting added (disabled / search_existing_servers / + shared_cache / shared_cache_and_search; default: **disabled**). +- [x] `is_cacheable` flag stored per content item; cache mode and is_cacheable + guard are in place — actual cross-server/cache copy logic is Phase 3. +- [ ] `backup_before_install` agent call (Phase 3 — needs agent tar/zip helper). +- [ ] `restart_after_install` agent call (Phase 3 — needs server start after install). ### Phase 3 — Steam Workshop integration See Part 6 below. @@ -295,3 +359,59 @@ Steam Workshop content is treated as a Server Content type (`addon_type=workshop 6. **Install history must be logged.** - Record who installed what, when, and the script exit code. - This log must be readable by admins but not modifiable by users. + +--- + +## 14. Part 10: Content Reuse / Cache Mode Security Rules + +### Governing setting: `server_content_cache_mode` + +| Value | Agent behaviour | +|--------------------------|-----------------| +| `disabled` (**default**) | Always install from the configured source/script. No cross-server copy, no shared cache. | +| `search_existing_servers` | Agent may scan other local game-server folders for matching **cacheable** content and copy directly if the checksum matches. | +| `shared_cache` | Agent may store **cacheable** content in a shared cache folder and use it for future installs. | +| `shared_cache_and_search` | Both `shared_cache` and `search_existing_servers` are active. | + +### Content eligibility for sharing / caching + +Only content where **`is_cacheable = 1`** on the addon record may ever be +shared or placed in the shared cache. Admins must explicitly opt in per item. + +**Never mark the following as cacheable:** +- Config files, server.cfg, whitelist / banlist, RCON passwords +- User-edited files, saves, player databases +- Log files, crash dumps +- Files containing credentials, tokens, or keys +- Entire server home directories + +**Safe to mark cacheable (public, non-sensitive, reproducible):** +- Publicly distributed map packs (.bsp, .nav, .vmf bundles) +- Workshop content / mod archives sourced from public URLs +- Vanilla server jars (Minecraft, etc.) from official APIs +- Stock config templates (not user-edited copies) + +### Checksum enforcement + +When a cached or copied file is used, the agent **must** verify the SHA-256 +checksum against the value stored in `server_content_manifest.checksum_sha256` +(if present). A checksum mismatch must abort the install and fall back to the +original source download. + +### Path restrictions + +- The agent must never copy files **outside** approved content paths. +- Users **cannot** specify source paths. Only admin-defined content items + define where content comes from and where it is placed. +- Path traversal (`../`) is forbidden in all `path` fields. The panel + validates this; the agent provides a second layer of defence. +- The shared cache folder must be separate from any game server home and not + accessible directly by game server processes. + +### Phase 3 implementation checklist (not yet done) + +- [ ] Agent-side: implement copy-from-existing-server with checksum verification. +- [ ] Agent-side: implement shared cache store and restore. +- [ ] Panel-side: validate `is_cacheable` before passing cache-mode hint to agent. +- [ ] Panel-side: expose cache hit/miss in install history log. +- [ ] Shared cache path configurable in panel settings (`server_content_cache_path`). diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index 7e86b645..240050ef 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -161,7 +161,7 @@ function exec_ogp_module() { { $addon_id = (int)$_REQUEST['addon_id']; - $addons_rows = $db->resultQuery("SELECT url, path, post_script FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); + $addons_rows = $db->resultQuery("SELECT url, path, post_script, addon_type, install_method, content_version, requires_stop, restart_after_install FROM OGP_DB_PREFIXaddons WHERE addon_id=".$addon_id.$query_groups); if (!is_array($addons_rows)) { $addons_rows = []; } @@ -174,7 +174,23 @@ function exec_ogp_module() { $remote = new OGPRemoteLibrary($home_info['agent_ip'],$home_info['agent_port'],$home_info['encryption_key'],$home_info['timeout']); - $addon_info = $addons_rows[0]; + $addon_info = $addons_rows[0]; + $install_method = isset($addon_info['install_method']) ? $addon_info['install_method'] : 'download_zip'; + $content_version = isset($addon_info['content_version']) ? $addon_info['content_version'] : ''; + $requires_stop = !empty($addon_info['requires_stop']) ? 1 : 0; + + // ── requires_stop guard ─────────────────────────────────────────────── + // If the content item requires the server to be stopped first, check + // whether the server is currently running and block the install if so. + // (Phase 2 blocks install; automatic stop/start is Phase 3.) + if ( $state == "start" && $requires_stop ) { + $is_running = $remote->is_screen_running( $home_info['home_name'], $home_info['home_id'] ); + if ( $is_running === 1 ) { + print_failure('This content item requires the server to be stopped before installing. Please stop the server and try again.'); + echo "

".get_lang('back')."

"; + return; + } + } $url = $addon_info['url']; $filename = basename($url); #### Replace template variables in the post-install script with @@ -231,8 +247,22 @@ function exec_ogp_module() { } #### end of replacements - if ( $state == "start" AND $addon_id != "" ) + if ( $state == "start" AND $addon_id != "" ) { + // Record install attempt in history before triggering download. + $cache_mode = scm_get_cache_mode($db); + $history_id = scm_record_install_start( + $db, + $home_id, + $addon_id, + $user_id, + $addon_info['url'], + $content_version, + $install_method, + $cache_mode + ); + $_SESSION['scm_history_id_' . $home_id . '_' . $addon_id] = $history_id; $pid = $remote->start_file_download( $addon_info['url'], $home_info['home_path']."/".$addon_info['path'], $filename, "uncompress", $post_script); + } $headers = get_headers($url, 1); @@ -301,6 +331,19 @@ function exec_ogp_module() { elseif( $remote->is_file_download_in_progress($pid) === 0 AND $remote->is_screen_running("post_script",$pid) === 0 ) { print_success(get_lang('addon_installed_successfully')); + // Update install history and manifest on successful completion. + $history_key = 'scm_history_id_' . $home_id . '_' . $addon_id; + if (!empty($_SESSION[$history_key])) { + scm_record_install_done($db, (int)$_SESSION[$history_key], 'installed', 0); + unset($_SESSION[$history_key]); + } + scm_upsert_manifest($db, $home_id, $addon_id, array( + 'install_method' => $install_method, + 'content_version' => $content_version, + 'install_state' => 'installed', + 'source_url' => $addon_info['url'], + 'installed_by' => $user_id, + )); echo "

".get_lang('back')."

"; $view->refresh("?m=addonsmanager&p=user_addons&home_id=$home_id". diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index 0527f987..ef9ecde3 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -25,16 +25,21 @@ // Central category map — defines all valid addon_type values and their labels. require_once(dirname(__FILE__) . '/server_content_categories.php'); +require_once(dirname(__FILE__) . '/server_content_helpers.php'); function exec_ogp_module() { global $db; + // Ensure Phase 2 schema is present (idempotent). + scm_ensure_phase2_schema($db); + // Build the complete list of allowed content types from the category map. // Admins can create items of any registered type; the original three types // (plugin, mappack, config) are always included. $addon_types = get_server_content_type_keys(); // all keys $addon_type_labels = get_server_content_categories(); // key => label + $install_methods = scm_get_install_methods(); // install_method keys => labels if (isset($_POST['create_addon']) AND isset($_POST['name']) AND $_POST['url']=="") { @@ -54,13 +59,21 @@ function exec_ogp_module() { } elseif (isset($_POST['create_addon']) AND isset($_POST['name']) AND isset($_POST['url']) AND isset($_POST['addon_type']) and isset($_POST['home_cfg_id']) ) { - $fields['name'] = $_POST['name']; - $fields['url'] = $_POST['url']; - $fields['path'] = $_POST['path']; - $fields['addon_type'] = $_POST['addon_type']; - $fields['home_cfg_id'] = $_POST['home_cfg_id']; - $fields['post_script'] = $_POST['post_script']; - $fields['group_id'] = $_POST['group_id']; + $valid_install_methods = array_keys($install_methods); + $fields['name'] = $_POST['name']; + $fields['url'] = $_POST['url']; + $fields['path'] = $_POST['path']; + $fields['addon_type'] = $_POST['addon_type']; + $fields['home_cfg_id'] = $_POST['home_cfg_id']; + $fields['post_script'] = $_POST['post_script']; + $fields['group_id'] = $_POST['group_id']; + $fields['install_method'] = in_array($_POST['install_method'], $valid_install_methods) ? $_POST['install_method'] : 'download_zip'; + $fields['content_version'] = isset($_POST['content_version']) ? $_POST['content_version'] : ''; + $fields['requires_stop'] = !empty($_POST['requires_stop']) ? 1 : 0; + $fields['backup_before_install'] = !empty($_POST['backup_before_install']) ? 1 : 0; + $fields['restart_after_install'] = !empty($_POST['restart_after_install']) ? 1 : 0; + $fields['is_cacheable'] = !empty($_POST['is_cacheable']) ? 1 : 0; + $fields['description'] = isset($_POST['description']) ? $_POST['description'] : ''; if( is_numeric($db->resultInsertId( 'addons', $fields )) ) { print_success(get_lang_f("addon_has_been_created",$_POST['name'])); @@ -70,13 +83,20 @@ function exec_ogp_module() { } echo "

".get_lang('addons_manager')."

"; - $name = isset($_POST['name']) ? $_POST['name'] : ""; - $url = isset($_POST['url']) ? $_POST['url'] : ""; - $path = isset($_POST['path']) ? $_POST['path'] : ""; - $post_script = isset($_POST['post_script']) ? $_POST['post_script'] : ""; - $home_cfg_id = isset($_POST['home_cfg_id']) ? $_POST['home_cfg_id'] : ""; - $addon_type = isset($_POST['addon_type']) ? $_POST['addon_type'] : ""; - $group_id = isset($_POST['group_id']) ? $_POST['group_id'] : ""; + $name = isset($_POST['name']) ? $_POST['name'] : ""; + $url = isset($_POST['url']) ? $_POST['url'] : ""; + $path = isset($_POST['path']) ? $_POST['path'] : ""; + $post_script = isset($_POST['post_script']) ? $_POST['post_script'] : ""; + $home_cfg_id = isset($_POST['home_cfg_id']) ? $_POST['home_cfg_id'] : ""; + $addon_type = isset($_POST['addon_type']) ? $_POST['addon_type'] : ""; + $group_id = isset($_POST['group_id']) ? $_POST['group_id'] : ""; + $install_method = isset($_POST['install_method']) ? $_POST['install_method'] : "download_zip"; + $content_version = isset($_POST['content_version']) ? $_POST['content_version'] : ""; + $requires_stop = isset($_POST['requires_stop']) ? (int)$_POST['requires_stop'] : 1; + $backup_before_install = isset($_POST['backup_before_install']) ? (int)$_POST['backup_before_install'] : 1; + $restart_after_install = isset($_POST['restart_after_install']) ? (int)$_POST['restart_after_install'] : 0; + $is_cacheable = isset($_POST['is_cacheable']) ? (int)$_POST['is_cacheable'] : 0; + $description = isset($_POST['description']) ? $_POST['description'] : ""; if (isset($_POST['addon_id']) && (int)$_POST['addon_id'] > 0 && isset($_POST['edit'])) { @@ -84,14 +104,21 @@ function exec_ogp_module() { if (!is_array($addons_rows)) { $addons_rows = []; } - $addon_info = $addons_rows[0]; - $name = isset($addon_info['name']) ? $addon_info['name'] : ""; - $url = isset($addon_info['url']) ? $addon_info['url'] : ""; - $path = isset($addon_info['path']) ? $addon_info['path'] : ""; - $post_script = isset($addon_info['post_script']) ? $addon_info['post_script'] : ""; - $home_cfg_id = isset($addon_info['home_cfg_id']) ? $addon_info['home_cfg_id'] : ""; - $addon_type = isset($addon_info['addon_type']) ? $addon_info['addon_type'] : ""; - $group_id = isset($addon_info['group_id']) ? $addon_info['group_id'] : ""; + $addon_info = $addons_rows[0]; + $name = isset($addon_info['name']) ? $addon_info['name'] : ""; + $url = isset($addon_info['url']) ? $addon_info['url'] : ""; + $path = isset($addon_info['path']) ? $addon_info['path'] : ""; + $post_script = isset($addon_info['post_script']) ? $addon_info['post_script'] : ""; + $home_cfg_id = isset($addon_info['home_cfg_id']) ? $addon_info['home_cfg_id'] : ""; + $addon_type = isset($addon_info['addon_type']) ? $addon_info['addon_type'] : ""; + $group_id = isset($addon_info['group_id']) ? $addon_info['group_id'] : ""; + $install_method = isset($addon_info['install_method']) ? $addon_info['install_method'] : "download_zip"; + $content_version = isset($addon_info['content_version']) ? $addon_info['content_version'] : ""; + $requires_stop = isset($addon_info['requires_stop']) ? (int)$addon_info['requires_stop'] : 1; + $backup_before_install = isset($addon_info['backup_before_install']) ? (int)$addon_info['backup_before_install'] : 1; + $restart_after_install = isset($addon_info['restart_after_install']) ? (int)$addon_info['restart_after_install'] : 0; + $is_cacheable = isset($addon_info['is_cacheable']) ? (int)$addon_info['is_cacheable'] : 0; + $description = isset($addon_info['description']) ? $addon_info['description'] : ""; } ?>
@@ -214,6 +241,79 @@ function exec_ogp_module() {
+ Install Method + + + The mechanism used to deliver this content to the server. +
+ Content Version + + + Optional version tag shown in the installed-content list. +
+ Description + + +
+ Behaviour Options + + +    + +    + +
+ Content Reuse + + + + Only check this for public, non-sensitive content (maps, mods, jars). + Never check for configs, saves, credentials, or user-edited files. + Caching only activates when the Server Content Cache Mode panel + setting (in Panel Settings) is set to something other than Disabled. + +
'addons_manager', 'name' => 'Server Content Manager', 'group' => 'admin' ) @@ -72,4 +77,92 @@ $install_queries[2] = array( KEY `idx_install_state` (`install_state`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" ); + +// ── db_version 4 : Phase 2 – install_method, per-server manifest, install history ── +// +// Uses a PHP callable so each ALTER is applied only when the column does not +// already exist (safe for repeated runs, compatible with all MySQL versions). +// +$install_queries[3] = array( + function ($db) { + $prefix = OGP_DB_PREFIX; + + // ── Extend the addons table with Phase 2 columns ────────────────────── + $new_columns = array( + 'install_method' => "VARCHAR(32) NOT NULL DEFAULT 'download_zip' AFTER `group_id`", + 'content_version' => "VARCHAR(64) NULL AFTER `install_method`", + 'requires_stop' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `content_version`", + 'backup_before_install' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `requires_stop`", + 'restart_after_install' => "TINYINT(1) NOT NULL DEFAULT 0 AFTER `backup_before_install`", + 'is_cacheable' => "TINYINT(1) NOT NULL DEFAULT 0 AFTER `restart_after_install`", + 'description' => "TEXT NULL AFTER `is_cacheable`", + ); + + foreach ($new_columns as $col => $definition) { + $escaped_col = $db->realEscapeSingle($col); + $escaped_table = $db->realEscapeSingle($prefix . 'addons'); + $check = $db->resultQuery( + "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escaped_table}' + AND COLUMN_NAME = '{$escaped_col}'" + ); + if (empty($check)) { + if (!$db->query("ALTER TABLE `{$prefix}addons` ADD COLUMN `{$col}` {$definition}")) { + return false; + } + } + } + + // ── Per-server installed-content manifest ───────────────────────────── + if (!$db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_manifest` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `install_method` VARCHAR(32) NOT NULL DEFAULT 'download_zip', + `content_version` VARCHAR(64) NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'installed', + `checksum_sha256` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `installed_by` INT NULL, + `updated_at` DATETIME NULL, + `notes` TEXT NULL, + UNIQUE KEY `uniq_home_addon` (`home_id`, `addon_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_install_state` (`install_state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + )) { + return false; + } + + // ── Install history (one row per install attempt) ───────────────────── + if (!$db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_install_history` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `installed_by` INT NULL, + `started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `completed_at` DATETIME NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'started', + `install_method` VARCHAR(32) NULL, + `content_version` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `cache_mode_used` VARCHAR(32) NULL, + `result_code` INT NULL, + `log_output` MEDIUMTEXT NULL, + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_started_at` (`started_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + )) { + return false; + } + + return true; + }, +); ?> diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php index cb5c1d38..efebbb0c 100644 --- a/Panel/modules/addonsmanager/server_content_helpers.php +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -198,3 +198,295 @@ function scm_validate_csrf_token($token) return hash_equals((string)$_SESSION['addonsmanager_workshop_csrf'], (string)$token); } +// ───────────────────────────────────────────────────────────────────────────── +// Phase 2 helpers – schema guard, cache mode, manifest, install history +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Returns the allowed values for the server_content_cache_mode panel setting. + * + * disabled – Always install from the configured source. No scanning, + * no shared cache. (DEFAULT – safest choice) + * search_existing_servers – Agent may scan other local game-server folders for + * matching cacheable content and copy directly if safe. + * shared_cache – Agent may store cacheable content in a shared cache + * folder and reuse it on future installs. + * shared_cache_and_search – Both shared_cache and search_existing_servers are + * active simultaneously. + * + * Security note: only content explicitly marked is_cacheable=1 on the addon + * record may ever be shared or cached. Private configs, user-edited files, + * saves, databases, logs, and credentials must never be included. + * + * @return array key => human-readable label + */ +function scm_get_valid_cache_modes() +{ + return array( + 'disabled' => 'Disabled (always install from source)', + 'search_existing_servers' => 'Search existing servers (copy from local installs)', + 'shared_cache' => 'Shared cache (store and reuse cached copies)', + 'shared_cache_and_search' => 'Shared cache + search existing servers', + ); +} + +/** + * Reads the current server_content_cache_mode panel setting. + * Returns 'disabled' if not set. + * + * @param object $db Panel DB handle + * @return string One of the scm_get_valid_cache_modes() keys + */ +function scm_get_cache_mode($db) +{ + $valid = scm_get_valid_cache_modes(); + $value = ''; + if (method_exists($db, 'getSetting')) { + $value = (string)$db->getSetting('server_content_cache_mode'); + } + return array_key_exists($value, $valid) ? $value : 'disabled'; +} + +/** + * Returns allowed install_method values and their display labels. + * + * @return array + */ +function scm_get_install_methods() +{ + return array( + 'download_zip' => 'Download & extract archive (.zip / .tar.gz)', + 'download_file' => 'Download single file (no extraction)', + 'post_script' => 'Run post-script only (no download)', + 'steam_workshop' => 'Steam Workshop (via agent SteamCMD)', + 'minecraft_jar' => 'Minecraft server jar / version switcher', + 'profile_copy' => 'Copy stored profile directory', + ); +} + +/** + * Idempotently ensures the Phase 2 schema is present. + * Called from pages that use manifest / history data so that existing + * installs that have not yet run the module updater are covered. + * + * @param object $db Panel DB handle + * @return bool + */ +function scm_ensure_phase2_schema($db) +{ + static $phase2_checked = false; + if ($phase2_checked) { + return true; + } + $phase2_checked = true; + $prefix = OGP_DB_PREFIX; + + // ── Extend addons table ─────────────────────────────────────────────────── + $new_columns = array( + 'install_method' => "VARCHAR(32) NOT NULL DEFAULT 'download_zip'", + 'content_version' => "VARCHAR(64) NULL", + 'requires_stop' => "TINYINT(1) NOT NULL DEFAULT 1", + 'backup_before_install' => "TINYINT(1) NOT NULL DEFAULT 1", + 'restart_after_install' => "TINYINT(1) NOT NULL DEFAULT 0", + 'is_cacheable' => "TINYINT(1) NOT NULL DEFAULT 0", + 'description' => "TEXT NULL", + ); + foreach ($new_columns as $col => $definition) { + $escaped_col = $db->realEscapeSingle($col); + $escaped_table = $db->realEscapeSingle($prefix . 'addons'); + $check = $db->resultQuery( + "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$escaped_table}' + AND COLUMN_NAME = '{$escaped_col}'" + ); + if (empty($check)) { + $db->query("ALTER TABLE `{$prefix}addons` ADD COLUMN `{$col}` {$definition}"); + } + } + + // ── Per-server manifest ─────────────────────────────────────────────────── + $db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_manifest` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `install_method` VARCHAR(32) NOT NULL DEFAULT 'download_zip', + `content_version` VARCHAR(64) NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'installed', + `checksum_sha256` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `installed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `installed_by` INT NULL, + `updated_at` DATETIME NULL, + `notes` TEXT NULL, + UNIQUE KEY `uniq_home_addon` (`home_id`, `addon_id`), + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_install_state` (`install_state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + // ── Install history ─────────────────────────────────────────────────────── + $db->query( + "CREATE TABLE IF NOT EXISTS `{$prefix}server_content_install_history` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `home_id` INT NOT NULL, + `addon_id` INT NOT NULL, + `installed_by` INT NULL, + `started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `completed_at` DATETIME NULL, + `install_state` VARCHAR(32) NOT NULL DEFAULT 'started', + `install_method` VARCHAR(32) NULL, + `content_version` VARCHAR(64) NULL, + `source_url` VARCHAR(255) NULL, + `cache_mode_used` VARCHAR(32) NULL, + `result_code` INT NULL, + `log_output` MEDIUMTEXT NULL, + KEY `idx_home_id` (`home_id`), + KEY `idx_addon_id` (`addon_id`), + KEY `idx_started_at` (`started_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + return true; +} + +/** + * Returns all manifest rows for a given server home. + * + * @param object $db + * @param int $home_id + * @return array + */ +function scm_get_manifest_rows($db, $home_id) +{ + $home_id = (int)$home_id; + if ($home_id <= 0 || !scm_ensure_phase2_schema($db)) { + return array(); + } + $rows = $db->resultQuery( + "SELECT m.*, a.name AS addon_name, a.addon_type, a.install_method AS addon_install_method + FROM `".OGP_DB_PREFIX."server_content_manifest` m + LEFT JOIN `".OGP_DB_PREFIX."addons` a ON a.addon_id = m.addon_id + WHERE m.home_id = {$home_id} + ORDER BY m.installed_at DESC" + ); + return is_array($rows) ? $rows : array(); +} + +/** + * Creates a new install history row and returns its insert ID. + * Returns 0 on failure. + * + * @param object $db + * @param int $home_id + * @param int $addon_id + * @param int $user_id + * @param string $source_url + * @param string $content_version + * @param string $install_method + * @param string $cache_mode_used + * @return int history row ID, or 0 on failure + */ +function scm_record_install_start($db, $home_id, $addon_id, $user_id, $source_url = '', $content_version = '', $install_method = 'download_zip', $cache_mode_used = 'disabled') +{ + $home_id = (int)$home_id; + $addon_id = (int)$addon_id; + $user_id = (int)$user_id; + $source_url = $db->realEscapeSingle((string)$source_url); + $content_version = $db->realEscapeSingle((string)$content_version); + $install_method = $db->realEscapeSingle((string)$install_method); + $cache_mode_used = $db->realEscapeSingle((string)$cache_mode_used); + + if (!scm_ensure_phase2_schema($db)) { + return 0; + } + $id = $db->resultInsertId( + 'server_content_install_history', + array( + 'home_id' => $home_id, + 'addon_id' => $addon_id, + 'installed_by' => $user_id, + 'install_state' => 'started', + 'install_method' => $install_method, + 'content_version' => $content_version, + 'source_url' => $source_url, + 'cache_mode_used' => $cache_mode_used, + ) + ); + return is_numeric($id) ? (int)$id : 0; +} + +/** + * Updates an existing install history row with the final result. + * + * @param object $db + * @param int $history_id + * @param string $state 'installed' | 'failed' | 'cancelled' + * @param int $result_code Exit code (0 = success) + * @param string $log_output Script/download log snippet + * @return bool + */ +function scm_record_install_done($db, $history_id, $state = 'installed', $result_code = 0, $log_output = '') +{ + $history_id = (int)$history_id; + $state = $db->realEscapeSingle((string)$state); + $result_code = (int)$result_code; + $log_output = $db->realEscapeSingle((string)$log_output); + if ($history_id <= 0) { + return false; + } + return (bool)$db->query( + "UPDATE `".OGP_DB_PREFIX."server_content_install_history` + SET install_state = '{$state}', + result_code = {$result_code}, + log_output = '{$log_output}', + completed_at = NOW() + WHERE id = {$history_id}" + ); +} + +/** + * Inserts or updates a server_content_manifest row for a successful install. + * + * @param object $db + * @param int $home_id + * @param int $addon_id + * @param array $fields Optional overrides: install_method, content_version, + * install_state, source_url, checksum_sha256, installed_by + * @return bool + */ +function scm_upsert_manifest($db, $home_id, $addon_id, array $fields = array()) +{ + $home_id = (int)$home_id; + $addon_id = (int)$addon_id; + if ($home_id <= 0 || $addon_id <= 0 || !scm_ensure_phase2_schema($db)) { + return false; + } + $install_method = $db->realEscapeSingle((string)(isset($fields['install_method']) ? $fields['install_method'] : 'download_zip')); + $content_version = $db->realEscapeSingle((string)(isset($fields['content_version']) ? $fields['content_version'] : '')); + $install_state = $db->realEscapeSingle((string)(isset($fields['install_state']) ? $fields['install_state'] : 'installed')); + $source_url = $db->realEscapeSingle((string)(isset($fields['source_url']) ? $fields['source_url'] : '')); + $checksum = $db->realEscapeSingle((string)(isset($fields['checksum_sha256']) ? $fields['checksum_sha256'] : '')); + $installed_by = isset($fields['installed_by']) ? (int)$fields['installed_by'] : 'NULL'; + if ($installed_by !== 'NULL' && $installed_by <= 0) { + $installed_by = 'NULL'; + } + + return (bool)$db->query( + "INSERT INTO `".OGP_DB_PREFIX."server_content_manifest` + (`home_id`,`addon_id`,`install_method`,`content_version`,`install_state`,`source_url`,`checksum_sha256`,`installed_by`,`installed_at`,`updated_at`) + VALUES + ({$home_id},{$addon_id},'{$install_method}','{$content_version}','{$install_state}','{$source_url}','{$checksum}',{$installed_by},NOW(),NOW()) + ON DUPLICATE KEY UPDATE + install_method = VALUES(install_method), + content_version = VALUES(content_version), + install_state = VALUES(install_state), + source_url = VALUES(source_url), + checksum_sha256 = VALUES(checksum_sha256), + installed_at = NOW(), + updated_at = NOW()" + ); +} + diff --git a/Panel/modules/settings/settings.php b/Panel/modules/settings/settings.php index e69798b0..ce988c9e 100644 --- a/Panel/modules/settings/settings.php +++ b/Panel/modules/settings/settings.php @@ -77,7 +77,9 @@ function exec_ogp_module() "discord_webhook_main" => $_REQUEST['discord_webhook_main'], "discord_webhook_admin" => $_REQUEST['discord_webhook_admin'], // Debug - "debug_level" => $_REQUEST['debug_level'] ?? '1' + "debug_level" => $_REQUEST['debug_level'] ?? '1', + // Server Content + "server_content_cache_mode" => $_REQUEST['server_content_cache_mode'] ?? 'disabled', ); $db->setSettings($settings); @@ -208,8 +210,24 @@ function exec_ogp_module() $ft->add_custom_field('debug_level', create_drop_box_from_array($debug_level_options, 'debug_level', @$row['debug_level'] ?? '1', false)); + // Server Content cache mode + // Controls whether the agent may copy cacheable content from other servers + // or a shared cache instead of always re-downloading from the source. + // Default: disabled (safest – no copying of any content between servers). + // See addonsmanager/SERVER_CONTENT_ROADMAP.md for full description. + $cache_mode_options = array( + 'disabled' => 'Disabled (always install from source — default)', + 'search_existing_servers' => 'Search existing servers (copy cacheable content from local game-server folders)', + 'shared_cache' => 'Shared cache (store and reuse cacheable content in a shared cache folder)', + 'shared_cache_and_search' => 'Shared cache + search existing servers', + ); + $current_cache_mode = isset($row['server_content_cache_mode']) && array_key_exists($row['server_content_cache_mode'], $cache_mode_options) + ? $row['server_content_cache_mode'] : 'disabled'; + $ft->add_custom_field('server_content_cache_mode', + create_drop_box_from_array($cache_mode_options, 'server_content_cache_mode', $current_cache_mode, false)); + // Add option to reset game server order to default - $ft->add_field('checkbox','reset_game_server_order','0'); + $ft->add_field('checkbox','reset_game_server_order','0'); $ft->end_table(); $ft->add_button("submit","update_settings",get_lang('update_settings')); From b8a0c45bbe1fa9b2217d89e8b14ca71309e8829b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 22:08:08 +0000 Subject: [PATCH 5/8] Add server content scheduler hooks and cron wiring Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/1f4380da-e76c-48d2-abe4-6f8f5f26e562 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .github/module-map.md | 2 +- Panel/CHANGELOG.md | 1 + Panel/docs/COPILOT_TODO.md | 1 + Panel/includes/api_functions.php | 3 + .../addonsmanager/server_content_actions.php | 472 ++++++++++++++++++ Panel/modules/cron/cron.php | 19 +- Panel/modules/cron/shared_cron_functions.php | 66 ++- Panel/modules/cron/user_cron.php | 19 +- Panel/ogp_api.php | 41 ++ 9 files changed, 593 insertions(+), 31 deletions(-) create mode 100644 Panel/modules/addonsmanager/server_content_actions.php diff --git a/.github/module-map.md b/.github/module-map.md index fae19d9f..39a9b38d 100644 --- a/.github/module-map.md +++ b/.github/module-map.md @@ -40,7 +40,7 @@ This file captures how the control panel, storefront, agents, and helper scripts | `extras`, `addonsmanager` | Workshop/add-on management. | Hooks into game homes after provisioning. | | `litefm`, `ftp`, `TS3Admin` | File managers and TeamSpeak controllers. | Depend on homes and remote server credentials set during provisioning. | | `news`, `circular`, `faq` | Content modules for panel UI. | Use standard MVC wrappers, share session/auth. | -| `cron` | Scheduler UI feeding `scripts/` commands. | Maintains job metadata that OS cron reads. | +| `cron` | Scheduler UI feeding `scripts/` commands. | Maintains job metadata that OS cron reads, including scheduler-triggered Server Content actions via `ogp_api.php?server_content/run_scheduled_action` and `modules/addonsmanager/server_content_actions.php`. | ## Storefront (`Panel/modules/billing` runtime + `Website/` compatibility wrappers) diff --git a/Panel/CHANGELOG.md b/Panel/CHANGELOG.md index 921ec2a3..5a3c80b0 100644 --- a/Panel/CHANGELOG.md +++ b/Panel/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 2026-05-18 +- **Cron ↔ Server Content action hook integration:** Added scheduler-callable Server Content hooks in `modules/addonsmanager/server_content_actions.php`, exposed API route `server_content/run_scheduled_action`, and wired cron/user-cron action builders/parsing to support server content scheduled actions (check/install/queue/restart/validate/backup flows) without embedding game-specific install logic in the scheduler. - **Server Content Workshop Phase 1 in addonsmanager:** Added a new `Workshop Content` flow under Server Content with per-home Workshop ID storage, ID validation/deduplication, install/update/remove/update-all actions, manifest-based script handoff (`gsp_server_content/workshop_manifest.json`), safe placeholder workshop scripts for Linux/Cygwin, and schema support via `server_content_workshop` plus `addons.addon_type VARCHAR(32)`. - **Updater layout hardening + pre-update patch framework:** Reworked `modules/administration/panel_update.php` to resolve explicit GSP root/Panel/Website paths, run mandatory preflight checks, self-update updater files before main sync when drift is detected, and apply ordered required patches from `modules/update/patches/` with DB/local state tracking. Backup/rollback now includes both Panel + Website archives and root `version.json`, logs moved to root `logs/update_trace.log`, and the admin Update UI now exposes preflight, patch apply, Apache path scan/fix, backup, update, and rollback actions. - **Billing runtime relocation + portable path bootstrap:** Re-homed storefront runtime to `Panel/modules/billing`, added portable runtime helpers (`billing_bootstrap.php`, `site_config.php`, `site_config.example.php`) with env/local override support for base path and panel path, normalized critical storefront redirects/links to computed billing URLs, and added `Website` compatibility wrappers for key billing entrypoints. diff --git a/Panel/docs/COPILOT_TODO.md b/Panel/docs/COPILOT_TODO.md index 038459e7..fdb3355a 100644 --- a/Panel/docs/COPILOT_TODO.md +++ b/Panel/docs/COPILOT_TODO.md @@ -18,3 +18,4 @@ - Add an automated deployment check that fails when `Website/timestamp.txt` and `modules/billing/timestamp.txt` diverge after storefront/content changes. - Add an admin preview/diff panel for Apache path repairs so staff can review exact vhost line changes before confirming `Fix Apache Paths`. - Add Phase 2 Workshop Content UX in `addonsmanager`: browse/search/select Workshop items with metadata while reusing the Phase 1 per-home saved-ID action pipeline. +- Add localized language strings/tooltips for the new cron scheduler `server_content_*` action labels across all supported panel locales. diff --git a/Panel/includes/api_functions.php b/Panel/includes/api_functions.php index 43ad0c44..0e503d8d 100644 --- a/Panel/includes/api_functions.php +++ b/Panel/includes/api_functions.php @@ -55,6 +55,9 @@ function get_function_args($main_request) //______________ Steam Workshop $functions["steam_workshop/install"] = array("token" => true, "ip" => true, "port" => true, "mod_key" => false, "mods_list" => true); + + //______________ Server Content + $functions["server_content/run_scheduled_action"] = array("token" => true, "home_id" => true, "action" => true, "options" => false); //______________ Settings $functions["setting/get"] = array("token" => true, "setting_name" => true); diff --git a/Panel/modules/addonsmanager/server_content_actions.php b/Panel/modules/addonsmanager/server_content_actions.php new file mode 100644 index 00000000..6e131f28 --- /dev/null +++ b/Panel/modules/addonsmanager/server_content_actions.php @@ -0,0 +1,472 @@ +getGameHome($home_id); +} + +function server_content_collect_workshop_ids($db, $home_id) +{ + $rows = $db->resultQuery( + "SELECT workshop_item_id FROM `".OGP_DB_PREFIX."server_content_workshop` + WHERE home_id=".(int)$home_id." AND install_state<>'removed'" + ); + $item_ids = array(); + if (is_array($rows)) { + foreach ((array)$rows as $row) { + $item_id = trim((string)$row['workshop_item_id']); + if ($item_id !== '' && preg_match('/^[0-9]+$/', $item_id)) { + $item_ids[$item_id] = $item_id; + } + } + } + return array_values($item_ids); +} + +function server_content_collect_manifest_addon_rows($db, $home_id) +{ + $rows = $db->resultQuery( + "SELECT m.addon_id, m.install_method, m.content_version, m.install_state, a.name, a.url + FROM `".OGP_DB_PREFIX."server_content_manifest` m + LEFT JOIN `".OGP_DB_PREFIX."addons` a ON a.addon_id = m.addon_id + WHERE m.home_id=".(int)$home_id." + ORDER BY m.updated_at DESC, m.installed_at DESC" + ); + return is_array($rows) ? $rows : array(); +} + +function server_content_create_remote(array $home_info) +{ + return new OGPRemoteLibrary( + $home_info['agent_ip'], + $home_info['agent_port'], + $home_info['encryption_key'], + $home_info['timeout'] + ); +} + +function server_content_result($status, $message, array $details = array()) +{ + return array( + 'status' => (string)$status, + 'success' => ((string)$status === 'success' || (string)$status === 'no_updates' || (string)$status === 'restart_required'), + 'message' => (string)$message, + 'details' => $details, + ); +} + +function server_content_record_installed_content_state(array $home_info, array $state) +{ + $home_path = rtrim(clean_path((string)$home_info['home_path']), '/'); + $file_path = clean_path($home_path . '/gsp_server_content/installed_content.json'); + if (!scm_path_is_under_home($home_path, $file_path)) { + return false; + } + $json = json_encode($state); + if ($json === false) { + return false; + } + $remote = server_content_create_remote($home_info); + $remote->exec("mkdir -p " . escapeshellarg(dirname($file_path))); + return ((int)$remote->remote_writefile($file_path, $json) === 1); +} + +function server_content_log_action($home_id, $action, $status, $message = '', $details = array()) +{ + global $db; + $payload = array( + 'home_id' => (int)$home_id, + 'action' => (string)$action, + 'status' => (string)$status, + 'message' => (string)$message, + 'details' => $details, + ); + $db->logger("server_content_action " . json_encode($payload)); + return true; +} + +function server_content_build_manifest($home_id, $content_type, $action, $items = array(), $options = array()) +{ + $home_info = server_content_get_home_info($home_id); + if ($home_info === false) { + return false; + } + $home_path = rtrim(clean_path((string)$home_info['home_path']), '/'); + $manifest_dir = clean_path($home_path . '/gsp_server_content/manifests'); + $file_stub = preg_replace('/[^a-z0-9_\-]+/i', '_', (string)$content_type . '_' . (string)$action); + if ($file_stub === '' || $file_stub === null) { + $file_stub = 'manifest'; + } + $manifest_path = clean_path($manifest_dir . '/' . $file_stub . '_' . date('Ymd_His') . '_' . mt_rand(1000, 9999) . '.json'); + if (!scm_path_is_under_home($home_path, $manifest_path)) { + return false; + } + $manifest = array( + 'manifest_version' => 1, + 'home_id' => (int)$home_info['home_id'], + 'home_cfg_id' => (int)$home_info['home_cfg_id'], + 'remote_server_id' => (int)$home_info['remote_server_id'], + 'content_type' => (string)$content_type, + 'action' => (string)$action, + 'items' => is_array($items) ? array_values($items) : array(), + 'options' => is_array($options) ? $options : array(), + 'generated_at' => date('Y-m-d H:i:s'), + ); + $manifest_json = json_encode($manifest); + if ($manifest_json === false) { + return false; + } + $remote = server_content_create_remote($home_info); + $remote->exec("mkdir -p " . escapeshellarg($manifest_dir)); + if ((int)$remote->remote_writefile($manifest_path, $manifest_json) !== 1) { + return false; + } + return $manifest_path; +} + +function server_content_resolve_script_path(array $home_info, $script_key, array $options = array()) +{ + $script_path = ''; + if (isset($options['script_path'])) { + $script_path = trim((string)$options['script_path']); + } + $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); + if ($server_xml === false) { + return array(false, false); + } + if ($script_path === '' && $script_key === 'workshop') { + $script_path = scm_get_workshop_script_path($home_info, $server_xml); + } + if ($script_path === '' && $script_key !== '' && isset($server_xml->$script_key)) { + $script_path = trim((string)$server_xml->$script_key); + } + return array($server_xml, $script_path); +} + +function server_content_execute_manifest($home_id, $manifest_path, $script_key, $options = array()) +{ + $home_info = server_content_get_home_info($home_id); + if ($home_info === false) { + return server_content_result('failed', 'Invalid server home.', array('home_id' => (int)$home_id)); + } + list($server_xml, $script_path) = server_content_resolve_script_path($home_info, $script_key, $options); + if ($server_xml === false) { + return server_content_result('failed', 'Unable to load server XML configuration.', array('home_id' => (int)$home_id)); + } + $script_path = trim((string)$script_path); + if ($script_path === '' || !preg_match('/^[^\r\n\0]+$/', $script_path)) { + return server_content_result('failed', 'Configured server content script path is invalid.', array('script_key' => (string)$script_key)); + } + $remote = server_content_create_remote($home_info); + if ($remote->status_chk() !== 1) { + return server_content_result('failed', 'Agent is offline.', array('remote_server_id' => (int)$home_info['remote_server_id'])); + } + if ((int)$remote->rfile_exists($script_path) !== 1) { + return server_content_result('failed', 'Server content script was not found on agent host.', array('script_path' => $script_path)); + } + $command = "bash " . escapeshellarg($script_path) . " " . escapeshellarg((string)$manifest_path) . " ; echo __GSP_SERVER_CONTENT_EXIT:$?"; + $output = $remote->exec($command); + if (!is_string($output) || $output === '') { + return server_content_result('failed', 'Server content script did not return output.', array('script_path' => $script_path)); + } + if (!preg_match('/__GSP_SERVER_CONTENT_EXIT:(\d+)/', $output, $matches)) { + return server_content_result('failed', 'Server content script exit marker was not found.', array('output' => trim($output))); + } + $exit_code = (int)$matches[1]; + if ($exit_code !== 0) { + return server_content_result('failed', 'Server content script failed.', array( + 'exit_code' => $exit_code, + 'output' => trim($output), + 'script_path' => $script_path, + )); + } + return server_content_result('success', 'Server content script executed successfully.', array( + 'exit_code' => $exit_code, + 'output' => trim($output), + 'script_path' => $script_path, + 'manifest_path' => (string)$manifest_path, + )); +} + +function server_content_check_updates($home_id, $options = array()) +{ + $options['check_only'] = true; + return server_content_install_updates($home_id, $options); +} + +function server_content_update_workshop($home_id, $options = array()) +{ + $options['workshop_only'] = true; + return server_content_install_updates($home_id, $options); +} + +function server_content_install_updates($home_id, $options = array()) +{ + global $db; + $home_info = server_content_get_home_info($home_id); + if ($home_info === false) { + return server_content_result('failed', 'Invalid server home.'); + } + scm_ensure_phase2_schema($db); + scm_ensure_workshop_schema($db); + + $workshop_action = isset($options['workshop_action']) ? (string)$options['workshop_action'] : ''; + if ($workshop_action === '') { + $workshop_action = !empty($options['check_only']) ? 'check_updates' : 'update'; + } + $workshop_ids = server_content_collect_workshop_ids($db, (int)$home_info['home_id']); + $manifest_rows = server_content_collect_manifest_addon_rows($db, (int)$home_info['home_id']); + if (empty($workshop_ids) && empty($manifest_rows)) { + $result = server_content_result('no_updates', 'No installed server content records were found for this home.', array( + 'home_id' => (int)$home_info['home_id'], + )); + server_content_record_installed_content_state($home_info, array( + 'home_id' => (int)$home_info['home_id'], + 'last_action' => 'no_updates', + 'last_updated_at' => date('Y-m-d H:i:s'), + )); + return $result; + } + + if (!empty($workshop_ids) && empty($options['check_only'])) { + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installing', null, false, false); + } + + $manifest_items = array( + 'workshop_item_ids' => $workshop_ids, + 'manifest_addons' => $manifest_rows, + ); + $manifest_path = server_content_build_manifest($home_info['home_id'], 'server_content', $workshop_action, $manifest_items, $options); + if ($manifest_path === false) { + if (!empty($workshop_ids) && empty($options['check_only'])) { + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'failed', 'Failed to build server content manifest.', false, false); + } + return server_content_result('failed', 'Failed to build server content manifest.'); + } + + $execute = server_content_execute_manifest($home_info['home_id'], $manifest_path, 'workshop', $options); + if (empty($execute['success'])) { + if (!empty($workshop_ids) && empty($options['check_only'])) { + $error_message = isset($execute['message']) ? $execute['message'] : 'Unknown failure.'; + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'failed', $error_message, false, false); + } + return $execute; + } + + if (!empty($workshop_ids) && empty($options['check_only'])) { + scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installed', null, false, true); + } + server_content_record_installed_content_state($home_info, array( + 'home_id' => (int)$home_info['home_id'], + 'last_action' => (string)$workshop_action, + 'last_result' => 'success', + 'last_manifest' => $manifest_path, + 'last_updated_at' => date('Y-m-d H:i:s'), + 'installed_workshop_ids' => $workshop_ids, + )); + + if (!empty($options['check_only'])) { + return server_content_result('success', 'Server content update check completed.', array( + 'manifest_path' => $manifest_path, + 'workshop_items' => count($workshop_ids), + )); + } + return server_content_result('success', 'Server content updates were installed.', array( + 'manifest_path' => $manifest_path, + 'workshop_items' => count($workshop_ids), + 'manifest_rows' => count($manifest_rows), + )); +} + +function server_content_home_is_running(array $home_info) +{ + $remote = server_content_create_remote($home_info); + return ($remote->is_screen_running(OGP_SCREEN_TYPE_HOME, $home_info['home_id']) == 1); +} + +function server_content_install_updates_if_stopped($home_id, $options = array()) +{ + $home_info = server_content_get_home_info($home_id); + if ($home_info === false) { + return server_content_result('failed', 'Invalid server home.'); + } + if (server_content_home_is_running($home_info)) { + return server_content_result('restart_required', 'Server is running; update skipped until server is stopped.', array( + 'home_id' => (int)$home_info['home_id'], + )); + } + return server_content_install_updates($home_id, $options); +} + +function server_content_install_updates_next_restart($home_id, $options = array()) +{ + $home_info = server_content_get_home_info($home_id); + if ($home_info === false) { + return server_content_result('failed', 'Invalid server home.'); + } + $options['queued_for_restart'] = true; + $manifest_path = server_content_build_manifest($home_info['home_id'], 'server_content', 'install_next_restart', array(), $options); + if ($manifest_path === false) { + return server_content_result('failed', 'Failed to queue update manifest for next restart.'); + } + server_content_record_installed_content_state($home_info, array( + 'home_id' => (int)$home_info['home_id'], + 'last_action' => 'install_next_restart', + 'last_result' => 'queued', + 'queued_manifest' => $manifest_path, + 'last_updated_at' => date('Y-m-d H:i:s'), + )); + return server_content_result('restart_required', 'Server content updates were queued for next restart.', array( + 'manifest_path' => $manifest_path, + )); +} + +function server_content_restart_home($home_id, $options = array()) +{ + global $db, $user_info; + $home_info = server_content_get_home_info($home_id); + if ($home_info === false) { + return server_content_result('failed', 'Invalid server home.'); + } + $server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']); + if ($server_xml === false) { + return server_content_result('failed', 'Could not load server XML for restart.'); + } + $remote = server_content_create_remote($home_info); + $host_stat = $remote->status_chk(); + if ($host_stat !== 1) { + return server_content_result('failed', 'Agent is offline; cannot restart server.'); + } + + $ip_ports = $db->getHomeIpPorts($home_info['home_id']); + if (!is_array($ip_ports) || !isset($ip_ports[0])) { + return server_content_result('failed', 'No IP/port mapping found for server restart.'); + } + $ip = $ip_ports[0]['ip']; + $port = (int)$ip_ports[0]['port']; + + $mod_id = key($home_info['mods']); + $start_cmd = get_start_cmd($user_info, $remote, $server_xml, $home_info, $mod_id, $ip, $port, $db); + $preStart = isset($server_xml->pre_start) ? trim((string)$server_xml->pre_start) : ""; + $envVars = isset($server_xml->environment_variables) ? trim((string)$server_xml->environment_variables) : ""; + + $delay = isset($options['restart_delay_seconds']) ? (int)$options['restart_delay_seconds'] : 0; + if ($delay > 0) { + if ($delay > 300) { + $delay = 300; + } + sleep($delay); + } + + $remote_retval = $remote->remote_restart_server( + $home_info['home_id'], + $ip, + $port, + $server_xml->control_protocol, + $home_info['control_password'], + $server_xml->control_protocol_type, + $home_info['home_path'], + $server_xml->server_exec_name, + $server_xml->exe_location, + $start_cmd, + $home_info['mods'][$mod_id]['cpu_affinity'], + $home_info['mods'][$mod_id]['nice'], + $preStart, + $envVars, + $server_xml->game_key, + (isset($server_xml->console_log) ? $server_xml->console_log : "") + ); + if ($remote_retval !== 1) { + return server_content_result('restart_required', 'Update completed but automatic restart failed.', array( + 'restart_status' => $remote_retval, + )); + } + return server_content_result('success', 'Update completed and server restart was triggered.', array( + 'restart_status' => $remote_retval, + )); +} + +function server_content_install_updates_and_restart($home_id, $options = array()) +{ + $install_result = server_content_install_updates($home_id, $options); + if (empty($install_result['success']) || $install_result['status'] === 'failed') { + return $install_result; + } + $restart_result = server_content_restart_home($home_id, $options); + $install_result['details']['restart'] = $restart_result; + if ($restart_result['status'] === 'success') { + $install_result['status'] = 'success'; + $install_result['message'] = 'Server content updates installed and server restart requested.'; + return $install_result; + } + $install_result['status'] = 'restart_required'; + $install_result['success'] = true; + $install_result['message'] = 'Server content updates installed; restart is still required.'; + return $install_result; +} + +function server_content_run_scheduled_action($home_id, $action, $options = array()) +{ + $home_id = (int)$home_id; + $action = trim((string)$action); + if ($home_id <= 0) { + return server_content_result('failed', 'Invalid server home id.'); + } + $handlers = array( + 'server_content_check_updates' => 'server_content_check_updates', + 'server_content_check_workshop_updates' => 'server_content_update_workshop', + 'server_content_install_updates_if_stopped' => 'server_content_install_updates_if_stopped', + 'server_content_install_updates_next_restart' => 'server_content_install_updates_next_restart', + 'server_content_install_updates_now' => 'server_content_install_updates', + 'server_content_install_updates_and_restart' => 'server_content_install_updates_and_restart', + 'server_content_update_workshop' => 'server_content_update_workshop', + 'server_content_update_all' => 'server_content_install_updates', + 'server_content_notify_updates_only' => 'server_content_check_updates', + 'server_content_validate_files' => 'server_content_update_workshop', + 'server_content_backup_before_update' => 'server_content_install_updates', + ); + if (!isset($handlers[$action]) || !function_exists($handlers[$action])) { + $result = server_content_result('failed', 'Unsupported scheduled server content action.', array( + 'action' => $action, + )); + server_content_log_action($home_id, $action, $result['status'], $result['message'], $result['details']); + return $result; + } + + if ($action === 'server_content_check_workshop_updates' || $action === 'server_content_validate_files') { + $options['check_only'] = true; + $options['workshop_action'] = ($action === 'server_content_validate_files') ? 'validate_files' : 'check_updates'; + } + if ($action === 'server_content_backup_before_update') { + $options['backup_before_update'] = true; + } + if ($action === 'server_content_install_updates_and_restart' && !isset($options['safe_restart'])) { + $options['safe_restart'] = true; + } + if ($action === 'server_content_notify_updates_only') { + $options['notify_only'] = true; + $options['check_only'] = true; + } + + server_content_log_action($home_id, $action, 'started', 'Scheduled action started.', $options); + $handler = $handlers[$action]; + $result = $handler($home_id, $options); + server_content_log_action($home_id, $action, $result['status'], $result['message'], $result['details']); + return $result; +} + diff --git a/Panel/modules/cron/cron.php b/Panel/modules/cron/cron.php index b75c1d53..4465cbba 100644 --- a/Panel/modules/cron/cron.php +++ b/Panel/modules/cron/cron.php @@ -77,19 +77,12 @@ function exec_ogp_module() $mod_key = $game_home['mod_key']; $token = $db->getApiToken($_SESSION['user_id']); - switch ($_POST['action']) { - case "stop": - $command = "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/stop&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}\" --no-check-certificate > /dev/null 2>&1"; - break; - case "start": - $command = "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/start&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}\" --no-check-certificate > /dev/null 2>&1"; - break; - case "restart": - $command = "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/restart&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}\" --no-check-certificate > /dev/null 2>&1"; - break; - case "steam_auto_update": - $command = "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/update&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}&type=steam\" --no-check-certificate > /dev/null 2>&1"; - break; + $command = build_cron_scheduler_command($panelURL, $token, $game_home, $_POST['action']); + if($command === false) + { + print_failure(get_lang('bad_inputs')); + $view->refresh('?m=cron&p=cron',2); + return; } $remote = new OGPRemoteLibrary( $game_home['agent_ip'], $game_home['agent_port'], diff --git a/Panel/modules/cron/shared_cron_functions.php b/Panel/modules/cron/shared_cron_functions.php index e41575fa..45f7d543 100644 --- a/Panel/modules/cron/shared_cron_functions.php +++ b/Panel/modules/cron/shared_cron_functions.php @@ -47,10 +47,17 @@ function reloadJobs($server_homes, $remote_servers, $getAllJobs = true) list($wget,$wget_args,$url,$wget_nocert,$gt,$devnull,$err2out) = explode(" ", $command, 7); parse_str(parse_url(trim($url,'"'), PHP_URL_QUERY), $url_query); - - if(!isset($url_query['ip']) or !isset($url_query['port'])) + $home_info = false; + if(isset($url_query['ip']) && isset($url_query['port'])) + { + $home_info = $db->getGameHomeByIP($url_query['ip'], $url_query['port']); + } + elseif(isset($url_query['home_id'])) + { + $home_info = $db->getGameHome((int)$url_query['home_id'], true); + } + if(!$home_info) continue; - $home_info = $db->getGameHomeByIP($url_query['ip'], $url_query['port']); if(!$getAllJobs && !hasAccess($home_info)) continue; @@ -63,6 +70,8 @@ function reloadJobs($server_homes, $remote_servers, $getAllJobs = true) $action = "start"; }else if($action == "gamemanager/restart"){ $action = "restart"; + }else if($action == "server_content/run_scheduled_action" && isset($url_query['action']) && $url_query['action'] != ""){ + $action = $url_query['action']; } $jobsArray[$rhost_id][$jobId] = array( 'job' => $job, @@ -76,7 +85,7 @@ function reloadJobs($server_homes, $remote_servers, $getAllJobs = true) 'home_id' => $home_info['home_id'], 'ip' => $home_info['ip'], 'port' => $home_info['port'], - 'mod_key' => $url_query['mod_key']); + 'mod_key' => isset($url_query['mod_key']) ? $url_query['mod_key'] : ''); } else { @@ -99,6 +108,50 @@ function reloadJobs($server_homes, $remote_servers, $getAllJobs = true) return array($jobsArray, $remote_servers_offline); } +function get_server_content_scheduled_actions() { + return array( + 'server_content_check_updates', + 'server_content_check_workshop_updates', + 'server_content_install_updates_if_stopped', + 'server_content_install_updates_next_restart', + 'server_content_install_updates_now', + 'server_content_install_updates_and_restart', + 'server_content_notify_updates_only', + 'server_content_update_all', + 'server_content_validate_files', + 'server_content_backup_before_update', + ); +} + +function build_cron_scheduler_command($panelURL, $token, $game_home, $action) { + $ip = $game_home['ip']; + $port = $game_home['port']; + $mod_key = isset($game_home['mod_key']) ? $game_home['mod_key'] : ''; + $home_id = isset($game_home['home_id']) ? (int)$game_home['home_id'] : 0; + if(in_array($action, get_server_content_scheduled_actions())) + { + $options = array('triggered_by' => 'scheduler'); + if($action == 'server_content_install_updates_and_restart') + { + $options['safe_restart'] = true; + $options['restart_delay_seconds'] = 60; + } + $options_json = urlencode(json_encode($options)); + return "wget -qO- \"${panelURL}/ogp_api.php?server_content/run_scheduled_action&token=${token}&home_id=${home_id}&action=${action}&options=${options_json}\" --no-check-certificate > /dev/null 2>&1"; + } + switch ($action) { + case "stop": + return "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/stop&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}\" --no-check-certificate > /dev/null 2>&1"; + case "start": + return "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/start&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}\" --no-check-certificate > /dev/null 2>&1"; + case "restart": + return "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/restart&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}\" --no-check-certificate > /dev/null 2>&1"; + case "steam_auto_update": + return "wget -qO- \"${panelURL}/ogp_api.php?gamemanager/update&token=${token}&ip=${ip}&port=${port}&mod_key=${mod_key}&type=steam\" --no-check-certificate > /dev/null 2>&1"; + } + return false; +} + function updateCronJobTokens($old_token, $token){ global $db; $remote_servers = $db->getRemoteServers(); @@ -152,6 +205,11 @@ function get_action_selector($action = false, $server_homes = false, $homeid_ip_ if( $server_xml->installer == "steamcmd" ) $server_actions[] = 'steam_auto_update'; } + global $db; + if($db->isModuleInstalled('addonsmanager')) + { + $server_actions = array_merge($server_actions, get_server_content_scheduled_actions()); + } $select_action = '