diff --git a/Panel/modules/addonsmanager/addons_manager.php b/Panel/modules/addonsmanager/addons_manager.php index c4682553..d818cb64 100644 --- a/Panel/modules/addonsmanager/addons_manager.php +++ b/Panel/modules/addonsmanager/addons_manager.php @@ -81,6 +81,7 @@ function exec_ogp_module() { 'url' => $fields['url'], 'path' => $fields['path'], 'workshop_item_id' => $fields['workshop_item_id'], + 'workshop_app_id' => $fields['workshop_app_id'], 'target_path_template' => $fields['target_path_template'], 'post_script' => $fields['post_script'], 'config_edit_rule' => $fields['config_edit_rule'], diff --git a/Panel/modules/addonsmanager/module.php b/Panel/modules/addonsmanager/module.php index eac60521..9b2161e0 100644 --- a/Panel/modules/addonsmanager/module.php +++ b/Panel/modules/addonsmanager/module.php @@ -25,13 +25,16 @@ * (allow_user_workshop_ids, max_workshop_ids, required_workshop_ids, * blocked_workshop_ids); add content_id column to * server_content_workshop so user installs link to their template + * 7 – add Phase 1 Workshop runtime tracking columns to + * server_content_workshop (install_path, install_strategy, enabled, + * load_order) * */ // Module general information $module_title = "Server Content Manager"; -$module_version = "2.4"; -$db_version = 6; +$module_version = "2.5"; +$db_version = 7; $module_required = TRUE; $module_menus = array( array( 'subpage' => 'addons_manager', 'name' => 'Server Content Manager', 'group' => 'admin' ) @@ -260,4 +263,31 @@ $install_queries[5] = array( return true; }, ); +// ── db_version 7 : Workshop Phase 1 runtime tracking columns ──────────────── +$install_queries[6] = array( + function ($db) { + $prefix = OGP_DB_PREFIX; + $table = $db->realEscapeSingle($prefix . 'server_content_workshop'); + $columns = array( + 'install_path' => "VARCHAR(512) NULL AFTER `title`", + 'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`", + 'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`", + 'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`", + ); + foreach ($columns as $col => $definition) { + $check = $db->resultQuery( + "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$table}' + AND COLUMN_NAME = '" . $db->realEscapeSingle($col) . "'" + ); + if (empty($check)) { + if (!$db->query("ALTER TABLE `{$prefix}server_content_workshop` ADD COLUMN `{$col}` {$definition}")) { + return false; + } + } + } + return true; + }, +); ?> diff --git a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh index e63912e5..2f29207d 100755 --- a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh @@ -69,6 +69,19 @@ def ensure_under_home(path_value): return target +def safe_folder_name(value, fallback): + text = str(value or '').strip() + if not text or '..' in text or '/' in text or '\\' in text or '\x00' in text: + return fallback + return text + + +def truthy(value): + if isinstance(value, bool): + return value + return str(value).strip().lower() in ('1', 'yes', 'true', 'on') + + def resolve_steamcmd(explicit_path=''): candidates = [] explicit_path = str(explicit_path or '').strip() @@ -104,6 +117,26 @@ def sync_copy(src, dst): shutil.copy2(source_entry, target_entry) +def copy_bikeys(mod_path, keys_target, workshop_id): + if not os.path.isdir(mod_path): + return 0 + keys_target = ensure_under_home(keys_target) + os.makedirs(keys_target, exist_ok=True) + copied = 0 + for root, dirs, files in os.walk(mod_path): + for filename in files: + if not filename.lower().endswith('.bikey'): + continue + source_file = os.path.join(root, filename) + target_file = os.path.join(keys_target, filename) + shutil.copy2(source_file, target_file) + copied += 1 + log(f"workshop_id={workshop_id} key={target_file}", 'Copying Key') + if copied == 0: + log(f"workshop_id={workshop_id} no .bikey files found; continuing", 'Copying Key') + return copied + + try: with open(manifest_path, 'r', encoding='utf-8') as handle: manifest = json.load(handle) @@ -122,13 +155,17 @@ try: 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() + item_details = manifest.get('item_details') or extra.get('item_details') or {} + default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').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) + detail = item_details.get(workshop_id) or item_details.get(str(workshop_id)) or {} + folder_name = safe_folder_name(detail.get('folder_name') or extra.get('optional_folder_name') or '', '@' + workshop_id) + install_strategy = str(detail.get('install_strategy') or default_install_strategy).strip() or 'copy_to_mod_folder' template_values = { 'HOME_ID': manifest.get('home_id', ''), 'SERVER_ROOT': server_root, @@ -139,11 +176,13 @@ try: '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() + target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}') + target_path = str(detail.get('target_path_resolved') or 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) + keys_target_path = str(detail.get('keys_target_path') or extra.get('keys_target_path') or os.path.join(server_root, 'keys')) + should_copy_keys = truthy(detail.get('copy_keys', extra.get('copy_keys', install_strategy in ('dayz_mod_folder', 'arma_mod_folder')))) download_dir = ensure_under_home(render_template(default_download_dir, template_values)) source_path = os.path.join(download_dir, workshop_id) @@ -169,9 +208,11 @@ try: 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') + log(f"workshop_id={workshop_id} strategy={install_strategy} 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 should_copy_keys: + copy_bikeys(target_path, keys_target_path, workshop_id) 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) 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 index 011a4f1e..c8700564 100755 --- a/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh +++ b/Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh @@ -69,6 +69,19 @@ def ensure_under_home(path_value): return target +def safe_folder_name(value, fallback): + text = str(value or '').strip() + if not text or '..' in text or '/' in text or '\\' in text or '\x00' in text: + return fallback + return text + + +def truthy(value): + if isinstance(value, bool): + return value + return str(value).strip().lower() in ('1', 'yes', 'true', 'on') + + def resolve_steamcmd(explicit_path=''): candidates = [] explicit_path = str(explicit_path or '').strip() @@ -105,6 +118,26 @@ def sync_copy(src, dst): shutil.copy2(source_entry, target_entry) +def copy_bikeys(mod_path, keys_target, workshop_id): + if not os.path.isdir(mod_path): + return 0 + keys_target = ensure_under_home(keys_target) + os.makedirs(keys_target, exist_ok=True) + copied = 0 + for root, dirs, files in os.walk(mod_path): + for filename in files: + if not filename.lower().endswith('.bikey'): + continue + source_file = os.path.join(root, filename) + target_file = os.path.join(keys_target, filename) + shutil.copy2(source_file, target_file) + copied += 1 + log(f"workshop_id={workshop_id} key={target_file}", 'Copying Key') + if copied == 0: + log(f"workshop_id={workshop_id} no .bikey files found; continuing", 'Copying Key') + return copied + + try: with open(manifest_path, 'r', encoding='utf-8') as handle: manifest = json.load(handle) @@ -123,13 +156,17 @@ try: 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() + item_details = manifest.get('item_details') or extra.get('item_details') or {} + default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').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) + detail = item_details.get(workshop_id) or item_details.get(str(workshop_id)) or {} + folder_name = safe_folder_name(detail.get('folder_name') or extra.get('optional_folder_name') or '', '@' + workshop_id) + install_strategy = str(detail.get('install_strategy') or default_install_strategy).strip() or 'copy_to_mod_folder' template_values = { 'HOME_ID': manifest.get('home_id', ''), 'SERVER_ROOT': server_root, @@ -140,11 +177,13 @@ try: '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() + target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}') + target_path = str(detail.get('target_path_resolved') or 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) + keys_target_path = str(detail.get('keys_target_path') or extra.get('keys_target_path') or os.path.join(server_root, 'keys')) + should_copy_keys = truthy(detail.get('copy_keys', extra.get('copy_keys', install_strategy in ('dayz_mod_folder', 'arma_mod_folder')))) download_dir = ensure_under_home(render_template(default_download_dir, template_values)) source_path = os.path.join(download_dir, workshop_id) @@ -170,9 +209,11 @@ try: 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') + log(f"workshop_id={workshop_id} strategy={install_strategy} 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 should_copy_keys: + copy_bikeys(target_path, keys_target_path, workshop_id) 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) diff --git a/Panel/modules/addonsmanager/server_content_helpers.php b/Panel/modules/addonsmanager/server_content_helpers.php index 7b6cb46b..9a3f8757 100644 --- a/Panel/modules/addonsmanager/server_content_helpers.php +++ b/Panel/modules/addonsmanager/server_content_helpers.php @@ -30,6 +30,10 @@ function scm_ensure_workshop_schema($db) `workshop_app_id` VARCHAR(32) NULL, `workshop_item_id` VARCHAR(64) NOT NULL, `title` VARCHAR(255) NULL, + `install_path` VARCHAR(512) NULL, + `install_strategy` VARCHAR(64) NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `load_order` INT NOT NULL DEFAULT 0, `install_state` VARCHAR(32) NOT NULL DEFAULT 'selected', `last_installed_at` DATETIME NULL, `last_updated_at` DATETIME NULL, @@ -60,6 +64,25 @@ function scm_ensure_workshop_schema($db) ); } + $workshop_columns = array( + 'install_path' => "VARCHAR(512) NULL AFTER `title`", + 'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`", + 'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`", + 'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`", + ); + foreach ($workshop_columns as $col => $definition) { + $escaped_col = $db->realEscapeSingle($col); + $col_check = $db->resultQuery( + "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{$wk_table}' + AND COLUMN_NAME = '{$escaped_col}'" + ); + if (empty($col_check)) { + $db->query("ALTER TABLE `".OGP_DB_PREFIX."server_content_workshop` ADD COLUMN `{$col}` {$definition}"); + } + } + return $ok; } @@ -98,28 +121,49 @@ function scm_get_workshop_rows($db, $home_id) 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" + "SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." ORDER BY load_order ASC, created_at DESC, workshop_item_id ASC" ); return is_array($rows) ? $rows : array(); } +function scm_extract_workshop_item_id($value) +{ + $value = trim((string)$value); + if ($value === '') { + return ''; + } + if (preg_match('/^[0-9]{1,20}$/', $value)) { + return ltrim($value, '0') === '' ? '' : $value; + } + if (preg_match('/[?&]id=([0-9]{1,20})(?:[^0-9]|$)/i', $value, $matches)) { + return ltrim($matches[1], '0') === '' ? '' : $matches[1]; + } + if (preg_match('/steamcommunity\.com\/(?:sharedfiles|workshop)\/filedetails\/\?[^ \t\r\n]*id=([0-9]{1,20})/i', $value, $matches)) { + return ltrim($matches[1], '0') === '' ? '' : $matches[1]; + } + return ''; +} + function scm_parse_workshop_ids($raw, &$invalid = array()) { $invalid = array(); $ids = array(); - // Accept IDs separated by commas, newlines, or a mix of both. - $normalized = str_replace(array("\r\n", "\r", "\n"), ',', (string)$raw); - $parts = explode(',', $normalized); + // Accept IDs or full Steam Workshop URLs separated by commas, whitespace, + // or newlines. Customer input is reduced to numeric IDs before it reaches + // manifests, shell commands, or install paths. + $normalized = preg_replace('/[\r\n\t ]+/', ',', (string)$raw); + $parts = explode(',', (string)$normalized); foreach ((array)$parts as $part) { $value = trim((string)$part); if ($value === '') { continue; } - if (!preg_match('/^[0-9]+$/', $value)) { + $item_id = scm_extract_workshop_item_id($value); + if ($item_id === '') { $invalid[] = $value; continue; } - $ids[$value] = $value; + $ids[$item_id] = $item_id; } return array_values($ids); } @@ -472,11 +516,11 @@ function scm_validate_workshop_user_ids($raw_ids, &$message = '') $invalid = array(); $ids = scm_parse_workshop_ids($raw_ids, $invalid); if (!empty($invalid)) { - $message = 'Invalid Workshop IDs: ' . implode(', ', $invalid); + $message = 'Invalid Workshop item entries. Use a numeric Workshop ID or a Steam Workshop URL: ' . implode(', ', $invalid); return false; } if (empty($ids)) { - $message = 'Enter at least one numeric Workshop ID.'; + $message = 'Enter at least one Steam Workshop ID or Workshop URL.'; return false; } $message = ''; @@ -604,6 +648,46 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml, ); } +function scm_detect_workshop_install_strategy(array $home_info, $server_xml, array $template = array()) +{ + if (!empty($template['install_strategy'])) { + $strategy = trim((string)$template['install_strategy']); + if (preg_match('/^[a-z0-9_\-]+$/i', $strategy)) { + return strtolower($strategy); + } + } + foreach (array('workshop_install_strategy', 'install_strategy') as $tag) { + if (isset($server_xml->$tag)) { + $strategy = trim((string)$server_xml->$tag); + if ($strategy !== '' && preg_match('/^[a-z0-9_\-]+$/i', $strategy)) { + return strtolower($strategy); + } + } + } + $game_key = strtolower((string)(isset($home_info['game_key']) ? $home_info['game_key'] : '')); + $cfg_file = strtolower((string)(isset($home_info['home_cfg_file']) ? $home_info['home_cfg_file'] : '')); + $name = strtolower((string)(isset($home_info['game_name']) ? $home_info['game_name'] : '')); + $haystack = $game_key . ' ' . $cfg_file . ' ' . $name; + if (strpos($haystack, 'dayz') !== false) { + return 'dayz_mod_folder'; + } + if (strpos($haystack, 'arma') !== false) { + return 'arma_mod_folder'; + } + return 'copy_to_mod_folder'; +} + +function scm_workshop_should_copy_keys($server_xml, $install_strategy) +{ + foreach (array('workshop_copy_keys', 'copy_workshop_keys') as $tag) { + if (isset($server_xml->$tag)) { + $value = strtolower(trim((string)$server_xml->$tag)); + return in_array($value, array('1', 'yes', 'true', 'on'), true); + } + } + return in_array((string)$install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true); +} + 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); diff --git a/Panel/modules/addonsmanager/workshop_action.php b/Panel/modules/addonsmanager/workshop_action.php index f4d735ef..5bb5c6cf 100644 --- a/Panel/modules/addonsmanager/workshop_action.php +++ b/Panel/modules/addonsmanager/workshop_action.php @@ -67,9 +67,92 @@ function scm_workshop_filter_existing_ids($db, $home_id, array $item_ids) return array_values($allowed); } -function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $action, array $item_ids, &$error = '', array $extra_manifest = array()) +function scm_workshop_get_content_template($db, $addon_id) +{ + $addon_id = (int)$addon_id; + if ($addon_id <= 0) { + return array(); + } + scm_ensure_phase2_schema($db); + $rows = $db->resultQuery( + "SELECT addon_id, name, workshop_app_id, target_path_template, optional_folder_name, + post_script, launch_param_additions, content_version, description + FROM `" . OGP_DB_PREFIX . "addons` + WHERE addon_id=" . $addon_id . " AND install_method='steam_workshop' + LIMIT 1" + ); + return (is_array($rows) && !empty($rows)) ? $rows[0] : array(); +} + +function scm_workshop_build_manifest_context($db, array $home_info, $server_xml, array $item_ids, array $template = array()) +{ + $install_strategy = scm_detect_workshop_install_strategy($home_info, $server_xml, $template); + $copy_keys = scm_workshop_should_copy_keys($server_xml, $install_strategy); + $item_details = array(); + $resolved_app_id = ''; + $steam_app_id = ''; + $template_payload = array( + 'workshop_app_id' => isset($template['workshop_app_id']) ? (string)$template['workshop_app_id'] : '', + 'target_path_template' => isset($template['target_path_template']) ? (string)$template['target_path_template'] : '', + 'optional_folder_name' => isset($template['optional_folder_name']) ? (string)$template['optional_folder_name'] : '', + ); + + foreach ($item_ids as $item_id) { + $payload = $template_payload; + $payload['workshop_item_id'] = (string)$item_id; + // A fixed optional folder name is safe only when installing one item. For + // multi-item installs, use @ so items cannot overwrite each + // other by sharing the same target folder. + if (count($item_ids) !== 1) { + $payload['optional_folder_name'] = ''; + } + $message = ''; + $runtime = scm_build_workshop_runtime_context($db, $home_info, $server_xml, $payload, $message); + if ($runtime === false) { + $runtime = array(); + } + $item_app_id = isset($runtime['workshop_app_id']) ? (string)$runtime['workshop_app_id'] : ''; + if ($resolved_app_id === '' && $item_app_id !== '') { + $resolved_app_id = $item_app_id; + } + if ($steam_app_id === '' && !empty($runtime['steam_app_id'])) { + $steam_app_id = (string)$runtime['steam_app_id']; + } + $item_details[(string)$item_id] = array( + 'workshop_item_id' => (string)$item_id, + 'title' => '', + 'folder_name' => isset($runtime['folder_name']) && $runtime['folder_name'] !== '' ? (string)$runtime['folder_name'] : '@' . $item_id, + 'target_path_template' => isset($runtime['target_path_template']) ? (string)$runtime['target_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}', + 'target_path_resolved' => isset($runtime['target_path_resolved']) ? (string)$runtime['target_path_resolved'] : '', + 'install_strategy' => $install_strategy, + 'copy_keys' => $copy_keys ? 1 : 0, + 'keys_target_path' => rtrim((string)$home_info['home_path'], '/') . '/keys', + ); + } + + if ($resolved_app_id === '') { + $resolved_app_id = scm_extract_workshop_app_id($server_xml); + } + + return array( + 'workshop_app_id' => $resolved_app_id, + 'steam_app_id' => $steam_app_id, + 'server_root' => rtrim((string)$home_info['home_path'], '/'), + 'install_strategy' => $install_strategy, + 'copy_keys' => $copy_keys ? 1 : 0, + 'target_path_template' => isset($template['target_path_template']) && trim((string)$template['target_path_template']) !== '' ? trim((string)$template['target_path_template']) : '{SERVER_ROOT}/{MOD_FOLDER}', + 'post_install_script' => isset($template['post_script']) ? trim((string)$template['post_script']) : '', + 'launch_param_additions' => isset($template['launch_param_additions']) ? trim((string)$template['launch_param_additions']) : '', + 'content_template_id' => isset($template['addon_id']) ? (int)$template['addon_id'] : 0, + 'content_template_name' => isset($template['name']) ? (string)$template['name'] : '', + 'item_details' => $item_details, + ); +} + +function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $action, array $item_ids, &$error = '', array $extra_manifest = array(), &$result_details = array()) { $error = ''; + $result_details = array(); if (empty($item_ids)) { $error = 'No Workshop IDs were selected for this action.'; return false; @@ -89,12 +172,18 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $manifest_dir = dirname($manifest_path); $manifest = array( + 'manifest_version' => 1, 'action' => (string)$action, 'home_id' => (int)$home_info['home_id'], 'home_cfg_id' => (int)$home_info['home_cfg_id'], + 'game_path' => $home_path, + 'server_path' => $home_path, '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), + 'item_details' => !empty($extra_manifest['item_details']) && is_array($extra_manifest['item_details']) ? $extra_manifest['item_details'] : array(), + 'install_strategy' => !empty($extra_manifest['install_strategy']) ? (string)$extra_manifest['install_strategy'] : '', + 'target_path' => !empty($extra_manifest['target_path_template']) ? (string)$extra_manifest['target_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}', 'generated_at' => date('Y-m-d H:i:s'), ); if (!empty($extra_manifest)) { @@ -133,6 +222,12 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $error = 'Workshop script exit marker not found in output.'; return false; } + $result_details = array( + 'manifest_path' => $manifest_path, + 'script_path' => $script_path, + 'log_path' => clean_path($manifest_dir . (scm_is_windows_home($home_info) ? '/workshop_install_windows.log' : '/workshop_install.log')), + 'output' => trim(preg_replace('/__GSP_WORKSHOP_EXIT:\d+/', '', $output)), + ); $exit_code = (int)$matches[1]; if ($exit_code !== 0) { $error = 'Workshop script failed (exit '.$exit_code.'): '.trim($output); @@ -149,6 +244,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r $message = 'Workshop schema migration failed.'; return false; } + scm_ensure_phase2_schema($db); $home_id = (int)$home_info['home_id']; $user_id = (int)$user_id; @@ -159,39 +255,26 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r return false; } - // Resolve the workshop_app_id: prefer the admin content template, fall - // back to the game XML. - $template_workshop_app_id = ''; - if ($addon_id > 0) { - $tpl = $db->resultQuery( - "SELECT workshop_app_id FROM `" . OGP_DB_PREFIX . "addons` - WHERE addon_id=" . $addon_id . " AND install_method='steam_workshop'" - ); - if (is_array($tpl) && !empty($tpl[0]['workshop_app_id'])) { - $template_workshop_app_id = trim((string)$tpl[0]['workshop_app_id']); - } - } - $extra_manifest = array(); - if ($template_workshop_app_id !== '') { - $extra_manifest['workshop_app_id'] = $template_workshop_app_id; - } + $template = scm_workshop_get_content_template($db, $addon_id); if ($action === 'install_new') { $invalid = array(); $item_ids = scm_parse_workshop_ids($raw_ids, $invalid); if (!empty($invalid)) { - $message = 'Invalid Workshop IDs: ' . implode(', ', $invalid); + $message = 'Invalid Workshop item entries. Use a numeric Workshop ID or Steam Workshop URL: ' . implode(', ', $invalid); return false; } if (empty($item_ids)) { - $message = 'Enter at least one numeric Workshop ID.'; + $message = 'Enter at least one Steam Workshop ID or Workshop URL.'; return false; } - // Determine the resolved workshop_app_id for storage (template first, then XML). - $resolved_app_id = $template_workshop_app_id !== '' - ? $template_workshop_app_id - : scm_extract_workshop_app_id($server_xml); + $manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template); + $resolved_app_id = isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : ''; + if ($resolved_app_id === '') { + $message = 'Workshop App ID is missing. Configure it on the Server Content template or game XML before installing Workshop items.'; + return false; + } // Check whether the content_id column exists (added in db_version 6). $has_content_id_col = (bool)$db->resultQuery( @@ -201,19 +284,32 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r AND COLUMN_NAME = 'content_id'" ); + $next_order_rows = $db->resultQuery( + "SELECT COALESCE(MAX(load_order), 0) AS max_order FROM `".OGP_DB_PREFIX."server_content_workshop` + WHERE home_id=".$home_id + ); + $next_order = (is_array($next_order_rows) && isset($next_order_rows[0]['max_order'])) ? (int)$next_order_rows[0]['max_order'] : 0; foreach ($item_ids as $item_id) { + $item_detail = isset($manifest_context['item_details'][(string)$item_id]) ? $manifest_context['item_details'][(string)$item_id] : array(); + $install_path = isset($item_detail['target_path_resolved']) ? (string)$item_detail['target_path_resolved'] : ''; + $install_strategy = isset($item_detail['install_strategy']) ? (string)$item_detail['install_strategy'] : (string)$manifest_context['install_strategy']; + $next_order++; $content_id_col = $has_content_id_col && $addon_id > 0 ? ", content_id" : ''; $content_id_val = $has_content_id_col && $addon_id > 0 ? ", " . $addon_id : ''; $content_id_upd = $has_content_id_col && $addon_id > 0 ? ", content_id=VALUES(content_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" . $content_id_col . ") + (home_id, home_cfg_id, remote_server_id, workshop_app_id, workshop_item_id, install_path, install_strategy, enabled, load_order, install_state, created_by, created_at, updated_at" . $content_id_col . ") VALUES ( ".$home_id.", ".(int)$home_info['home_cfg_id'].", ".(int)$home_info['remote_server_id'].", '".$db->realEscapeSingle($resolved_app_id)."', '".$db->realEscapeSingle($item_id)."', - 'selected', + '".$db->realEscapeSingle($install_path)."', + '".$db->realEscapeSingle($install_strategy)."', + 1, + ".$next_order.", + 'queued', ".$user_id.", NOW(), NOW() @@ -223,7 +319,10 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r home_cfg_id=VALUES(home_cfg_id), remote_server_id=VALUES(remote_server_id), workshop_app_id=VALUES(workshop_app_id), - install_state='selected', + install_path=VALUES(install_path), + install_strategy=VALUES(install_strategy), + enabled=1, + install_state='queued', last_error=NULL, updated_at=NOW()" . $content_id_upd; $db->query($query); @@ -231,12 +330,13 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r 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, $extra_manifest); + $details = array(); + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', $item_ids, $error, $manifest_context, $details); 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)." addon_id=".$addon_id." status=success"); $is_error = false; - $message = 'Workshop IDs installed successfully.'; + $message = 'Workshop item(s) installed successfully. Manifest: '.scm_h(isset($details['manifest_path']) ? $details['manifest_path'] : '').' Log: '.scm_h(isset($details['log_path']) ? $details['log_path'] : ''); return true; } scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); @@ -252,9 +352,11 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r return false; } $target_action = ($action === 'remove_selected') ? 'remove' : 'update'; + $manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template); 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, $extra_manifest); + $details = array(); + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, $target_action, $item_ids, $error, $manifest_context, $details); if ($ok) { if ($target_action === 'remove') { scm_workshop_update_rows_state($db, $home_id, $item_ids, 'removed', null, false, true); @@ -263,7 +365,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r } scm_workshop_log_action($db, $home_id, $user_id, $action." ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success"); $is_error = false; - $message = ($target_action === 'remove') ? 'Selected Workshop IDs marked removed.' : 'Selected Workshop IDs updated successfully.'; + $message = (($target_action === 'remove') ? 'Selected Workshop item(s) removed.' : 'Selected Workshop item(s) updated successfully.') . ' Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : ''); return true; } scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); @@ -288,14 +390,16 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r $message = 'No Workshop IDs are currently saved for this server.'; return false; } + $manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template); 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, $extra_manifest); + $details = array(); + $ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'update', $item_ids, $error, $manifest_context, $details); 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)." addon_id=".$addon_id." status=success"); $is_error = false; - $message = 'All saved Workshop IDs updated successfully.'; + $message = 'All saved Workshop item(s) updated successfully. Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : ''); return true; } scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false); diff --git a/Panel/modules/addonsmanager/workshop_content.php b/Panel/modules/addonsmanager/workshop_content.php index 17aeb9d7..593ba7dd 100644 --- a/Panel/modules/addonsmanager/workshop_content.php +++ b/Panel/modules/addonsmanager/workshop_content.php @@ -3,7 +3,7 @@ * * GSP - Server Content Workshop page * - * Users enter Steam Workshop IDs to install on their server. + * Users enter Steam Workshop IDs or URLs to install on their server. * The admin defines the content template (game, app ID, install path). * */ @@ -38,6 +38,7 @@ function exec_ogp_module() { print_failure('Failed to initialize Workshop Content storage.'); return; } + scm_ensure_phase2_schema($db); // Load the admin content template if an addon_id was provided. $addon_template = null; @@ -88,6 +89,7 @@ function exec_ogp_module() { } echo "

"; } + echo "

Enter a Steam Workshop URL or numeric item ID. GSP stores only the numeric Workshop ID and uses the Server Content template for game-specific install behavior.

"; if ($message !== '') { if ($is_error) { @@ -114,10 +116,10 @@ function exec_ogp_module() { - + + + + - + + + + @@ -173,4 +181,3 @@ function exec_ogp_module() { resultQuery( - "SELECT p.`id` - FROM " . sw_monitor_table('steam_workshop_game_profiles') . " p - JOIN " . sw_monitor_table('config_homes') . " c ON c.`game_key` = p.`config_name` - JOIN " . sw_monitor_table('server_homes') . " s ON s.`home_cfg_id` = c.`home_cfg_id` - WHERE s.home_id = " . (int)$server_home['home_id'] . " - AND p.enabled = 1 - LIMIT 1" -); - -if (!empty($_sw_profile)) { - $module_buttons[] = " - - Steam Workshop - "; -} - -unset($_sw_profile); +// Deprecated: the standalone steam_workshop workflow is no longer the primary +// user path. Server Content Manager (addonsmanager) now owns Workshop installs +// and exposes its own monitor button when content templates exist. ?> diff --git a/docs/decisions/0004-workshop-system.md b/docs/decisions/0004-workshop-system.md index 38fba63c..3e4dfbc9 100644 --- a/docs/decisions/0004-workshop-system.md +++ b/docs/decisions/0004-workshop-system.md @@ -2,12 +2,14 @@ ## Status -Accepted +Accepted, Phase 1 implementation started ## Decision `Panel/modules/addonsmanager` should remain the primary future home for Workshop items, mods, add-ons, and server content. `steam_workshop` should remain a deprecated compatibility layer only. +Phase 1 implements this decision by routing the user-facing Workshop install flow through `addonsmanager/workshop_content.php` and suppressing the standalone `steam_workshop` monitor button. + ## Reasoning - `addonsmanager` already has the richer schema and more complete product direction. @@ -24,3 +26,10 @@ Accepted - `steam_workshop` is explicitly deprecated in the codebase. - Separate modules would fragment user workflows and duplicate install logic. +## Implementation Notes + +- Workshop input accepts numeric IDs or Steam URLs, then stores numeric IDs only. +- Manifests are written under the server home in `gsp_server_content`. +- Bundled Linux/Cygwin scripts are copied from the Panel module to an agent-managed folder under the server home before execution. +- DayZ/Arma-style installs default to `@` folders and copy `.bikey` files into `keys` when present. +- Startup parameter generation remains a later phase. diff --git a/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md b/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md new file mode 100644 index 00000000..3ad66b3c --- /dev/null +++ b/docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md @@ -0,0 +1,167 @@ +# Workshop Phase 1 Implementation + +## Summary + +Phase 1 makes the active Server Content Manager Workshop path usable for DayZ/Arma-style Workshop installs without reviving the deprecated standalone `steam_workshop` user workflow. + +Active workflow: + +`Game Monitor` -> `Server Content` -> `Steam Workshop Mods` + +## Files Changed + +- `Panel/modules/addonsmanager/server_content_helpers.php` +- `Panel/modules/addonsmanager/workshop_action.php` +- `Panel/modules/addonsmanager/workshop_content.php` +- `Panel/modules/addonsmanager/addons_manager.php` +- `Panel/modules/addonsmanager/module.php` +- `Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh` +- `Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh` +- `Panel/modules/steam_workshop/monitor_buttons.php` + +## User Flow + +1. Open a server from the Panel. +2. Click `Server Content`. +3. Open the `Steam Workshop Mods` category. +4. Paste one or more Steam Workshop URLs or numeric Workshop IDs. +5. Click `Install / Queue`. +6. The Panel validates input, stores numeric Workshop IDs, writes a manifest, syncs the install script, executes it through the agent, and shows the result. + +Accepted input examples: + +- `450814997` +- `https://steamcommunity.com/sharedfiles/filedetails/?id=450814997` + +Invalid text is rejected before reaching the manifest or shell script. + +## Admin Flow + +1. Create a Server Content template in `addonsmanager`. +2. Set content type to `Steam Workshop Mods`. +3. Configure `Workshop App ID` when the game XML/profile does not provide it. +4. Leave `Default Workshop IDs` blank for normal user-supplied installs. +5. Optionally configure a target path template. + +The admin template defines capability and policy. The customer supplies only Workshop item IDs or URLs. + +## Install Flow + +1. `workshop_content.php` receives the form post. +2. `workshop_action.php` parses URLs/IDs and records rows in `server_content_workshop`. +3. The Panel builds a manifest with per-item install details. +4. The manifest is written to: + - `{SERVER_HOME}/gsp_server_content/workshop_manifest.json` +5. The correct bundled script is copied to: + - `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_linux.sh` + - or `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh` +6. The agent runs: + - `bash
Workshop Item IDsWorkshop URLs / IDs - -
Enter one or more Steam Workshop IDs, one per line or comma-separated.
Example for Arma 3 CBA_A3: 450814997
+ +
Enter one or more Steam Workshop URLs or numeric IDs, one per line, comma-separated, or space-separated.
Example for Arma 3 CBA_A3: https://steamcommunity.com/sharedfiles/filedetails/?id=450814997
@@ -131,20 +133,26 @@ function exec_ogp_module() { Workshop ID TitleEnabledOrder StateInstall Path Last Installed Last Updated Last Error
No Workshop IDs saved for this server yet.
No Workshop items saved for this server yet.
'>