From c687165132826dba6d210e1948151f975f9bd562 Mon Sep 17 00:00:00 2001
From: Frank Harris
Date: Fri, 5 Jun 2026 13:31:15 -0500
Subject: [PATCH] codex steam workshop integrarion
---
.../modules/addonsmanager/addons_manager.php | 1 +
Panel/modules/addonsmanager/module.php | 34 +++-
.../workshop/generic_steam_workshop_linux.sh | 49 ++++-
.../generic_steam_workshop_windows_cygwin.sh | 49 ++++-
.../addonsmanager/server_content_helpers.php | 100 ++++++++++-
.../modules/addonsmanager/workshop_action.php | 168 ++++++++++++++----
.../addonsmanager/workshop_content.php | 19 +-
.../steam_workshop/monitor_buttons.php | 22 +--
docs/decisions/0004-workshop-system.md | 11 +-
.../WORKSHOP_PHASE1_IMPLEMENTATION.md | 167 +++++++++++++++++
docs/features/WORKSHOP_SYSTEM.md | 71 +++++++-
docs/modules/SERVER_CONTENT_MANAGER.md | 22 ++-
12 files changed, 629 insertions(+), 84 deletions(-)
create mode 100644 docs/features/WORKSHOP_PHASE1_IMPLEMENTATION.md
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() {
-
Workshop Item IDs
+
Workshop 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
Title
+
Enabled
+
Order
State
+
Install Path
Last Installed
Last Updated
Last Error
-
No Workshop IDs saved for this server yet.
+
No Workshop items saved for this server yet.
'>
+
+
+
@@ -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