From c471c4b3f8349801531e0d82b1055e9afce6289f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 22:36:34 +0000 Subject: [PATCH 1/3] Implement workshop install normalization Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/b08aa54e-755c-4869-ba97-0f817a05b3c6 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- Panel/js/modules/addonsmanager.js | 72 +++- Panel/lang/English/modules/addonsmanager.php | 14 +- .../addonsmanager/addons_installer.php | 145 ++++---- .../modules/addonsmanager/addons_manager.php | 5 +- .../workshop/generic_steam_workshop_linux.sh | 233 ++++++++++--- .../generic_steam_workshop_windows_cygwin.sh | 229 ++++++++++--- .../addonsmanager/server_content_actions.php | 11 +- .../server_content_categories.php | 4 +- .../addonsmanager/server_content_helpers.php | 310 +++++++++++++++--- .../modules/addonsmanager/workshop_action.php | 15 +- Panel/ogp_api.php | 25 +- 11 files changed, 827 insertions(+), 236 deletions(-) mode change 100644 => 100755 Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh mode change 100644 => 100755 Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh diff --git a/Panel/js/modules/addonsmanager.js b/Panel/js/modules/addonsmanager.js index b2135530..b8ccc7ab 100644 --- a/Panel/js/modules/addonsmanager.js +++ b/Panel/js/modules/addonsmanager.js @@ -1,7 +1,15 @@ $(function() { + function replaceTemplate(template, values) { + var output = String(template || ''); + $.each(values, function(key, value) { + output = output.split(key).join(value); + }); + return output; + } + var methodToRows = { download_zip: ['#scm-row-url', '#scm-row-path'], - steam_workshop: ['#scm-row-workshop-id', '#scm-row-workshop-app-id', '#scm-row-target-path-template', '#scm-row-optional-folder-name'], + steam_workshop: ['#scm-row-workshop-id', '#scm-row-workshop-app-id', '#scm-row-target-path-template', '#scm-row-optional-folder-name', '#scm-row-post-script', '#scm-row-launch-param-additions'], post_script: ['#scm-row-post-script'], config_edit: ['#scm-row-path', '#scm-row-config-edit-rule'] }; @@ -37,4 +45,66 @@ $(function() { $method.on('change', applyContentTypeUi); applyContentTypeUi(); + + var $userSelect = $('#scm-user-addon-select'); + var $userWorkshopRows = $('.scm-user-workshop-row'); + var $userWorkshopId = $('#scm-user-workshop-id'); + var $userWorkshopAppId = $('#scm-user-workshop-app-id'); + var $userTargetTemplate = $('#scm-user-target-path-template'); + var $userOptionalFolderName = $('#scm-user-optional-folder-name'); + var $userPreview = $('#scm-user-target-path-preview'); + + function updateUserWorkshopUi() { + if ($userSelect.length === 0) return; + var $selected = $userSelect.find('option:selected'); + var installMethod = String($selected.data('installMethod') || ''); + var isWorkshop = installMethod === 'steam_workshop'; + $userWorkshopRows.toggle(isWorkshop); + if (!isWorkshop) { + return; + } + + if (!$userWorkshopId.val()) { + $userWorkshopId.val(String($selected.data('workshopItemId') || '')); + } + if (!$userWorkshopAppId.val()) { + $userWorkshopAppId.val(String($selected.data('workshopAppId') || $userWorkshopAppId.data('defaultAppId') || '')); + } + if (!$userTargetTemplate.val()) { + $userTargetTemplate.val(String($selected.data('targetPathTemplate') || $userTargetTemplate.data('defaultTemplate') || '')); + } + if (!$userOptionalFolderName.val()) { + $userOptionalFolderName.val(String($selected.data('optionalFolderName') || '')); + } + + var workshopId = $.trim($userWorkshopId.val()); + var workshopAppId = $.trim($userWorkshopAppId.val()) || String($selected.data('workshopAppId') || $userWorkshopAppId.data('defaultAppId') || ''); + var folderName = $.trim($userOptionalFolderName.val()) || (workshopId ? '@' + workshopId : '@{WORKSHOP_ID}'); + var targetTemplate = $.trim($userTargetTemplate.val()) || String($selected.data('targetPathTemplate') || $userTargetTemplate.data('defaultTemplate') || ''); + var previewValues = { + '{SERVER_ROOT}': String($userPreview.data('serverRoot') || ''), + '{GAME_ROOT}': String($userPreview.data('gameRoot') || ''), + '{WORKSHOP_ID}': workshopId || '{WORKSHOP_ID}', + '{WORKSHOP_APP_ID}': workshopAppId || '{WORKSHOP_APP_ID}', + '{STEAM_APP_ID}': String($userPreview.data('steamAppId') || '{STEAM_APP_ID}'), + '{FOLDER_NAME}': folderName, + '{MOD_FOLDER}': folderName + }; + $userPreview.text(replaceTemplate(targetTemplate, previewValues)); + } + + if ($userSelect.length) { + $userSelect.on('change', function() { + $userWorkshopId.val(String($(this).find('option:selected').data('workshopItemId') || '')); + $userWorkshopAppId.val(String($(this).find('option:selected').data('workshopAppId') || '')); + $userTargetTemplate.val(String($(this).find('option:selected').data('targetPathTemplate') || '')); + $userOptionalFolderName.val(String($(this).find('option:selected').data('optionalFolderName') || '')); + updateUserWorkshopUi(); + }); + $userWorkshopId.on('input', updateUserWorkshopUi); + $userWorkshopAppId.on('input', updateUserWorkshopUi); + $userTargetTemplate.on('input', updateUserWorkshopUi); + $userOptionalFolderName.on('input', updateUserWorkshopUi); + updateUserWorkshopUi(); + } }); diff --git a/Panel/lang/English/modules/addonsmanager.php b/Panel/lang/English/modules/addonsmanager.php index 22e50cce..d41e110d 100644 --- a/Panel/lang/English/modules/addonsmanager.php +++ b/Panel/lang/English/modules/addonsmanager.php @@ -42,9 +42,9 @@ define('LANG_wait_while_decompressing', "Wait while the file %s is decompressed. define('LANG_addon_name', "Content Item Name"); define('LANG_url', "URL"); define('LANG_select_game_type', "Select Game Type"); -define('LANG_plugin', "Plugins / Mods"); -define('LANG_mappack', "Map Packs"); -define('LANG_config', "Config Packs"); +define('LANG_plugin', "Downloadable Mod"); +define('LANG_mappack', "Steam Workshop Item"); +define('LANG_config', "Configuration Package"); if (!defined('LANG_version')) { define('LANG_version', "Version"); } @@ -93,8 +93,8 @@ define('LANG_target_path_template', "Target Path"); define('LANG_optional_folder_name', "Optional Folder Name"); define('LANG_config_edit_rule', "Config Edit Rule"); define('LANG_launch_param_additions', "Launch Parameter Additions"); -define('LANG_content_type_help_download_zip', "Downloads an archive/file from URL; extract path is optional."); -define('LANG_content_type_help_steam_workshop', "Installs/updates a Workshop item with Workshop ID (no URL required)."); -define('LANG_content_type_help_post_script', "Runs a scripted installer action (no URL required)."); -define('LANG_content_type_help_config_edit', "Edits config at target path using provided action/rules (no URL required)."); +define('LANG_content_type_help_download_zip', "Download and extract a ZIP, RAR, or archive file."); +define('LANG_content_type_help_steam_workshop', "Install a Steam Workshop mod using Workshop ID."); +define('LANG_content_type_help_post_script', "Run a custom scripted installation process."); +define('LANG_content_type_help_config_edit', "Install configuration files, profiles, or templates."); ?> diff --git a/Panel/modules/addonsmanager/addons_installer.php b/Panel/modules/addonsmanager/addons_installer.php index b602f5b7..f22fbe01 100644 --- a/Panel/modules/addonsmanager/addons_installer.php +++ b/Panel/modules/addonsmanager/addons_installer.php @@ -1,3 +1,4 @@ + isset($addon_info['url']) ? $addon_info['url'] : '', - 'path' => isset($addon_info['path']) ? $addon_info['path'] : '', - 'workshop_item_id' => isset($addon_info['workshop_item_id']) ? $addon_info['workshop_item_id'] : '', - 'target_path_template' => isset($addon_info['target_path_template']) ? $addon_info['target_path_template'] : '', - 'post_script' => isset($addon_info['post_script']) ? $addon_info['post_script'] : '', - 'config_edit_rule' => isset($addon_info['config_edit_rule']) ? $addon_info['config_edit_rule'] : '', - ); $validation_message = ''; - if ($state == "start" && !scm_validate_install_method_payload($install_method, $validation_payload, $validation_message)) { + if ($state == "start" && !scm_validate_install_method_payload($install_method, $install_payload, $validation_message)) { print_failure($validation_message); return; } @@ -209,7 +206,7 @@ function exec_ogp_module() { return; } } - $url = $addon_info['url']; + $url = $install_payload['url']; $filename = basename($url); #### Replace template variables in the post-install script with #### live server data before sending to the agent. @@ -285,52 +282,38 @@ function exec_ogp_module() { 'content_type' => $install_method, 'home_id' => (int)$home_id, 'home_cfg_id' => (int)$home_info['home_cfg_id'], - 'workshop_id' => isset($addon_info['workshop_item_id']) ? (string)$addon_info['workshop_item_id'] : '', - 'target_path' => isset($addon_info['target_path_template']) ? (string)$addon_info['target_path_template'] : (string)$addon_info['path'], + 'workshop_id' => isset($install_payload['workshop_item_id']) ? (string)$install_payload['workshop_item_id'] : '', + 'target_path' => ($install_method === 'steam_workshop') + ? (string)$install_payload['target_path_template'] + : (string)$install_payload['path'], 'action' => 'started', )); if ($install_method === 'steam_workshop') { scm_ensure_workshop_schema($db); - $workshop_item_id = trim((string)$addon_info['workshop_item_id']); - $target_path_template = trim((string)$addon_info['target_path_template']); - $resolved = function_exists('steam_workshop_install_item_to_home') - ? steam_workshop_install_item_to_home($db, $home_info, $workshop_item_id, $target_path_template, array( - 'optional_folder_name' => trim((string)$addon_info['optional_folder_name']), - 'workshop_app_id' => trim((string)$addon_info['workshop_app_id']), - )) - : array('ok' => false); - if (empty($resolved['ok'])) { - $fallback_profile = function_exists('sw_get_profile_for_home') ? sw_get_profile_for_home($db, (int)$home_id) : false; - $fallback_workshop_app_id = trim((string)$addon_info['workshop_app_id']); - if ($fallback_workshop_app_id === '' && is_array($fallback_profile) && !empty($fallback_profile['workshop_app_id'])) { - $fallback_workshop_app_id = (string)$fallback_profile['workshop_app_id']; - } - if ($fallback_workshop_app_id === '') { - $fallback_workshop_app_id = scm_extract_workshop_app_id($server_xml); - } - $placeholder_map = scm_build_placeholder_map($home_info, array('exe_location' => (string)$server_xml->exe_location), array( - 'WORKSHOP_ID' => $workshop_item_id, - 'WORKSHOP_APP_ID' => $fallback_workshop_app_id, - 'STEAM_APP_ID' => (is_array($fallback_profile) && !empty($fallback_profile['steam_app_id'])) ? (string)$fallback_profile['steam_app_id'] : '', - )); - $resolved = array( - 'workshop_app_id' => $fallback_workshop_app_id, - 'steam_app_id' => (is_array($fallback_profile) && !empty($fallback_profile['steam_app_id'])) ? (string)$fallback_profile['steam_app_id'] : '', - 'target_path_resolved' => scm_apply_placeholders($target_path_template, $placeholder_map), - ); + $workshop_runtime = scm_build_workshop_runtime_context($db, $home_info, $server_xml, $install_payload, $validation_message); + if ($workshop_runtime === false) { + print_failure($validation_message); + echo "

".get_lang('back')."

"; + return; } - $workshop_app_id = isset($resolved['workshop_app_id']) ? (string)$resolved['workshop_app_id'] : ''; - $steam_app_id = isset($resolved['steam_app_id']) ? (string)$resolved['steam_app_id'] : ''; - $target_path_resolved = isset($resolved['target_path_resolved']) ? (string)$resolved['target_path_resolved'] : ''; + $workshop_item_id = (string)$workshop_runtime['workshop_item_id']; + $target_path_template = (string)$workshop_runtime['target_path_template']; + $workshop_app_id = (string)$workshop_runtime['workshop_app_id']; + $steam_app_id = (string)$workshop_runtime['steam_app_id']; + $target_path_resolved = (string)$workshop_runtime['target_path_resolved']; $extra_manifest = array( 'addon_id' => (int)$addon_id, 'target_path_template' => $target_path_template, 'target_path_resolved' => $target_path_resolved, - 'optional_folder_name' => trim((string)$addon_info['optional_folder_name']), + 'optional_folder_name' => trim((string)$install_payload['optional_folder_name']), 'config_edit_rule' => trim((string)$addon_info['config_edit_rule']), 'launch_param_additions' => trim((string)$addon_info['launch_param_additions']), 'workshop_app_id' => $workshop_app_id, 'steam_app_id' => $steam_app_id, + 'steamcmd_path' => isset($workshop_runtime['steamcmd_path']) ? (string)$workshop_runtime['steamcmd_path'] : '', + 'workshop_download_dir' => isset($workshop_runtime['workshop_download_dir']) ? (string)$workshop_runtime['workshop_download_dir'] : '', + 'server_root' => isset($workshop_runtime['server_root']) ? (string)$workshop_runtime['server_root'] : rtrim((string)$home_info['home_path'], '/'), + 'post_install_script' => trim((string)$post_script), ); $workshop_error = ''; $workshop_ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', array($workshop_item_id), $workshop_error, $extra_manifest); @@ -350,7 +333,10 @@ function exec_ogp_module() { 'home_id' => (int)$home_id, 'home_cfg_id' => (int)$home_info['home_cfg_id'], 'workshop_id' => $workshop_item_id, + 'steam_app_id' => $steam_app_id, + 'workshop_app_id' => $workshop_app_id, 'target_path' => $target_path_resolved, + 'final_folder_path' => $target_path_resolved, 'action' => 'succeeded', )); print_success(get_lang('addon_installed_successfully')); @@ -363,6 +349,8 @@ function exec_ogp_module() { 'home_id' => (int)$home_id, 'home_cfg_id' => (int)$home_info['home_cfg_id'], 'workshop_id' => $workshop_item_id, + 'steam_app_id' => $steam_app_id, + 'workshop_app_id' => $workshop_app_id, 'target_path' => $target_path_resolved, 'action' => 'failed', 'error' => $workshop_error, @@ -558,10 +546,28 @@ function exec_ogp_module() { } ?> resultQuery( + "SELECT addon_id, name, install_method, workshop_item_id, workshop_app_id, target_path_template, optional_folder_name + FROM OGP_DB_PREFIXaddons + WHERE addon_type='".$addon_type."' AND home_cfg_id=" . $home_cfg_id . $query_groups . " + ORDER BY name ASC" + ); + if (!is_array($addons)) { + $addons = []; + } + $selected_addon = isset($addons[0]) ? $addons[0] : array(); + $default_install_method = isset($selected_addon['install_method']) ? scm_get_install_method_default($selected_addon['install_method']) : ''; + $is_workshop_default = ($default_install_method === 'steam_workshop'); + $workshop_profile = function_exists('sw_get_profile_for_home') ? sw_get_profile_for_home($db, (int)$home_id) : false; + $default_workshop_app_id = !empty($selected_addon['workshop_app_id']) + ? trim((string)$selected_addon['workshop_app_id']) + : ((is_array($workshop_profile) && !empty($workshop_profile['workshop_app_id'])) ? (string)$workshop_profile['workshop_app_id'] : scm_extract_workshop_app_id($server_xml)); + $default_target_template = !empty($selected_addon['target_path_template']) + ? trim((string)$selected_addon['target_path_template']) + : ((is_array($workshop_profile) && !empty($workshop_profile['install_path_template'])) ? (string)$workshop_profile['install_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}'); + $default_optional_folder_name = !empty($selected_addon['optional_folder_name']) ? trim((string)$selected_addon['optional_folder_name']) : ''; ?>

@@ -579,21 +585,40 @@ function exec_ogp_module() { + > + > + > + > + > @@ -217,8 +218,8 @@ function exec_ogp_module() { diff --git a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh old mode 100644 new mode 100755 index ab6b5271..e63912e5 --- a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh @@ -7,57 +7,190 @@ if [[ -z "$MANIFEST_PATH" ]]; then exit 1 fi -if [[ ! -f "$MANIFEST_PATH" ]]; then - echo "Manifest not found: $MANIFEST_PATH" - exit 1 -fi +python3 - "$MANIFEST_PATH" <<'PY' +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime -MANIFEST_DIR="$(dirname "$MANIFEST_PATH")" -WORKSHOP_DIR="${MANIFEST_DIR}/workshop" -REMOVED_DIR="${WORKSHOP_DIR}/removed" -LOG_FILE="${MANIFEST_DIR}/workshop_phase1.log" +manifest_path = os.path.abspath(sys.argv[1]) +if not os.path.isfile(manifest_path): + print(f"Manifest not found: {manifest_path}") + sys.exit(1) -mkdir -p "$WORKSHOP_DIR" "$REMOVED_DIR" +manifest_dir = os.path.dirname(manifest_path) +home_root = os.path.dirname(manifest_dir) +log_file = os.path.join(manifest_dir, 'workshop_install.log') +removed_dir = os.path.join(manifest_dir, 'workshop', 'removed') +os.makedirs(removed_dir, exist_ok=True) -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","")) + +def log(message, status=None): + line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]" + if status: + line += f" [{status}]" + line += f" {message}" + print(line) + with open(log_file, 'a', encoding='utf-8') as handle: + handle.write(line + "\n") + + +def fail(message): + log(message, 'Failed') + raise RuntimeError(message) + + +def uniq_numeric_items(raw_items): + seen = [] + for value in raw_items: + text = str(value).strip() + if text.isdigit() and text not in seen: + seen.append(text) + return seen + + +def render_template(template, values): + rendered = str(template or '') + for key, value in values.items(): + rendered = rendered.replace('{' + key + '}', str(value)) + return rendered + + +def ensure_under_home(path_value): + target = os.path.abspath(path_value) + try: + common = os.path.commonpath([home_root, target]) + except ValueError: + common = '' + if common != os.path.abspath(home_root): + fail(f"Refusing to write outside server home: {target}") + return target + + +def resolve_steamcmd(explicit_path=''): + candidates = [] + explicit_path = str(explicit_path or '').strip() + if explicit_path: + candidates.append(explicit_path) + env_value = os.environ.get('STEAMCMD_PATH', '').strip() + if env_value: + candidates.append(env_value) + for path_value in ( + '/home/gameserver/steamcmd/steamcmd.sh', + shutil.which('steamcmd'), + shutil.which('steamcmd.exe'), + ): + if path_value: + candidates.append(path_value) + for candidate in candidates: + if candidate and os.path.isfile(candidate): + return candidate + fail('SteamCMD is missing on the agent host.') + + +def sync_copy(src, dst): + if not os.path.isdir(src): + fail(f"Workshop download source was not found: {src}") + os.makedirs(dst, exist_ok=True) + for entry in os.listdir(src): + source_entry = os.path.join(src, entry) + target_entry = os.path.join(dst, entry) + if os.path.isdir(source_entry): + sync_copy(source_entry, target_entry) + else: + os.makedirs(os.path.dirname(target_entry), exist_ok=True) + shutil.copy2(source_entry, target_entry) + + +try: + with open(manifest_path, 'r', encoding='utf-8') as handle: + manifest = json.load(handle) + + extra = manifest.get('extra') or {} + action = str(manifest.get('action', '')).strip() + raw_items = manifest.get('items', []) + if isinstance(raw_items, dict): + raw_items = raw_items.get('workshop_item_ids', []) + items = uniq_numeric_items(raw_items) + if not items: + fail('No Workshop IDs were found in the manifest.') + + workshop_app_id = str(extra.get('workshop_app_id') or manifest.get('workshop_app_id') or '').strip() + steam_app_id = str(extra.get('steam_app_id') or manifest.get('steam_app_id') or '').strip() + server_root = ensure_under_home(extra.get('server_root') or home_root) + steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '') + post_install_script = str(extra.get('post_install_script') or '').strip() + default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id) + + action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action + log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label) + + for workshop_id in items: + folder_name = str(extra.get('optional_folder_name') or '').strip() or ('@' + workshop_id) + template_values = { + 'HOME_ID': manifest.get('home_id', ''), + 'SERVER_ROOT': server_root, + 'GAME_ROOT': server_root, + 'WORKSHOP_ID': workshop_id, + 'WORKSHOP_APP_ID': workshop_app_id, + 'STEAM_APP_ID': steam_app_id, + 'FOLDER_NAME': folder_name, + 'MOD_FOLDER': folder_name, + } + target_template = str(extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}') + target_path = str(extra.get('target_path_resolved') or '').strip() + if len(items) != 1 or not target_path: + target_path = render_template(target_template, template_values) + target_path = ensure_under_home(target_path) + + download_dir = ensure_under_home(render_template(default_download_dir, template_values)) + source_path = os.path.join(download_dir, workshop_id) + + if action in ('install', 'update', 'check_updates'): + if not workshop_app_id: + fail(f"Workshop App ID is missing for Workshop item {workshop_id}.") + command = [ + steamcmd_path, + '+force_install_dir', server_root, + '+login', 'anonymous', + '+workshop_download_item', workshop_app_id, workshop_id, 'validate', + '+quit', + ] + log(f"workshop_id={workshop_id} steamcmd={' '.join(command)}", 'Downloading Workshop Item') + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=server_root) + if result.stdout: + for line in result.stdout.splitlines(): + log(f"steamcmd[{workshop_id}] {line}") + if result.returncode != 0: + fail(f"SteamCMD failed for Workshop item {workshop_id} with exit code {result.returncode}.") + if not os.path.isdir(source_path): + fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}") + + if action != 'check_updates': + log(f"workshop_id={workshop_id} install_path={target_path}", 'Extracting/Copying') + sync_copy(source_path, target_path) + log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name') + if post_install_script: + log(f"workshop_id={workshop_id} cwd={server_root}", 'Running Post-install Script') + post_result = subprocess.run(['bash', '-lc', post_install_script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=server_root) + if post_result.stdout: + for line in post_result.stdout.splitlines(): + log(f"post_install[{workshop_id}] {line}") + if post_result.returncode != 0: + fail(f"Post-install script failed for Workshop item {workshop_id} with exit code {post_result.returncode}.") + log(f"workshop_id={workshop_id} install_path={target_path}", 'Completed') + elif action == 'remove': + if os.path.exists(target_path): + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + removed_path = os.path.join(removed_dir, f"{workshop_id}_{timestamp}") + shutil.move(target_path, removed_path) + log(f"workshop_id={workshop_id} removed_path={removed_path}", 'Completed') + else: + log(f"workshop_id={workshop_id} target_path_missing={target_path}", 'Completed') + else: + fail(f"Unknown workshop action: {action}") +except RuntimeError: + sys.exit(1) 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 old mode 100644 new mode 100755 index c9b419ac..011a4f1e --- a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh @@ -7,52 +7,191 @@ if [[ -z "$MANIFEST_PATH" ]]; then exit 1 fi -if [[ ! -f "$MANIFEST_PATH" ]]; then - echo "Manifest not found: $MANIFEST_PATH" - exit 1 -fi +python3 - "$MANIFEST_PATH" <<'PY' +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime -MANIFEST_DIR="$(dirname "$MANIFEST_PATH")" -WORKSHOP_DIR="${MANIFEST_DIR}/workshop" -REMOVED_DIR="${WORKSHOP_DIR}/removed" -LOG_FILE="${MANIFEST_DIR}/workshop_phase1_windows.log" +manifest_path = os.path.abspath(sys.argv[1]) +if not os.path.isfile(manifest_path): + print(f"Manifest not found: {manifest_path}") + sys.exit(1) -mkdir -p "$WORKSHOP_DIR" "$REMOVED_DIR" +manifest_dir = os.path.dirname(manifest_path) +home_root = os.path.dirname(manifest_dir) +log_file = os.path.join(manifest_dir, 'workshop_install_windows.log') +removed_dir = os.path.join(manifest_dir, 'workshop', 'removed') +os.makedirs(removed_dir, exist_ok=True) -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","")) + +def log(message, status=None): + line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]" + if status: + line += f" [{status}]" + line += f" {message}" + print(line) + with open(log_file, 'a', encoding='utf-8') as handle: + handle.write(line + "\n") + + +def fail(message): + log(message, 'Failed') + raise RuntimeError(message) + + +def uniq_numeric_items(raw_items): + seen = [] + for value in raw_items: + text = str(value).strip() + if text.isdigit() and text not in seen: + seen.append(text) + return seen + + +def render_template(template, values): + rendered = str(template or '') + for key, value in values.items(): + rendered = rendered.replace('{' + key + '}', str(value)) + return rendered + + +def ensure_under_home(path_value): + target = os.path.abspath(path_value) + try: + common = os.path.commonpath([home_root, target]) + except ValueError: + common = '' + if common != os.path.abspath(home_root): + fail(f"Refusing to write outside server home: {target}") + return target + + +def resolve_steamcmd(explicit_path=''): + candidates = [] + explicit_path = str(explicit_path or '').strip() + if explicit_path: + candidates.append(explicit_path) + env_value = os.environ.get('STEAMCMD_PATH', '').strip() + if env_value: + candidates.append(env_value) + for path_value in ( + '/home/gameserver/steamcmd/steamcmd.sh', + shutil.which('steamcmd'), + shutil.which('steamcmd.exe'), + shutil.which('steamcmd.sh'), + ): + if path_value: + candidates.append(path_value) + for candidate in candidates: + if candidate and os.path.isfile(candidate): + return candidate + fail('SteamCMD is missing on the agent host.') + + +def sync_copy(src, dst): + if not os.path.isdir(src): + fail(f"Workshop download source was not found: {src}") + os.makedirs(dst, exist_ok=True) + for entry in os.listdir(src): + source_entry = os.path.join(src, entry) + target_entry = os.path.join(dst, entry) + if os.path.isdir(source_entry): + sync_copy(source_entry, target_entry) + else: + os.makedirs(os.path.dirname(target_entry), exist_ok=True) + shutil.copy2(source_entry, target_entry) + + +try: + with open(manifest_path, 'r', encoding='utf-8') as handle: + manifest = json.load(handle) + + extra = manifest.get('extra') or {} + action = str(manifest.get('action', '')).strip() + raw_items = manifest.get('items', []) + if isinstance(raw_items, dict): + raw_items = raw_items.get('workshop_item_ids', []) + items = uniq_numeric_items(raw_items) + if not items: + fail('No Workshop IDs were found in the manifest.') + + workshop_app_id = str(extra.get('workshop_app_id') or manifest.get('workshop_app_id') or '').strip() + steam_app_id = str(extra.get('steam_app_id') or manifest.get('steam_app_id') or '').strip() + server_root = ensure_under_home(extra.get('server_root') or home_root) + steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '') + post_install_script = str(extra.get('post_install_script') or '').strip() + default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id) + + action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action + log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label) + + for workshop_id in items: + folder_name = str(extra.get('optional_folder_name') or '').strip() or ('@' + workshop_id) + template_values = { + 'HOME_ID': manifest.get('home_id', ''), + 'SERVER_ROOT': server_root, + 'GAME_ROOT': server_root, + 'WORKSHOP_ID': workshop_id, + 'WORKSHOP_APP_ID': workshop_app_id, + 'STEAM_APP_ID': steam_app_id, + 'FOLDER_NAME': folder_name, + 'MOD_FOLDER': folder_name, + } + target_template = str(extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}') + target_path = str(extra.get('target_path_resolved') or '').strip() + if len(items) != 1 or not target_path: + target_path = render_template(target_template, template_values) + target_path = ensure_under_home(target_path) + + download_dir = ensure_under_home(render_template(default_download_dir, template_values)) + source_path = os.path.join(download_dir, workshop_id) + + if action in ('install', 'update', 'check_updates'): + if not workshop_app_id: + fail(f"Workshop App ID is missing for Workshop item {workshop_id}.") + command = [ + steamcmd_path, + '+force_install_dir', server_root, + '+login', 'anonymous', + '+workshop_download_item', workshop_app_id, workshop_id, 'validate', + '+quit', + ] + log(f"workshop_id={workshop_id} steamcmd={' '.join(command)}", 'Downloading Workshop Item') + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=server_root) + if result.stdout: + for line in result.stdout.splitlines(): + log(f"steamcmd[{workshop_id}] {line}") + if result.returncode != 0: + fail(f"SteamCMD failed for Workshop item {workshop_id} with exit code {result.returncode}.") + if not os.path.isdir(source_path): + fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}") + + if action != 'check_updates': + log(f"workshop_id={workshop_id} install_path={target_path}", 'Extracting/Copying') + sync_copy(source_path, target_path) + log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name') + if post_install_script: + log(f"workshop_id={workshop_id} cwd={server_root}", 'Running Post-install Script') + post_result = subprocess.run(['bash', '-lc', post_install_script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=server_root) + if post_result.stdout: + for line in post_result.stdout.splitlines(): + log(f"post_install[{workshop_id}] {line}") + if post_result.returncode != 0: + fail(f"Post-install script failed for Workshop item {workshop_id} with exit code {post_result.returncode}.") + log(f"workshop_id={workshop_id} install_path={target_path}", 'Completed') + elif action == 'remove': + if os.path.exists(target_path): + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + removed_path = os.path.join(removed_dir, f"{workshop_id}_{timestamp}") + shutil.move(target_path, removed_path) + log(f"workshop_id={workshop_id} removed_path={removed_path}", 'Completed') + else: + log(f"workshop_id={workshop_id} target_path_missing={target_path}", 'Completed') + else: + fail(f"Unknown workshop action: {action}") +except RuntimeError: + sys.exit(1) 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_actions.php b/Panel/modules/addonsmanager/server_content_actions.php index 6e131f28..b78308cf 100644 --- a/Panel/modules/addonsmanager/server_content_actions.php +++ b/Panel/modules/addonsmanager/server_content_actions.php @@ -176,7 +176,15 @@ function server_content_execute_manifest($home_id, $manifest_path, $script_key, 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) { + if ($script_key === 'workshop') { + $prepare_error = ''; + $prepared_path = scm_prepare_workshop_script_for_agent($remote, $home_info, $server_xml, $prepare_error); + if ($prepared_path === false) { + return server_content_result('failed', $prepare_error, array('script_key' => (string)$script_key)); + } + $script_path = $prepared_path; + } + elseif ((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:$?"; @@ -469,4 +477,3 @@ function server_content_run_scheduled_action($home_id, $action, $options = array server_content_log_action($home_id, $action, $result['status'], $result['message'], $result['details']); return $result; } - diff --git a/Panel/modules/addonsmanager/server_content_categories.php b/Panel/modules/addonsmanager/server_content_categories.php index 57b0af55..aee6dff8 100644 --- a/Panel/modules/addonsmanager/server_content_categories.php +++ b/Panel/modules/addonsmanager/server_content_categories.php @@ -40,9 +40,9 @@ function get_server_content_categories() { return array( - 'file_download' => 'File Download / Archive', + 'file_download' => 'Downloadable Mod', 'workshop_item' => 'Steam Workshop Item', - 'config_edit' => 'Config Edit', + 'config_edit' => 'Configuration Package', 'scripted_installer' => 'Scripted Installer', ); } diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php index 935f9c97..9d4fc234 100644 --- a/Panel/modules/addonsmanager/server_content_helpers.php +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -6,10 +6,10 @@ */ if (!defined('SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT')) { - define('SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT', '/var/www/html/GSP/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh'); + define('SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT', 'generic_steam_workshop_linux.sh'); } if (!defined('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT')) { - define('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT', '/var/www/html/GSP/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh'); + define('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT', 'generic_steam_workshop_windows_cygwin.sh'); } function scm_ensure_workshop_schema($db) @@ -182,6 +182,73 @@ function scm_get_workshop_script_path(array $home_info, $server_xml) return scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT; } +function scm_get_bundled_workshop_script_source(array $home_info) +{ + $filename = scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT; + return dirname(__FILE__) . '/scripts/workshop/' . $filename; +} + +function scm_is_legacy_panel_workshop_script_path($script_path) +{ + $script_path = trim((string)$script_path); + if ($script_path === '') { + return false; + } + return (strpos($script_path, '/var/www/html/') === 0 && strpos($script_path, '/Panel/modules/addonsmanager/scripts/workshop/') !== false) + || (strpos($script_path, '/OGP_User_Files/modules/addonsmanager/scripts/') !== false); +} + +function scm_get_agent_managed_workshop_script_path(array $home_info) +{ + $home_path = rtrim(clean_path((string)$home_info['home_path']), '/'); + $filename = scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT; + $remote_path = clean_path($home_path . '/gsp_server_content/scripts/workshop/' . $filename); + if (!scm_path_is_under_home($home_path, $remote_path)) { + return false; + } + return $remote_path; +} + +function scm_prepare_workshop_script_for_agent($remote, array $home_info, $server_xml, &$error = '') +{ + $error = ''; + $configured_path = trim((string)scm_get_workshop_script_path($home_info, $server_xml)); + if ($configured_path !== '' && !scm_is_legacy_panel_workshop_script_path($configured_path) && preg_match('/^[^\\r\\n\\0]+$/', $configured_path)) { + if ((int)$remote->rfile_exists($configured_path) === 1) { + return $configured_path; + } + $error = 'Configured workshop script not found on agent host: ' . $configured_path; + return false; + } + + $source_path = scm_get_bundled_workshop_script_source($home_info); + if (!is_file($source_path)) { + $error = 'Bundled workshop script is missing from the panel: ' . $source_path; + return false; + } + + $remote_path = scm_get_agent_managed_workshop_script_path($home_info); + if ($remote_path === false) { + $error = 'Unable to resolve an agent-managed workshop script path for this server.'; + return false; + } + + $script_body = @file_get_contents($source_path); + if ($script_body === false || $script_body === '') { + $error = 'Failed to read bundled workshop script: ' . $source_path; + return false; + } + + $remote_dir = dirname($remote_path); + $remote->exec("mkdir -p " . escapeshellarg($remote_dir)); + if ((int)$remote->remote_writefile($remote_path, $script_body) !== 1) { + $error = 'Failed to sync workshop script to agent host.'; + return false; + } + $remote->exec("chmod 755 " . escapeshellarg($remote_path) . " >/dev/null 2>&1 || true"); + return $remote_path; +} + function scm_get_csrf_token() { if (empty($_SESSION['addonsmanager_workshop_csrf'])) { @@ -255,9 +322,9 @@ function scm_get_cache_mode($db) function scm_get_install_methods() { return array( - 'download_zip' => 'File Download / Archive', + 'download_zip' => 'Downloadable Mod', 'steam_workshop' => 'Steam Workshop Item', - 'config_edit' => 'Config Edit', + 'config_edit' => 'Configuration Package', 'post_script' => 'Scripted Installer', ); } @@ -265,10 +332,10 @@ function scm_get_install_methods() function scm_get_install_method_help_text() { return array( - 'download_zip' => 'Downloads an archive or file from URL; extract path is optional.', - 'steam_workshop' => 'Installs a Steam Workshop item by Workshop ID without requiring URL.', - 'config_edit' => 'Applies config edits to the target file/path without requiring URL.', - 'post_script' => 'Runs an installer script/action body without requiring URL.', + 'download_zip' => 'Download and extract a ZIP, RAR, or archive file.', + 'steam_workshop' => 'Install a Steam Workshop mod using Workshop ID.', + 'config_edit' => 'Install configuration files, profiles, or templates.', + 'post_script' => 'Run a custom scripted installation process.', ); } @@ -305,52 +372,201 @@ function scm_get_install_method_default($value = '') return isset($methods[$value]) ? $value : 'download_zip'; } -function scm_validate_install_method_payload($install_method, array $payload, &$message = '') +function scm_get_install_payload_keys() { - $install_method = scm_get_install_method_default($install_method); - $required = scm_get_install_method_required_fields(); - $errors = scm_get_install_method_validation_errors(); - if (!isset($required[$install_method])) { - $message = 'Invalid install/content type selected.'; + return array( + 'url', + 'path', + 'workshop_item_id', + 'workshop_app_id', + 'target_path_template', + 'optional_folder_name', + 'post_script', + 'config_edit_rule', + 'launch_param_additions', + ); +} + +function scm_collect_install_payload(array $defaults = array(), array $request = array(), array $override_keys = array()) +{ + $payload = array(); + foreach (scm_get_install_payload_keys() as $key) { + $payload[$key] = isset($defaults[$key]) ? trim((string)$defaults[$key]) : ''; + } + foreach ($override_keys as $key) { + if (array_key_exists($key, $request)) { + $payload[$key] = trim((string)$request[$key]); + } + } + return $payload; +} + +function scm_validate_numeric_content_value($value, $error_message, &$message, $allow_blank = false) +{ + $value = trim((string)$value); + if ($value === '') { + if ($allow_blank) { + $message = ''; + return true; + } + $message = $error_message; return false; } - if ($install_method === 'config_edit') { - $path = isset($payload['path']) ? trim((string)$payload['path']) : ''; - $rule = isset($payload['config_edit_rule']) ? trim((string)$payload['config_edit_rule']) : ''; - if ($path === '' || $rule === '') { - $message = $errors['config_edit']; - return false; - } - $message = ''; - return true; - } - if ($install_method === 'post_script') { - $script = isset($payload['post_script']) ? trim((string)$payload['post_script']) : ''; - if ($script === '') { - $message = $errors['post_script']; - return false; - } - $message = ''; - return true; - } - foreach ($required[$install_method] as $field) { - $value = isset($payload[$field]) ? trim((string)$payload[$field]) : ''; - if ($value === '') { - $message = isset($errors[$install_method]) ? $errors[$install_method] : 'Missing required field.'; - return false; - } - } - if ($install_method === 'steam_workshop') { - $wid = isset($payload['workshop_item_id']) ? trim((string)$payload['workshop_item_id']) : ''; - if ($wid === '' || !preg_match('/^[0-9]+$/', $wid)) { - $message = 'Please enter a Workshop ID.'; - return false; - } + if (!preg_match('/^[0-9]+$/', $value)) { + $message = $error_message; + return false; } $message = ''; return true; } +function scm_validate_download_content(array $payload, &$message = '') +{ + $url = isset($payload['url']) ? trim((string)$payload['url']) : ''; + if ($url === '') { + $message = 'Please enter a download URL.'; + return false; + } + $message = ''; + return true; +} + +function scm_validate_workshop_content(array $payload, &$message = '') +{ + if (!scm_validate_numeric_content_value(isset($payload['workshop_item_id']) ? $payload['workshop_item_id'] : '', 'Please enter a Workshop ID.', $message, false)) { + return false; + } + if (!scm_validate_numeric_content_value(isset($payload['workshop_app_id']) ? $payload['workshop_app_id'] : '', 'Workshop App ID must be numeric.', $message, true)) { + return false; + } + $folder_name = isset($payload['optional_folder_name']) ? trim((string)$payload['optional_folder_name']) : ''; + if ($folder_name !== '' && (strpos($folder_name, '..') !== false || preg_match('/[\\\\\\/]/', $folder_name))) { + $message = 'Optional folder name must be a single folder name.'; + return false; + } + $message = ''; + return true; +} + +function scm_validate_scripted_installer(array $payload, &$message = '') +{ + $script = isset($payload['post_script']) ? trim((string)$payload['post_script']) : ''; + if ($script === '') { + $message = 'Please enter the installer script/action.'; + return false; + } + $message = ''; + return true; +} + +function scm_validate_configuration_package(array $payload, &$message = '') +{ + $path = isset($payload['path']) ? trim((string)$payload['path']) : ''; + $rule = isset($payload['config_edit_rule']) ? trim((string)$payload['config_edit_rule']) : ''; + if ($path === '' || $rule === '') { + $message = 'Please enter the config target and edit action.'; + return false; + } + $message = ''; + return true; +} + +function scm_validate_install_method_payload($install_method, array $payload, &$message = '') +{ + $install_method = scm_get_install_method_default($install_method); + if (!isset(scm_get_install_method_required_fields()[$install_method])) { + $message = 'Invalid install/content type selected.'; + return false; + } + + if ($install_method === 'download_zip') { + return scm_validate_download_content($payload, $message); + } + if ($install_method === 'steam_workshop') { + return scm_validate_workshop_content($payload, $message); + } + if ($install_method === 'post_script') { + return scm_validate_scripted_installer($payload, $message); + } + if ($install_method === 'config_edit') { + return scm_validate_configuration_package($payload, $message); + } + $message = ''; + return true; +} + +function scm_build_workshop_runtime_context($db, array $home_info, $server_xml, array $payload, &$message = '') +{ + if (!scm_validate_workshop_content($payload, $message)) { + return false; + } + + $workshop_item_id = trim((string)$payload['workshop_item_id']); + $target_path_template = trim((string)$payload['target_path_template']); + $optional_folder_name = trim((string)$payload['optional_folder_name']); + $workshop_app_id_override = trim((string)$payload['workshop_app_id']); + $fallback_profile = function_exists('sw_get_profile_for_home') ? sw_get_profile_for_home($db, (int)$home_info['home_id']) : false; + $resolved = function_exists('steam_workshop_install_item_to_home') + ? steam_workshop_install_item_to_home($db, $home_info, $workshop_item_id, $target_path_template, array( + 'optional_folder_name' => $optional_folder_name, + 'workshop_app_id' => $workshop_app_id_override, + )) + : array('ok' => false); + + if (!empty($resolved['ok'])) { + $profile = (isset($resolved['profile']) && is_array($resolved['profile'])) ? $resolved['profile'] : array(); + $message = ''; + return array( + 'workshop_item_id' => $workshop_item_id, + 'workshop_app_id' => isset($resolved['workshop_app_id']) ? (string)$resolved['workshop_app_id'] : '', + 'steam_app_id' => isset($resolved['steam_app_id']) ? (string)$resolved['steam_app_id'] : '', + 'folder_name' => isset($resolved['folder_name']) ? (string)$resolved['folder_name'] : ($optional_folder_name !== '' ? $optional_folder_name : '@' . $workshop_item_id), + 'target_path_template' => isset($resolved['target_path_template']) ? (string)$resolved['target_path_template'] : $target_path_template, + 'target_path_resolved' => isset($resolved['target_path_resolved']) ? (string)$resolved['target_path_resolved'] : '', + 'server_root' => rtrim((string)$home_info['home_path'], '/'), + 'steamcmd_path' => isset($profile['steamcmd_path']) ? trim((string)$profile['steamcmd_path']) : '', + 'workshop_download_dir' => (isset($profile['workshop_download_dir_template']) && trim((string)$profile['workshop_download_dir_template']) !== '') + ? sw_apply_template((string)$profile['workshop_download_dir_template'], (array)$resolved['vars']) + : '', + ); + } + + $fallback_workshop_app_id = $workshop_app_id_override; + if ($fallback_workshop_app_id === '' && is_array($fallback_profile) && !empty($fallback_profile['workshop_app_id'])) { + $fallback_workshop_app_id = (string)$fallback_profile['workshop_app_id']; + } + if ($fallback_workshop_app_id === '') { + $fallback_workshop_app_id = scm_extract_workshop_app_id($server_xml); + } + $steam_app_id = (is_array($fallback_profile) && !empty($fallback_profile['steam_app_id'])) ? (string)$fallback_profile['steam_app_id'] : ''; + $folder_name = ($optional_folder_name !== '') ? $optional_folder_name : '@' . $workshop_item_id; + $effective_template = $target_path_template; + if ($effective_template === '') { + $effective_template = (is_array($fallback_profile) && !empty($fallback_profile['install_path_template'])) + ? (string)$fallback_profile['install_path_template'] + : '{SERVER_ROOT}/{MOD_FOLDER}'; + } + $placeholder_map = scm_build_placeholder_map($home_info, array('exe_location' => isset($server_xml->exe_location) ? (string)$server_xml->exe_location : ''), array( + 'WORKSHOP_ID' => $workshop_item_id, + 'WORKSHOP_APP_ID' => $fallback_workshop_app_id, + 'STEAM_APP_ID' => $steam_app_id, + 'FOLDER_NAME' => $folder_name, + 'MOD_FOLDER' => $folder_name, + )); + $message = ''; + return array( + 'workshop_item_id' => $workshop_item_id, + 'workshop_app_id' => $fallback_workshop_app_id, + 'steam_app_id' => $steam_app_id, + 'folder_name' => $folder_name, + 'target_path_template' => $effective_template, + 'target_path_resolved' => scm_apply_placeholders($effective_template, $placeholder_map), + 'server_root' => rtrim((string)$home_info['home_path'], '/'), + 'steamcmd_path' => (is_array($fallback_profile) && !empty($fallback_profile['steamcmd_path'])) ? (string)$fallback_profile['steamcmd_path'] : '', + 'workshop_download_dir' => '', + ); +} + function scm_build_placeholder_map(array $home_info, array $server_context = array(), array $overrides = array()) { $home_id = (int)(isset($home_info['home_id']) ? $home_info['home_id'] : 0); @@ -370,6 +586,8 @@ function scm_build_placeholder_map(array $home_info, array $server_context = arr '{WORKSHOP_ID}' => '', '{WORKSHOP_APP_ID}' => '', '{STEAM_APP_ID}' => '', + '{FOLDER_NAME}' => '', + '{MOD_FOLDER}' => '', ); foreach ($overrides as $key => $value) { $token = '{' . strtoupper(trim((string)$key, '{}')) . '}'; diff --git a/Panel/modules/addonsmanager/workshop_action.php b/Panel/modules/addonsmanager/workshop_action.php index 1e4c4c1c..c0b94b7e 100644 --- a/Panel/modules/addonsmanager/workshop_action.php +++ b/Panel/modules/addonsmanager/workshop_action.php @@ -81,13 +81,6 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, 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.'; @@ -99,8 +92,10 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, '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), + 'workshop_app_id' => (!empty($extra_manifest['workshop_app_id']) ? (string)$extra_manifest['workshop_app_id'] : scm_extract_workshop_app_id($server_xml)), + 'steam_app_id' => !empty($extra_manifest['steam_app_id']) ? (string)$extra_manifest['steam_app_id'] : '', 'items' => array_values($item_ids), + 'generated_at' => date('Y-m-d H:i:s'), ); if (!empty($extra_manifest)) { $manifest['extra'] = $extra_manifest; @@ -123,8 +118,8 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $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; + $script_path = scm_prepare_workshop_script_for_agent($remote, $home_info, $server_xml, $error); + if ($script_path === false) { return false; } diff --git a/Panel/ogp_api.php b/Panel/ogp_api.php index 6d27d5cb..eaa2a278 100644 --- a/Panel/ogp_api.php +++ b/Panel/ogp_api.php @@ -1432,14 +1432,7 @@ function api_addonsmanager() $addon_info = $addons_rows[0]; $install_method = scm_get_install_method_default(isset($addon_info['install_method']) ? $addon_info['install_method'] : 'download_zip'); - $validation_payload = array( - 'url' => isset($addon_info['url']) ? $addon_info['url'] : '', - 'path' => isset($addon_info['path']) ? $addon_info['path'] : '', - 'workshop_item_id' => isset($addon_info['workshop_item_id']) ? $addon_info['workshop_item_id'] : '', - 'target_path_template' => isset($addon_info['target_path_template']) ? $addon_info['target_path_template'] : '', - 'post_script' => isset($addon_info['post_script']) ? $addon_info['post_script'] : '', - 'config_edit_rule' => isset($addon_info['config_edit_rule']) ? $addon_info['config_edit_rule'] : '', - ); + $validation_payload = scm_collect_install_payload($addon_info); $validation_message = ''; if (!scm_validate_install_method_payload($install_method, $validation_payload, $validation_message)) return array("status" => '422', "message" => $validation_message); @@ -1480,14 +1473,24 @@ function api_addonsmanager() if ($install_method === 'steam_workshop') { scm_ensure_workshop_schema($db); - $workshop_item_id = trim((string)$addon_info['workshop_item_id']); + $workshop_runtime = scm_build_workshop_runtime_context($db, $home_info, $server_xml, $validation_payload, $validation_message); + if ($workshop_runtime === false) + return array("status" => '422', "message" => $validation_message); + $workshop_item_id = (string)$workshop_runtime['workshop_item_id']; $workshop_error = ''; $extra_manifest = array( 'addon_id' => (int)$addon_id, - 'target_path_template' => isset($addon_info['target_path_template']) ? (string)$addon_info['target_path_template'] : '', - 'optional_folder_name' => isset($addon_info['optional_folder_name']) ? (string)$addon_info['optional_folder_name'] : '', + 'target_path_template' => (string)$workshop_runtime['target_path_template'], + 'target_path_resolved' => (string)$workshop_runtime['target_path_resolved'], + 'optional_folder_name' => isset($validation_payload['optional_folder_name']) ? (string)$validation_payload['optional_folder_name'] : '', 'config_edit_rule' => isset($addon_info['config_edit_rule']) ? (string)$addon_info['config_edit_rule'] : '', 'launch_param_additions' => isset($addon_info['launch_param_additions']) ? (string)$addon_info['launch_param_additions'] : '', + 'workshop_app_id' => (string)$workshop_runtime['workshop_app_id'], + 'steam_app_id' => (string)$workshop_runtime['steam_app_id'], + 'steamcmd_path' => isset($workshop_runtime['steamcmd_path']) ? (string)$workshop_runtime['steamcmd_path'] : '', + 'workshop_download_dir' => isset($workshop_runtime['workshop_download_dir']) ? (string)$workshop_runtime['workshop_download_dir'] : '', + 'server_root' => isset($workshop_runtime['server_root']) ? (string)$workshop_runtime['server_root'] : rtrim((string)$home_info['home_path'], '/'), + 'post_install_script' => trim((string)$post_script), ); $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', array($workshop_item_id), $workshop_error, $extra_manifest); if ($ok) From d0c7a5a25ca5ef3aebc9b0ea03094080f13db63a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 22:37:08 +0000 Subject: [PATCH 2/3] Document workshop normalization changes Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/b08aa54e-755c-4869-ba97-0f817a05b3c6 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .github/module-map.md | 2 +- LAST_UPDATE.txt | 19 +++++++++++++++++++ Panel/CHANGELOG.md | 1 + Panel/docs/COPILOT_TODO.md | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 LAST_UPDATE.txt diff --git a/.github/module-map.md b/.github/module-map.md index 39a9b38d..f5fa0d71 100644 --- a/.github/module-map.md +++ b/.github/module-map.md @@ -37,7 +37,7 @@ This file captures how the control panel, storefront, agents, and helper scripts | `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. | -| `extras`, `addonsmanager` | Workshop/add-on management. | Hooks into game homes after provisioning. | +| `extras`, `addonsmanager` | Workshop/add-on management. Server Content workshop installs now share validation/runtime helpers across admin, user, and API flows, and sync bundled workshop scripts into each home’s `gsp_server_content/scripts/workshop/` directory before execution. | Hooks into game homes after provisioning and uses agent-side SteamCMD copy/install workflows. | | `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, including scheduler-triggered Server Content actions via `ogp_api.php?server_content/run_scheduled_action` and `modules/addonsmanager/server_content_actions.php`. | diff --git a/LAST_UPDATE.txt b/LAST_UPDATE.txt new file mode 100644 index 00000000..f60b76bc --- /dev/null +++ b/LAST_UPDATE.txt @@ -0,0 +1,19 @@ +2026-05-19 22:26:47 +Branch: copilot/normalize-workshop-installs +Commit: c471c4b +Updated files: +- Panel/js/modules/addonsmanager.js +- Panel/lang/English/modules/addonsmanager.php +- Panel/modules/addonsmanager/addons_installer.php +- Panel/modules/addonsmanager/addons_manager.php +- Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh +- Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh +- Panel/modules/addonsmanager/server_content_actions.php +- Panel/modules/addonsmanager/server_content_categories.php +- Panel/modules/addonsmanager/server_content_helpers.php +- Panel/modules/addonsmanager/workshop_action.php +- Panel/ogp_api.php +- Panel/CHANGELOG.md +- Panel/docs/COPILOT_TODO.md +- .github/module-map.md +Summary: Normalized Steam Workshop content installs across shared validation, the user installer UI, API execution, and agent-side SteamCMD script syncing/logging. diff --git a/Panel/CHANGELOG.md b/Panel/CHANGELOG.md index a15bc6ab..e472e15b 100644 --- a/Panel/CHANGELOG.md +++ b/Panel/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 2026-05-19 +- **Server Content Workshop normalization:** Centralized install-method validation helpers, updated the user Server Content installer to prompt for Workshop ID/app/path overrides with live target previews, normalized workshop execution across UI/API flows, and switched agent execution to synced home-local workshop scripts that run real SteamCMD download/copy/post-install steps on Linux and Cygwin. - **Updater deployment diagnostics + legacy updater Panel path compatibility:** Update UI now surfaces configured stable/unstable branches plus the last resolved temporary checkout/source repo paths used during deployment, and `modules/update/updating.php` now treats `Panel/` and `panel/` zip paths equivalently so legacy update routes no longer skip panel files from current GitHub zip layouts. - **Updater deployment layout enforcement + marker file writes:** Hardened `modules/administration/panel_update.php` to require the live `/var/www/html/GSP` root with explicit `/Panel` and `/Website` destinations, log full source/destination path detection (including temp checkout/source roots), stop on invalid mapping, copy Panel/Website via explicit subtree mapping (preventing nested `Panel/Panel` or `Website/Website`), validate key copied files after sync, and write both `Website/timestamp.txt` and `modules/billing/timestamp.txt` marker files on successful updates. - **Workshop content script path fix:** Updated addonsmanager default Workshop script paths to the panel module script location under `/var/www/html/GSP/Panel/modules/addonsmanager/scripts/workshop/` so Workshop actions no longer use the incorrect game-home-mixed path on the agent host. diff --git a/Panel/docs/COPILOT_TODO.md b/Panel/docs/COPILOT_TODO.md index 75258766..2175c423 100644 --- a/Panel/docs/COPILOT_TODO.md +++ b/Panel/docs/COPILOT_TODO.md @@ -19,6 +19,7 @@ - Add an integration smoke test that exercises updater preflight, required patch state persistence, Apache path scan output, and rollback restore of Panel/Website/version.json artifacts. - 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 a live progress/status panel for addonsmanager Workshop installs so users can watch queued/downloading/copying/completed steps without leaving the page. - Add localized language strings/tooltips for the new cron scheduler `server_content_*` action labels across all supported panel locales. - Add a Game Manager "Live Server Status" panel that consumes `Panel/protocol/gsp_query.php` and shows banner preview plus copyable embed code. - Add an updater admin UI table that renders the full deployment preflight path map (temp checkout, source repo/panel/website, destination panel/website) directly from the new layout detection payload for one-click operator verification. From e0549cdaddee8b7032e0d382077427a0d6d96a9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 22:38:09 +0000 Subject: [PATCH 3/3] Refresh workshop update metadata Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/b08aa54e-755c-4869-ba97-0f817a05b3c6 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- LAST_UPDATE.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LAST_UPDATE.txt b/LAST_UPDATE.txt index f60b76bc..bed3d4c1 100644 --- a/LAST_UPDATE.txt +++ b/LAST_UPDATE.txt @@ -1,6 +1,6 @@ 2026-05-19 22:26:47 Branch: copilot/normalize-workshop-installs -Commit: c471c4b +Commit: d0c7a5a Updated files: - Panel/js/modules/addonsmanager.js - Panel/lang/English/modules/addonsmanager.php @@ -16,4 +16,5 @@ Updated files: - Panel/CHANGELOG.md - Panel/docs/COPILOT_TODO.md - .github/module-map.md +- LAST_UPDATE.txt Summary: Normalized Steam Workshop content installs across shared validation, the user installer UI, API execution, and agent-side SteamCMD script syncing/logging.
: - + + +
+ +
Install a Steam Workshop mod using Workshop ID. URL is not required.
+
Workshop App ID Override + +
+ +
+ +
Supported placeholders: {SERVER_ROOT}, {GAME_ROOT}, {WORKSHOP_ID}, {WORKSHOP_APP_ID}, {STEAM_APP_ID}, {FOLDER_NAME}, {MOD_FOLDER}
+
Target Path Preview + +
 
  diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index 9d402239..05d0e506 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -202,6 +202,7 @@ function exec_ogp_module() { + Example Arma 3 Workshop ID: 450814997
- - Supported placeholders: {HOME_ID}, {SERVER_ROOT}, {GAME_ROOT}, {WORKSHOP_ID}, {WORKSHOP_APP_ID}, {STEAM_APP_ID} + + Supported placeholders: {HOME_ID}, {SERVER_ROOT}, {GAME_ROOT}, {WORKSHOP_ID}, {WORKSHOP_APP_ID}, {STEAM_APP_ID}, {FOLDER_NAME}, {MOD_FOLDER}