woekshop phase 1

This commit is contained in:
Frank Harris 2026-06-09 06:13:44 -05:00
parent 17a31b7f5f
commit 5a03946bdf
15 changed files with 409 additions and 357 deletions

View file

@ -9,20 +9,16 @@ $(function() {
var methodToRows = {
download_zip: ['#scm-row-url', '#scm-row-path'],
steam_workshop: ['#scm-row-workshop-app-id', '#scm-row-target-path-template', '#scm-row-optional-folder-name', '#scm-row-post-script', '#scm-row-launch-param-additions'],
steam_workshop: ['#scm-row-workshop-xml-info'],
post_script: ['#scm-row-post-script'],
config_edit: ['#scm-row-path', '#scm-row-config-edit-rule']
};
var allRows = [
'#scm-row-url',
'#scm-row-path',
'#scm-row-workshop-id',
'#scm-row-workshop-app-id',
'#scm-row-target-path-template',
'#scm-row-optional-folder-name',
'#scm-row-workshop-xml-info',
'#scm-row-post-script',
'#scm-row-config-edit-rule',
'#scm-row-launch-param-additions'
'#scm-row-config-edit-rule'
];
var $method = $('#scm-install-method');
var $help = $('#scm-install-method-help');
@ -49,9 +45,6 @@ $(function() {
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() {
@ -67,20 +60,10 @@ $(function() {
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 workshopAppId = String($userPreview.data('workshopAppId') || '');
var folderName = workshopId ? '@' + workshopId : '@{WORKSHOP_ID}';
var targetTemplate = String($userPreview.data('targetTemplate') || $userPreview.text() || '');
var previewValues = {
'{SERVER_ROOT}': String($userPreview.data('serverRoot') || ''),
'{GAME_ROOT}': String($userPreview.data('gameRoot') || ''),
@ -96,15 +79,9 @@ $(function() {
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();
}
});

View file

@ -184,7 +184,7 @@ function exec_ogp_module() {
$content_version = isset($addon_info['content_version']) ? $addon_info['content_version'] : '';
$requires_stop = !empty($addon_info['requires_stop']) ? 1 : 0;
$user_override_keys = ($install_method === 'steam_workshop')
? array('workshop_item_id', 'workshop_app_id', 'target_path_template', 'optional_folder_name')
? array('workshop_item_id')
: array();
$install_payload = scm_collect_install_payload($addon_info, $_REQUEST, $user_override_keys);
$post_script = '';
@ -276,18 +276,23 @@ function exec_ogp_module() {
$cache_mode
);
$_SESSION['scm_history_id_' . $home_id . '_' . $addon_id] = $history_id;
scm_log_content_install_action(array(
'addon_id' => (int)$addon_id,
'addon_name' => isset($addon_info['name']) ? $addon_info['name'] : '',
'content_type' => $install_method,
'home_id' => (int)$home_id,
'home_cfg_id' => (int)$home_info['home_cfg_id'],
'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',
));
$log_target_path = (string)$install_payload['path'];
if ($install_method === 'steam_workshop') {
$log_target_path = scm_extract_workshop_install_path($server_xml);
if ($log_target_path === '') {
$log_target_path = scm_get_default_workshop_target_template(scm_detect_workshop_install_strategy($home_info, $server_xml));
}
}
scm_log_content_install_action(array(
'addon_id' => (int)$addon_id,
'addon_name' => isset($addon_info['name']) ? $addon_info['name'] : '',
'content_type' => $install_method,
'home_id' => (int)$home_id,
'home_cfg_id' => (int)$home_info['home_cfg_id'],
'workshop_id' => isset($install_payload['workshop_item_id']) ? (string)$install_payload['workshop_item_id'] : '',
'target_path' => $log_target_path,
'action' => 'started',
));
if ($install_method === 'steam_workshop') {
scm_ensure_workshop_schema($db);
$workshop_runtime = scm_build_workshop_runtime_context($db, $home_info, $server_xml, $install_payload, $validation_message);
@ -305,15 +310,14 @@ function exec_ogp_module() {
'addon_id' => (int)$addon_id,
'target_path_template' => $target_path_template,
'target_path_resolved' => $target_path_resolved,
'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']),
'launch_param_additions' => isset($server_xml->workshop_support->startup_param_format) ? trim((string)$server_xml->workshop_support->startup_param_format) : '',
'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),
'post_install_script' => scm_workshop_post_install_action($server_xml),
);
$workshop_error = '';
$workshop_ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', array($workshop_item_id), $workshop_error, $extra_manifest);
@ -581,14 +585,11 @@ function exec_ogp_module() {
$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']) : '';
$default_workshop_app_id = scm_extract_workshop_app_id($server_xml);
$default_target_template = scm_extract_workshop_install_path($server_xml);
if ($default_target_template === '') {
$default_target_template = scm_get_default_workshop_target_template(scm_detect_workshop_install_strategy($home_info, $server_xml));
}
?>
<h2><?php echo htmlentities($home_info['home_name'])."&nbsp;".$addon_type_lang ;?></h2>
<table class='center'>
@ -612,9 +613,6 @@ function exec_ogp_module() {
value="<?php echo (int)$addon['addon_id']; ?>"
data-install-method="<?php echo htmlspecialchars(scm_get_install_method_default(isset($addon['install_method']) ? $addon['install_method'] : ''), ENT_QUOTES, 'UTF-8'); ?>"
data-workshop-item-id="<?php echo htmlspecialchars(isset($addon['workshop_item_id']) ? $addon['workshop_item_id'] : '', ENT_QUOTES, 'UTF-8'); ?>"
data-workshop-app-id="<?php echo htmlspecialchars(isset($addon['workshop_app_id']) ? $addon['workshop_app_id'] : '', ENT_QUOTES, 'UTF-8'); ?>"
data-target-path-template="<?php echo htmlspecialchars(isset($addon['target_path_template']) ? $addon['target_path_template'] : '', ENT_QUOTES, 'UTF-8'); ?>"
data-optional-folder-name="<?php echo htmlspecialchars(isset($addon['optional_folder_name']) ? $addon['optional_folder_name'] : '', ENT_QUOTES, 'UTF-8'); ?>"
><?php echo htmlspecialchars($addon['name']); ?></option>
<?php } ?>
</select>
@ -623,22 +621,15 @@ function exec_ogp_module() {
<input type="text" id="scm-user-workshop-id" name="workshop_item_id" size="50" value="<?php echo htmlspecialchars(isset($selected_addon['workshop_item_id']) ? $selected_addon['workshop_item_id'] : '', ENT_QUOTES, 'UTF-8'); ?>" placeholder="Example Arma 3 Workshop ID: 450814997" />
<div class="info" style="margin-top:4px;">Install a Steam Workshop mod using Workshop ID. URL is not required.</div>
</td></tr>
<tr class="scm-user-workshop-row" <?php echo $is_workshop_default ? '' : 'style="display:none;"'; ?>><td align='right'><strong>Workshop App ID Override</strong></td><td align='left'>
<input type="text" id="scm-user-workshop-app-id" name="workshop_app_id" size="50" value="<?php echo htmlspecialchars($default_workshop_app_id, ENT_QUOTES, 'UTF-8'); ?>" data-default-app-id="<?php echo htmlspecialchars($default_workshop_app_id, ENT_QUOTES, 'UTF-8'); ?>" placeholder="Optional App ID override" />
</td></tr>
<tr class="scm-user-workshop-row" <?php echo $is_workshop_default ? '' : 'style="display:none;"'; ?>><td align='right'><strong><?php print_lang('optional_folder_name'); ?></strong></td><td align='left'>
<input type="text" id="scm-user-optional-folder-name" name="optional_folder_name" size="50" value="<?php echo htmlspecialchars($default_optional_folder_name, ENT_QUOTES, 'UTF-8'); ?>" placeholder="@myWorkshopMod (optional)" />
</td></tr>
<tr class="scm-user-workshop-row" <?php echo $is_workshop_default ? '' : 'style="display:none;"'; ?>><td align='right'><strong><?php print_lang('target_path_template'); ?></strong></td><td align='left'>
<input type="text" id="scm-user-target-path-template" name="target_path_template" size="85" value="<?php echo htmlspecialchars($default_target_template, ENT_QUOTES, 'UTF-8'); ?>" data-default-template="<?php echo htmlspecialchars($default_target_template, ENT_QUOTES, 'UTF-8'); ?>" placeholder="{SERVER_ROOT}/{MOD_FOLDER}" />
<div class="info" style="margin-top:4px;">Supported placeholders: {SERVER_ROOT}, {GAME_ROOT}, {WORKSHOP_ID}, {WORKSHOP_APP_ID}, {STEAM_APP_ID}, {FOLDER_NAME}, {MOD_FOLDER}</div>
</td></tr>
<tr class="scm-user-workshop-row" <?php echo $is_workshop_default ? '' : 'style="display:none;"'; ?>><td align='right'><strong>Target Path Preview</strong></td><td align='left'>
<code id="scm-user-target-path-preview"
data-server-root="<?php echo htmlspecialchars(rtrim((string)$home_info['home_path'], '/'), ENT_QUOTES, 'UTF-8'); ?>"
data-game-root="<?php echo htmlspecialchars(rtrim((string)$home_info['home_path'], '/'), ENT_QUOTES, 'UTF-8'); ?>"
data-steam-app-id="<?php echo htmlspecialchars((is_array($workshop_profile) && !empty($workshop_profile['steam_app_id'])) ? (string)$workshop_profile['steam_app_id'] : '', ENT_QUOTES, 'UTF-8'); ?>"
data-steam-app-id="<?php echo htmlspecialchars(scm_extract_workshop_steam_app_id($server_xml), ENT_QUOTES, 'UTF-8'); ?>"
data-workshop-app-id="<?php echo htmlspecialchars($default_workshop_app_id, ENT_QUOTES, 'UTF-8'); ?>"
data-target-template="<?php echo htmlspecialchars($default_target_template, ENT_QUOTES, 'UTF-8'); ?>"
><?php echo htmlspecialchars($default_target_template, ENT_QUOTES, 'UTF-8'); ?></code>
<div class="info" style="margin-top:4px;">Workshop App ID, install path, and launch parameter format are defined in the game XML.</div>
</td></tr>
<tr><td colspan='2' class='info'>&nbsp;</td></tr>
<td align='left'>

View file

@ -14,7 +14,7 @@
* 3. A script-driven installer (post_script only, no download required).
* 4. A Minecraft server jar / version switcher (future: install_method=minecraft_jar).
* 5. A DayZ/Epoch/Arma profile copy (future: install_method=profile_copy).
* 6. A Steam Workshop content bundle (future: install_method=steam_workshop).
* 6. A Steam Workshop entry point (install behavior comes from game XML).
* 7. A config preset (type=config).
* 8. A full server profile built from multiple actions (type=profile).
*
@ -26,6 +26,7 @@
// Central category map — defines all valid addon_type values and their labels.
require_once(dirname(__FILE__) . '/server_content_categories.php');
require_once(dirname(__FILE__) . '/server_content_helpers.php');
require_once("modules/config_games/server_config_parser.php");
function exec_ogp_module() {
@ -59,13 +60,19 @@ function exec_ogp_module() {
$fields['restart_after_install'] = !empty($_POST['restart_after_install']) ? 1 : 0;
$fields['is_cacheable'] = !empty($_POST['is_cacheable']) ? 1 : 0;
$fields['description'] = isset($_POST['description']) ? $_POST['description'] : '';
$fields['workshop_item_id'] = isset($_POST['workshop_item_id']) ? trim((string)$_POST['workshop_item_id']) : '';
$fields['workshop_app_id'] = isset($_POST['workshop_app_id']) ? trim((string)$_POST['workshop_app_id']) : '';
$fields['target_path_template']= isset($_POST['target_path_template']) ? trim((string)$_POST['target_path_template']) : '';
$fields['optional_folder_name']= isset($_POST['optional_folder_name']) ? trim((string)$_POST['optional_folder_name']) : '';
$fields['workshop_item_id'] = '';
$fields['workshop_app_id'] = '';
$fields['target_path_template']= '';
$fields['optional_folder_name']= '';
$fields['config_edit_rule'] = isset($_POST['config_edit_rule']) ? trim((string)$_POST['config_edit_rule']) : '';
$fields['launch_param_additions'] = isset($_POST['launch_param_additions']) ? trim((string)$_POST['launch_param_additions']) : '';
$fields['launch_param_additions'] = '';
$fields['addon_type'] = scm_get_addon_type_from_install_method($fields['install_method']);
if ($fields['install_method'] === 'steam_workshop') {
$fields['url'] = '';
$fields['path'] = '';
$fields['post_script'] = '';
$fields['config_edit_rule'] = '';
}
if ($fields['name'] === '')
{
@ -75,6 +82,10 @@ function exec_ogp_module() {
{
print_failure(get_lang("select_a_game_type"));
}
elseif ($fields['install_method'] === 'steam_workshop' && (!($game_cfg = $db->getGameCfg($fields['home_cfg_id'])) || !($server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $game_cfg['home_cfg_file'])) || !scm_workshop_is_supported($server_xml)))
{
print_failure('Steam Workshop support must be configured in the selected game XML before creating a Workshop content entry.');
}
else
{
$validation_payload = array(
@ -115,12 +126,7 @@ function exec_ogp_module() {
$restart_after_install = isset($_POST['restart_after_install']) ? (int)$_POST['restart_after_install'] : 0;
$is_cacheable = isset($_POST['is_cacheable']) ? (int)$_POST['is_cacheable'] : 0;
$description = isset($_POST['description']) ? $_POST['description'] : "";
$workshop_item_id = isset($_POST['workshop_item_id']) ? $_POST['workshop_item_id'] : "";
$workshop_app_id = isset($_POST['workshop_app_id']) ? $_POST['workshop_app_id'] : "";
$target_path_template = isset($_POST['target_path_template']) ? $_POST['target_path_template'] : "";
$optional_folder_name = isset($_POST['optional_folder_name']) ? $_POST['optional_folder_name'] : "";
$config_edit_rule = isset($_POST['config_edit_rule']) ? $_POST['config_edit_rule'] : "";
$launch_param_additions = isset($_POST['launch_param_additions']) ? $_POST['launch_param_additions'] : "";
if (isset($_POST['addon_id']) && (int)$_POST['addon_id'] > 0 && isset($_POST['edit']))
{
@ -143,12 +149,7 @@ function exec_ogp_module() {
$restart_after_install = isset($addon_info['restart_after_install']) ? (int)$addon_info['restart_after_install'] : 0;
$is_cacheable = isset($addon_info['is_cacheable']) ? (int)$addon_info['is_cacheable'] : 0;
$description = isset($addon_info['description']) ? $addon_info['description'] : "";
$workshop_item_id = isset($addon_info['workshop_item_id']) ? $addon_info['workshop_item_id'] : "";
$workshop_app_id = isset($addon_info['workshop_app_id']) ? $addon_info['workshop_app_id'] : "";
$target_path_template = isset($addon_info['target_path_template']) ? $addon_info['target_path_template'] : "";
$optional_folder_name = isset($addon_info['optional_folder_name']) ? $addon_info['optional_folder_name'] : "";
$config_edit_rule = isset($addon_info['config_edit_rule']) ? $addon_info['config_edit_rule'] : "";
$launch_param_additions = isset($addon_info['launch_param_additions']) ? $addon_info['launch_param_additions'] : "";
}
?>
<form action="" method="post">
@ -197,38 +198,12 @@ function exec_ogp_module() {
<input type="text" value="<?php echo $path; ?>" name="path" size="85" title="<?php print_lang('path_info'); ?>" />
</td>
</tr>
<tr id="scm-row-workshop-id">
<tr id="scm-row-workshop-xml-info">
<td align="right">
<b>Default Workshop IDs (Optional)</b>
<b>Steam Workshop</b>
</td>
<td align="left">
<input type="text" value="<?php echo htmlspecialchars($workshop_item_id, ENT_QUOTES, 'UTF-8'); ?>" name="workshop_item_id" size="85" placeholder="Leave blank users enter Workshop IDs on their server page" />
<small style="color:#666;">Optional. Users enter the actual Workshop IDs they want installed from their own server page. This field is not required.</small>
</td>
</tr>
<tr id="scm-row-workshop-app-id">
<td align="right">
<b>Game Compatibility (Workshop App ID)</b>
</td>
<td align="left">
<input type="text" value="<?php echo htmlspecialchars($workshop_app_id, ENT_QUOTES, 'UTF-8'); ?>" name="workshop_app_id" size="85" placeholder="Optional App ID override, e.g. 221100" />
</td>
</tr>
<tr id="scm-row-target-path-template">
<td align="right">
<b><?php print_lang('target_path_template'); ?></b>
</td>
<td align="left">
<input type="text" value="<?php echo htmlspecialchars($target_path_template, ENT_QUOTES, 'UTF-8'); ?>" name="target_path_template" size="85" placeholder="{SERVER_ROOT}/{MOD_FOLDER}" />
<small style="color:#666;">Supported placeholders: {HOME_ID}, {SERVER_ROOT}, {GAME_ROOT}, {WORKSHOP_ID}, {WORKSHOP_APP_ID}, {STEAM_APP_ID}, {FOLDER_NAME}, {MOD_FOLDER}</small>
</td>
</tr>
<tr id="scm-row-optional-folder-name">
<td align="right">
<b><?php print_lang('optional_folder_name'); ?></b>
</td>
<td align="left">
<input type="text" value="<?php echo htmlspecialchars($optional_folder_name, ENT_QUOTES, 'UTF-8'); ?>" name="optional_folder_name" size="85" placeholder="@MyWorkshopMod" />
<div class="info">Steam Workshop behavior is configured by the selected game's XML <code>workshop_support</code> block. Users install Workshop items from their server management page.</div>
</td>
</tr>
<tr id="scm-row-post-script">
@ -256,14 +231,6 @@ function exec_ogp_module() {
<textarea name="config_edit_rule" style="width:99%;height:90px;" placeholder="Text/rules to append or apply to the target config."><?php echo htmlspecialchars($config_edit_rule, ENT_QUOTES, 'UTF-8'); ?></textarea>
</td>
</tr>
<tr id="scm-row-launch-param-additions">
<td align="right">
<b><?php print_lang('launch_param_additions'); ?></b>
</td>
<td align="left">
<input type="text" value="<?php echo htmlspecialchars($launch_param_additions, ENT_QUOTES, 'UTF-8'); ?>" name="launch_param_additions" size="85" placeholder="-mod=@myMod;@anotherMod" />
</td>
</tr>
<tr>
<td align="right">
<b><?php print_lang('select_game_type'); ?></b>

View file

@ -153,9 +153,6 @@ function server_content_resolve_script_path(array $home_info, $script_key, array
if ($server_xml === false) {
return array(false, false);
}
if ($script_path === '' && $script_key === 'workshop') {
$script_path = scm_get_workshop_script_path($home_info, $server_xml);
}
if ($script_path === '' && $script_key !== '' && isset($server_xml->$script_key)) {
$script_path = trim((string)$server_xml->$script_key);
}
@ -172,10 +169,6 @@ function server_content_execute_manifest($home_id, $manifest_path, $script_key,
if ($server_xml === false) {
return server_content_result('failed', 'Unable to load server XML configuration.', array('home_id' => (int)$home_id));
}
$script_path = trim((string)$script_path);
if ($script_path === '' || !preg_match('/^[^\r\n\0]+$/', $script_path)) {
return server_content_result('failed', 'Configured server content script path is invalid.', array('script_key' => (string)$script_key));
}
$remote = server_content_create_remote($home_info);
if ($remote->status_chk() !== 1) {
return server_content_result('failed', 'Agent is offline.', array('remote_server_id' => (int)$home_info['remote_server_id']));
@ -188,8 +181,14 @@ function server_content_execute_manifest($home_id, $manifest_path, $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));
else {
$script_path = trim((string)$script_path);
if ($script_path === '' || !preg_match('/^[^\r\n\0]+$/', $script_path)) {
return server_content_result('failed', 'Configured server content script path is invalid.', array('script_key' => (string)$script_key));
}
if ((int)$remote->rfile_exists($script_path) !== 1) {
return server_content_result('failed', 'Server content script was not found on agent host.', array('script_path' => $script_path));
}
}
$command = "bash " . escapeshellarg($script_path) . " " . escapeshellarg((string)$manifest_path) . " ; echo __GSP_SERVER_CONTENT_EXIT:$?";
$output = $remote->exec($command);
@ -262,7 +261,7 @@ function server_content_install_updates($home_id, $options = array())
}
$manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $workshop_ids, array());
if (empty($manifest_context['workshop_app_id'])) {
return server_content_result('failed', 'Workshop App ID is missing. Configure it in game XML or the Server Content template.');
return server_content_result('failed', 'Workshop App ID is missing from the game XML workshop_support block.');
}
if (empty($options['check_only'])) {
scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installing', null, false, false);

View file

@ -105,12 +105,30 @@ function scm_ensure_workshop_schema($db)
`tags` TEXT NULL,
`game_key` VARCHAR(128) NULL,
`local_cache_path` VARCHAR(512) NULL,
`author` VARCHAR(255) NULL,
`thumbnail_url` VARCHAR(512) NULL,
UNIQUE KEY `uniq_workshop_app` (`workshop_id`, `app_id`),
KEY `idx_app_id` (`app_id`),
KEY `idx_install_count` (`install_count`),
KEY `idx_last_installed` (`last_installed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$catalog_table = $db->realEscapeSingle(OGP_DB_PREFIX . 'server_content_workshop_catalog');
foreach (array(
'author' => "VARCHAR(255) NULL AFTER `title`",
'thumbnail_url' => "VARCHAR(512) NULL AFTER `author`",
) 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 = '{$catalog_table}'
AND COLUMN_NAME = '{$escaped_col}'"
);
if (empty($col_check)) {
$db->query("ALTER TABLE `".OGP_DB_PREFIX."server_content_workshop_catalog` ADD COLUMN `{$col}` {$definition}");
}
}
return $ok;
}
@ -225,20 +243,37 @@ function scm_workshop_catalog_sort_sql($sort)
'published_date' => 'published_date DESC, title ASC, workshop_id ASC',
'last_updated' => 'last_updated DESC, title ASC, workshop_id ASC',
'last_installed' => 'last_installed DESC, title ASC, workshop_id ASC',
'workshop_id' => 'CAST(workshop_id AS UNSIGNED) ASC, workshop_id ASC',
);
return isset($allowed[$sort]) ? $allowed[$sort] : $allowed['last_installed'];
}
function scm_get_workshop_catalog_rows($db, $app_id = '', $sort = 'last_installed', $limit = 50)
function scm_get_workshop_catalog_rows($db, $app_id = '', $sort = 'last_installed', $limit = 50, $query = '', $tag = '')
{
if (!scm_ensure_workshop_schema($db)) {
return array();
}
$where = '';
$where_parts = array();
$app_id = trim((string)$app_id);
if ($app_id !== '' && preg_match('/^[0-9]+$/', $app_id)) {
$where = "WHERE app_id='" . $db->realEscapeSingle($app_id) . "'";
$where_parts[] = "app_id='" . $db->realEscapeSingle($app_id) . "'";
}
$query = trim((string)$query);
if ($query !== '') {
$query_id = scm_extract_workshop_item_id($query);
if ($query_id !== '') {
$where_parts[] = "workshop_id='" . $db->realEscapeSingle($query_id) . "'";
} else {
$like = "%" . $db->realEscapeSingle($query) . "%";
$where_parts[] = "(title LIKE '{$like}' OR author LIKE '{$like}' OR tags LIKE '{$like}' OR game_key LIKE '{$like}')";
}
}
$tag = trim((string)$tag);
if ($tag !== '') {
$like = "%" . $db->realEscapeSingle($tag) . "%";
$where_parts[] = "tags LIKE '{$like}'";
}
$where = empty($where_parts) ? '' : ('WHERE ' . implode(' AND ', $where_parts));
$limit = (int)$limit;
if ($limit <= 0 || $limit > 200) {
$limit = 50;
@ -266,14 +301,18 @@ function scm_workshop_record_catalog_items($db, $workshop_app_id, array $item_id
}
$detail = isset($item_details[$item_id]) && is_array($item_details[$item_id]) ? $item_details[$item_id] : array();
$title = isset($detail['title']) ? (string)$detail['title'] : '';
$author = isset($detail['author']) ? (string)$detail['author'] : '';
$thumbnail = isset($detail['thumbnail_url']) ? (string)$detail['thumbnail_url'] : '';
$install_path = isset($detail['target_path_resolved']) ? (string)$detail['target_path_resolved'] : '';
$db->query(
"INSERT INTO `".OGP_DB_PREFIX."server_content_workshop_catalog`
(workshop_id, app_id, title, install_count, first_seen, last_installed, last_updated, game_key, local_cache_path)
(workshop_id, app_id, title, author, thumbnail_url, install_count, first_seen, last_installed, last_updated, game_key, local_cache_path)
VALUES (
'".$db->realEscapeSingle($item_id)."',
'".$db->realEscapeSingle($workshop_app_id)."',
".($title === '' ? "NULL" : "'".$db->realEscapeSingle($title)."'").",
".($author === '' ? "NULL" : "'".$db->realEscapeSingle($author)."'").",
".($thumbnail === '' ? "NULL" : "'".$db->realEscapeSingle($thumbnail)."'").",
1,
NOW(),
NOW(),
@ -284,6 +323,8 @@ function scm_workshop_record_catalog_items($db, $workshop_app_id, array $item_id
ON DUPLICATE KEY UPDATE
install_count=install_count+1,
title=IF(VALUES(title) IS NULL OR VALUES(title)='', title, VALUES(title)),
author=IF(VALUES(author) IS NULL OR VALUES(author)='', author, VALUES(author)),
thumbnail_url=IF(VALUES(thumbnail_url) IS NULL OR VALUES(thumbnail_url)='', thumbnail_url, VALUES(thumbnail_url)),
last_installed=NOW(),
last_updated=".($mark_update ? "NOW()" : "last_updated").",
game_key=IF(VALUES(game_key) IS NULL OR VALUES(game_key)='', game_key, VALUES(game_key)),
@ -390,23 +431,20 @@ function scm_extract_workshop_app_id($server_xml)
return $value;
}
}
$candidates = array(
'workshop_app_id',
'workshop_appid',
'steam_workshop_app_id',
'steam_workshop_appid',
);
foreach ((array)$candidates as $candidate) {
if (isset($server_xml->$candidate)) {
$value = trim((string)$server_xml->$candidate);
if ($value !== '' && preg_match('/^[0-9]+$/', $value)) {
return $value;
}
}
}
return "";
}
function scm_workshop_is_supported($server_xml)
{
if (!isset($server_xml->workshop_support)) {
return false;
}
if (isset($server_xml->workshop_support->enabled) && !scm_workshop_xml_bool((string)$server_xml->workshop_support->enabled, true)) {
return false;
}
return scm_extract_workshop_app_id($server_xml) !== '';
}
function scm_extract_workshop_steam_app_id($server_xml)
{
if (isset($server_xml->workshop_support->steam_app_id)) {
@ -444,42 +482,15 @@ function scm_workshop_xml_bool($value, $default = false)
return (bool)$default;
}
function scm_get_workshop_script_path(array $home_info, $server_xml)
function scm_workshop_mod_prefix($server_xml)
{
$key = scm_is_windows_home($home_info) ? 'workshop_script_windows' : 'workshop_script_linux';
$nested_key = scm_is_windows_home($home_info) ? 'script_windows' : 'script_linux';
if (isset($server_xml->workshop_support->$nested_key)) {
$xml_path = trim((string)$server_xml->workshop_support->$nested_key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
if (isset($server_xml->workshop_support->mod_prefix)) {
$prefix = trim((string)$server_xml->workshop_support->mod_prefix);
if ($prefix !== '' && strpos($prefix, '/') === false && strpos($prefix, '\\') === false && strpos($prefix, "\0") === false) {
return $prefix;
}
}
if (isset($server_xml->$key)) {
$xml_path = trim((string)$server_xml->$key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
return scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT;
}
function scm_get_configured_workshop_script_path(array $home_info, $server_xml)
{
$key = scm_is_windows_home($home_info) ? 'workshop_script_windows' : 'workshop_script_linux';
$nested_key = scm_is_windows_home($home_info) ? 'script_windows' : 'script_linux';
if (isset($server_xml->workshop_support->$nested_key)) {
$xml_path = trim((string)$server_xml->workshop_support->$nested_key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
if (isset($server_xml->$key)) {
$xml_path = trim((string)$server_xml->$key);
if ($xml_path !== '' && preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return $xml_path;
}
}
return '';
return '@';
}
function scm_get_bundled_workshop_script_source(array $home_info)
@ -488,22 +499,6 @@ function scm_get_bundled_workshop_script_source(array $home_info)
return dirname(__FILE__) . '/scripts/workshop/' . $filename;
}
function scm_is_default_workshop_script_name($script_path)
{
$base = basename(trim((string)$script_path));
return in_array($base, array(SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT, SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT), true);
}
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']), '/');
@ -518,16 +513,6 @@ function scm_get_agent_managed_workshop_script_path(array $home_info)
function scm_prepare_workshop_script_for_agent($remote, array $home_info, $server_xml, &$error = '')
{
$error = '';
$configured_path = scm_get_configured_workshop_script_path($home_info, $server_xml);
if ($configured_path !== '' && !scm_is_default_workshop_script_name($configured_path) && !scm_is_legacy_panel_workshop_script_path($configured_path)) {
scm_log_content_install_action(array(
'type' => 'workshop_script_deprecated',
'home_id' => isset($home_info['home_id']) ? (int)$home_info['home_id'] : 0,
'configured_path' => $configured_path,
'message' => 'Configured static Workshop script ignored; Server Content generates a per-job script and runs it through the generic agent exec path.',
));
}
$source_path = scm_get_bundled_workshop_script_source($home_info);
if (!is_file($source_path)) {
$error = 'Panel Workshop job template is missing: ' . $source_path;
@ -649,7 +634,7 @@ function scm_get_install_method_help_text()
{
return array(
'download_zip' => 'Download and extract a ZIP, RAR, or archive file.',
'steam_workshop' => 'Configure how users may install Steam Workshop items for this game. Users enter the actual Workshop IDs from their server page.',
'steam_workshop' => 'Users install Steam Workshop items from their server page. App IDs, install paths, key-copy rules, and launch parameter format come from the game XML.',
'config_edit' => 'Install configuration files, profiles, or templates.',
'post_script' => 'Run a custom scripted installation process.',
);
@ -669,7 +654,7 @@ function scm_get_install_method_validation_errors()
{
return array(
'download_zip' => 'Please enter a download URL.',
'steam_workshop' => 'Please configure Workshop App ID or ensure the game XML provides one.',
'steam_workshop' => 'Workshop behavior is configured in the game XML.',
'config_edit' => 'Please enter the config target and edit action.',
'post_script' => 'Please enter the installer script/action.',
);
@ -749,16 +734,6 @@ function scm_validate_download_content(array $payload, &$message = '')
function scm_validate_workshop_content(array $payload, &$message = '')
{
// workshop_item_id is NOT required for admin content templates.
// Users supply Workshop IDs on their server page (workshop_content.php).
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;
}
@ -828,64 +803,21 @@ function scm_validate_install_method_payload($install_method, array $payload, &$
function scm_build_workshop_runtime_context($db, array $home_info, $server_xml, array $payload, &$message = '')
{
// workshop_item_id is now optional in admin templates; validate only the
// numeric format constraints (workshop_app_id, optional_folder_name).
if (!scm_validate_workshop_content($payload, $message)) {
return false;
}
$workshop_item_id = trim((string)(isset($payload['workshop_item_id']) ? $payload['workshop_item_id'] : ''));
$target_path_template = trim((string)(isset($payload['target_path_template']) ? $payload['target_path_template'] : ''));
$optional_folder_name = trim((string)(isset($payload['optional_folder_name']) ? $payload['optional_folder_name'] : ''));
$workshop_app_id_override = trim((string)(isset($payload['workshop_app_id']) ? $payload['workshop_app_id'] : ''));
$install_strategy = isset($payload['install_strategy']) ? trim((string)$payload['install_strategy']) : '';
$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'] : scm_extract_workshop_steam_app_id($server_xml);
$folder_name = ($optional_folder_name !== '') ? $optional_folder_name : '@' . $workshop_item_id;
$effective_template = $target_path_template;
if ($effective_template === '') {
if (is_array($fallback_profile) && !empty($fallback_profile['install_path_template'])) {
$effective_template = (string)$fallback_profile['install_path_template'];
} else {
$xml_install_path = scm_extract_workshop_install_path($server_xml);
$effective_template = $xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy);
}
}
$workshop_app_id = scm_extract_workshop_app_id($server_xml);
$steam_app_id = scm_extract_workshop_steam_app_id($server_xml);
$folder_prefix = scm_workshop_mod_prefix($server_xml);
$folder_name = $folder_prefix . $workshop_item_id;
$xml_install_path = scm_extract_workshop_install_path($server_xml);
$effective_template = $xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy);
$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,
'WORKSHOP_APP_ID' => $workshop_app_id,
'STEAM_APP_ID' => $steam_app_id,
'FOLDER_NAME' => $folder_name,
'MOD_FOLDER' => $folder_name,
@ -893,39 +825,25 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
$message = '';
return array(
'workshop_item_id' => $workshop_item_id,
'workshop_app_id' => $fallback_workshop_app_id,
'workshop_app_id' => $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'] : '',
'steamcmd_path' => '',
'workshop_download_dir' => '',
);
}
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);
}
}
if (isset($server_xml->workshop_support->install_strategy)) {
$strategy = trim((string)$server_xml->workshop_support->install_strategy);
if ($strategy !== '' && 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'] : ''));
@ -947,11 +865,6 @@ function scm_workshop_should_copy_keys($server_xml, $install_strategy)
return scm_workshop_xml_bool((string)$attrs['enabled'], false);
}
}
foreach (array('workshop_copy_keys', 'copy_workshop_keys') as $tag) {
if (isset($server_xml->$tag)) {
return scm_workshop_xml_bool((string)$server_xml->$tag, false);
}
}
return in_array((string)$install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true);
}
@ -968,6 +881,18 @@ function scm_workshop_keys_target_path($server_xml, array $home_info)
return scm_apply_placeholders($template, $map);
}
function scm_workshop_post_install_action($server_xml)
{
if (!isset($server_xml->workshop_support->post_install_action)) {
return '';
}
$action = trim((string)$server_xml->workshop_support->post_install_action);
if ($action === '' || strpos($action, "\0") !== false || strpos($action, "\r") !== false || strpos($action, "\n") !== false) {
return '';
}
return $action;
}
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);
@ -1006,6 +931,44 @@ function scm_apply_placeholders($template, array $placeholder_map)
return str_replace(array_keys($placeholder_map), array_values($placeholder_map), $template);
}
function scm_get_workshop_enabled_games($query = '', $tag = '')
{
$games = array();
$config_dir = defined('SERVER_CONFIG_LOCATION') ? SERVER_CONFIG_LOCATION : 'modules/config_games/server_configs/';
$schema = defined('XML_SCHEMA') ? XML_SCHEMA : 'modules/config_games/schema_server_config.xml';
$query = strtolower(trim((string)$query));
$tag = strtolower(trim((string)$tag));
foreach (glob(rtrim($config_dir, '/') . '/*.xml') ?: array() as $file) {
$xml = @simplexml_load_file($file);
if ($xml === false || !scm_workshop_is_supported($xml)) {
continue;
}
$game_key = isset($xml->game_key) ? (string)$xml->game_key : '';
$game_name = isset($xml->game_name) ? (string)$xml->game_name : basename($file, '.xml');
$app_id = scm_extract_workshop_app_id($xml);
$haystack = strtolower($game_key . ' ' . $game_name . ' ' . basename($file) . ' ' . $app_id);
if ($query !== '' && strpos($haystack, $query) === false) {
continue;
}
if ($tag !== '' && strpos($haystack, $tag) === false) {
continue;
}
$games[] = array(
'game_key' => $game_key,
'game_name' => $game_name,
'config_file' => basename($file),
'workshop_app_id' => $app_id,
'steam_app_id' => scm_extract_workshop_steam_app_id($xml),
'install_strategy' => isset($xml->workshop_support->install_strategy) ? (string)$xml->workshop_support->install_strategy : '',
'schema' => $schema,
);
}
usort($games, function ($a, $b) {
return strcasecmp($a['game_name'] . $a['config_file'], $b['game_name'] . $b['config_file']);
});
return $games;
}
function scm_content_logs_dir()
{
return dirname(__FILE__) . '/logs';

View file

@ -88,16 +88,11 @@ scm_workshop_test_assert(isset($windowsRemote->files[$script]), 'writes Windows/
scm_workshop_test_assert(strpos($windowsRemote->files[$script], '+runscript') !== false, 'Windows/Cygwin per-job script runs SteamCMD through runscript');
scm_workshop_test_assert($error === '', 'default Windows/Cygwin job staging does not report missing agent script');
$configuredXml = simplexml_load_string('<game_config><workshop_support><script_linux>/agent/custom/workshop.sh</script_linux></workshop_support></game_config>');
$configuredXml = simplexml_load_string('<game_config><workshop_support><enabled>1</enabled><workshop_app_id>107410</workshop_app_id></workshop_support></game_config>');
$customRemote = new ScmWorkshopFakeRemote();
$customRemote->existing[] = '/agent/custom/workshop.sh';
$script = scm_prepare_workshop_script_for_agent($customRemote, $linuxHome, $configuredXml, $error);
scm_workshop_test_assert(strpos($script, '/srv/games/arma3/gsp_server_content/jobs/workshop/workshop_job_') === 0, 'ignores configured static script and uses generated per-job script');
$missingCustomRemote = new ScmWorkshopFakeRemote();
$script = scm_prepare_workshop_script_for_agent($missingCustomRemote, $linuxHome, $configuredXml, $error);
scm_workshop_test_assert(strpos($script, '/srv/games/arma3/gsp_server_content/jobs/workshop/workshop_job_') === 0, 'missing custom static script still uses generated per-job script');
scm_workshop_test_assert($error === '', 'missing custom script does not expose script-not-found error');
scm_workshop_test_assert(strpos($script, '/srv/games/arma3/gsp_server_content/jobs/workshop/workshop_job_') === 0, 'XML Workshop support still stages generated per-job script');
scm_workshop_test_assert($error === '', 'generated per-job script does not expose script-not-found error');
@unlink(dirname(__DIR__) . '/logs/content_install.log');
@rmdir(dirname(__DIR__) . '/logs');
@ -119,6 +114,7 @@ scm_workshop_test_assert($runtime['target_path_resolved'] === '/srv/games/arma3/
$appXml = simplexml_load_string('<game_config><workshop_support><steam_app_id>107410</steam_app_id><workshop_app_id>107410</workshop_app_id><install_strategy>arma_mod_folder</install_strategy><install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path><copy_keys enabled="1"><target_path>{SERVER_ROOT}/keys</target_path></copy_keys></workshop_support></game_config>');
scm_workshop_test_assert(scm_extract_workshop_app_id($appXml) === '107410', 'extracts canonical Workshop app ID from game XML');
scm_workshop_test_assert(scm_extract_workshop_steam_app_id($appXml) === '107410', 'extracts canonical Steam app ID from game XML');
scm_workshop_test_assert(scm_workshop_is_supported($appXml) === true, 'canonical Workshop XML enables Workshop support');
scm_workshop_test_assert(scm_detect_workshop_install_strategy($linuxHome, $appXml) === 'arma_mod_folder', 'extracts canonical Workshop install strategy from game XML');
$runtime = scm_build_workshop_runtime_context(new stdClass(), $linuxHome, $appXml, array('workshop_item_id' => '450814997'), $message);
scm_workshop_test_assert($runtime['target_path_template'] === '{SERVER_ROOT}/{MOD_FOLDER}', 'canonical Workshop install_path controls target template');

View file

@ -18,6 +18,7 @@
// Central category map — load so we can iterate all types dynamically.
require_once(dirname(__FILE__) . '/server_content_categories.php');
require_once(dirname(__FILE__) . '/server_content_helpers.php');
require_once("modules/config_games/server_config_parser.php");
function exec_ogp_module() {
global $db;
@ -47,6 +48,8 @@ function exec_ogp_module() {
{
scm_ensure_workshop_schema($db);
$home_cfg_id = $home_info['home_cfg_id'];
$server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']);
$workshop_supported = ($server_xml !== false && scm_workshop_is_supported($server_xml));
echo "<h2>Server Content: ".htmlentities($home_info['home_name'])."</h2>\n".
"<table class='center' >\n".
"<tr>\n";
@ -68,6 +71,10 @@ function exec_ogp_module() {
"AND home_cfg_id=" . (int)$home_cfg_id . $query_groups
);
$items_qty = is_array($items) ? count((array)$items) : 0;
if ($type_key === 'workshop_item' && $workshop_supported && $items_qty < 1) {
$items = array(array('addon_id' => 0, 'name' => 'Steam Workshop', 'game_name' => isset($home_info['game_name']) ? $home_info['game_name'] : ''));
$items_qty = 1;
}
if ($items && $items_qty >= 1)
{
if ($printed_any_cell)
@ -85,7 +92,7 @@ function exec_ogp_module() {
($first_addon_id > 0 ? "&amp;addon_id=" . $first_addon_id : '') .
"&amp;ip=" . htmlspecialchars($ip) .
"&amp;port=" . htmlspecialchars($port) . "'>" .
htmlspecialchars($type_label) . " (" . $items_qty . ")" .
htmlspecialchars($type_label) . ($workshop_supported ? "" : " (" . $items_qty . ")") .
"</a>\n";
} else {
echo "<a href='?m=addonsmanager&amp;p=addons" .

View file

@ -75,8 +75,7 @@ function scm_workshop_get_content_template($db, $addon_id)
}
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
"SELECT addon_id, name, content_version, description
FROM `" . OGP_DB_PREFIX . "addons`
WHERE addon_id=" . $addon_id . " AND install_method='steam_workshop'
LIMIT 1"
@ -93,22 +92,12 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
$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;
$payload['install_strategy'] = $install_strategy;
// A fixed optional folder name is safe only when installing one item. For
// multi-item installs, use @<workshop_id> so items cannot overwrite each
// other by sharing the same target folder.
if (count($item_ids) !== 1) {
$payload['optional_folder_name'] = '';
}
$payload = array(
'workshop_item_id' => (string)$item_id,
'install_strategy' => $install_strategy,
);
$message = '';
$runtime = scm_build_workshop_runtime_context($db, $home_info, $server_xml, $payload, $message);
if ($runtime === false) {
@ -146,10 +135,10 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'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']) : ($xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy)),
'target_path_template' => $xml_install_path !== '' ? $xml_install_path : scm_get_default_workshop_target_template($install_strategy),
'keys_target_path' => $keys_target_path,
'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']) : '',
'post_install_script' => scm_workshop_post_install_action($server_xml),
'launch_param_additions' => isset($server_xml->workshop_support->startup_param_format) ? trim((string)$server_xml->workshop_support->startup_param_format) : '',
'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,
@ -261,6 +250,10 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
$message = 'Unable to read server configuration for workshop action.';
return false;
}
if (!scm_workshop_is_supported($server_xml)) {
$message = 'This game XML does not enable Steam Workshop support. Add a valid workshop_support block before installing Workshop items.';
return false;
}
$template = scm_workshop_get_content_template($db, $addon_id);
@ -279,7 +272,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
$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.';
$message = 'Workshop App ID is missing from the game XML workshop_support block.';
return false;
}

View file

@ -4,7 +4,7 @@
* GSP - Server Content Workshop page
*
* Users enter Steam Workshop IDs or URLs to install on their server.
* The admin defines the content template (game, app ID, install path).
* Game-specific Workshop behavior comes from the game XML workshop_support block.
*
*/
@ -39,12 +39,18 @@ function exec_ogp_module() {
return;
}
scm_ensure_phase2_schema($db);
$server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']);
if ($server_xml === false || !scm_workshop_is_supported($server_xml)) {
print_failure('Steam Workshop is not enabled for this game XML. Add a valid workshop_support block before using Workshop management.');
echo create_back_button("addonsmanager","user_addons");
return;
}
// Load the admin content template if an addon_id was provided.
// Template records are optional and used only as labels/history anchors.
$addon_template = null;
if ($addon_id > 0) {
$template_rows = $db->resultQuery(
"SELECT addon_id, name, workshop_app_id, target_path_template, optional_folder_name, description
"SELECT addon_id, name, description
FROM `" . OGP_DB_PREFIX . "addons`
WHERE addon_id=" . $addon_id . " AND install_method='steam_workshop'"
);
@ -57,6 +63,8 @@ function exec_ogp_module() {
$is_error = false;
$entered_ids = '';
$catalog_sort = isset($_REQUEST['catalog_sort']) ? (string)$_REQUEST['catalog_sort'] : 'last_installed';
$catalog_query = isset($_REQUEST['workshop_search']) ? trim((string)$_REQUEST['workshop_search']) : '';
$catalog_tag = isset($_REQUEST['workshop_tag']) ? trim((string)$_REQUEST['workshop_tag']) : '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$posted_home_id = isset($_POST['home_id']) ? (int)$_POST['home_id'] : 0;
@ -82,11 +90,17 @@ function exec_ogp_module() {
}
}
$rows = scm_get_workshop_rows($db, $home_id);
$server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']);
$catalog_app_id = ($server_xml !== false) ? scm_extract_workshop_app_id($server_xml) : '';
$catalog_rows = scm_get_workshop_catalog_rows($db, $catalog_app_id, $catalog_sort, 50);
$steam_app_id = ($server_xml !== false) ? scm_extract_workshop_steam_app_id($server_xml) : '';
$install_strategy = ($server_xml !== false) ? scm_detect_workshop_install_strategy($home_info, $server_xml) : '';
$rows = scm_get_workshop_rows($db, $home_id);
$catalog_rows = scm_get_workshop_catalog_rows($db, $catalog_app_id, $catalog_sort, 50, $catalog_query, $catalog_tag);
$game_search_rows = scm_get_workshop_enabled_games($catalog_query, $catalog_tag);
$csrf_token = scm_get_csrf_token();
$base_query = 'm=addonsmanager&p=workshop_content&home_id=' . (int)$home_id .
'&mod_id=' . (int)$mod_id . '&ip=' . urlencode($ip) . '&port=' . urlencode($port) .
'&addon_id=' . (int)$addon_id .
'&workshop_search=' . urlencode($catalog_query) . '&workshop_tag=' . urlencode($catalog_tag);
echo "<h2>Workshop Mods: " . scm_h($home_info['home_name']) . "</h2>";
if ($addon_template !== null) {
@ -96,7 +110,7 @@ function exec_ogp_module() {
}
echo "</p>";
}
echo "<p class='info'>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.</p>";
echo "<p class='info'>Enter a Steam Workshop URL or numeric item ID. GSP stores only the numeric Workshop ID. App IDs, install paths, mod folder strategy, key-copy behavior, and launch parameter format come from this game's XML.</p>";
if ($message !== '') {
if ($is_error) {
@ -109,8 +123,58 @@ function exec_ogp_module() {
<table class='center'>
<tr><td align='right'><strong>Server Name:</strong></td><td align='left'><?php echo scm_h($home_info['home_name']); ?></td></tr>
<tr><td align='right'><strong>Game Name:</strong></td><td align='left'><?php echo scm_h($home_info['game_name']); ?></td></tr>
<tr><td align='right'><strong>Workshop App ID:</strong></td><td align='left'><?php echo scm_h($catalog_app_id); ?></td></tr>
<tr><td align='right'><strong>Steam App ID:</strong></td><td align='left'><?php echo scm_h($steam_app_id); ?></td></tr>
<tr><td align='right'><strong>Install Strategy:</strong></td><td align='left'><?php echo scm_h($install_strategy); ?></td></tr>
</table>
<h3>Search Workshop</h3>
<form method='get' action=''>
<input type='hidden' name='m' value='addonsmanager' />
<input type='hidden' name='p' value='workshop_content' />
<input type='hidden' name='home_id' value='<?php echo (int)$home_id; ?>' />
<input type='hidden' name='mod_id' value='<?php echo (int)$mod_id; ?>' />
<input type='hidden' name='ip' value='<?php echo scm_h($ip); ?>' />
<input type='hidden' name='port' value='<?php echo scm_h($port); ?>' />
<input type='hidden' name='addon_id' value='<?php echo (int)$addon_id; ?>' />
<table class='center'>
<tr>
<td align='right'><strong>Keyword / ID / URL</strong></td>
<td align='left'><input type='text' name='workshop_search' size='42' value='<?php echo scm_h($catalog_query); ?>' placeholder='ACE, CBA, Zombies, Maps, or Workshop ID' /></td>
<td align='right'><strong>Tag</strong></td>
<td align='left'><input type='text' name='workshop_tag' size='24' value='<?php echo scm_h($catalog_tag); ?>' placeholder='Weapons, Missions, Maps' /></td>
<td><button type='submit'>Search</button></td>
</tr>
<?php if ($catalog_app_id !== '' && ($catalog_query !== '' || $catalog_tag !== '')): ?>
<tr>
<td></td>
<td colspan='4' class='info'>
<a target='_blank' rel='noopener' href='https://steamcommunity.com/workshop/browse/?appid=<?php echo scm_h($catalog_app_id); ?>&amp;searchtext=<?php echo scm_h(urlencode(trim($catalog_query . ' ' . $catalog_tag))); ?>'>Open matching Steam Workshop search</a>
</td>
</tr>
<?php endif; ?>
</table>
</form>
<?php if ($catalog_query !== '' || $catalog_tag !== ''): ?>
<h3>Workshop-Enabled Games</h3>
<table class='center'>
<tr><th>Game</th><th>Config</th><th>Workshop App ID</th><th>Strategy</th></tr>
<?php if (empty($game_search_rows)): ?>
<tr><td colspan='4' class='info'>No Workshop-enabled game XML files matched this search.</td></tr>
<?php else: ?>
<?php foreach ((array)$game_search_rows as $game_row): ?>
<tr>
<td><?php echo scm_h($game_row['game_name']); ?></td>
<td><?php echo scm_h($game_row['config_file']); ?></td>
<td><?php echo scm_h($game_row['workshop_app_id']); ?></td>
<td><?php echo scm_h($game_row['install_strategy']); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</table>
<?php endif; ?>
<form method='post' action=''>
<input type='hidden' name='m' value='addonsmanager' />
<input type='hidden' name='p' value='workshop_content' />
@ -191,27 +255,52 @@ function exec_ogp_module() {
</form>
<h3>Known Workshop Items</h3>
<p class='info'>These are Workshop items previously installed through Server Content Manager. Metadata is optional; direct ID or URL install remains available even when Steam metadata has not been fetched yet.</p>
<p class='info'>These are Workshop items previously installed through Server Content Manager. The catalog grows automatically from real installs. Metadata is optional; direct ID or URL install remains available even when Steam metadata has not been fetched yet.</p>
<table class='center'>
<tr>
<th>Workshop ID</th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=name'>Name</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=install_count'>Install Count</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=published_date'>Published</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=last_updated'>Last Updated</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=last_installed'>Last Installed</a></th>
<th><a href='?<?php echo scm_h($base_query); ?>&amp;catalog_sort=name'>Name</a></th>
<th>Author</th>
<th>Thumbnail</th>
<th><a href='?<?php echo scm_h($base_query); ?>&amp;catalog_sort=install_count'>Install Count</a></th>
<th><a href='?<?php echo scm_h($base_query); ?>&amp;catalog_sort=published_date'>Published</a></th>
<th><a href='?<?php echo scm_h($base_query); ?>&amp;catalog_sort=last_updated'>Last Updated</a></th>
<th><a href='?<?php echo scm_h($base_query); ?>&amp;catalog_sort=last_installed'>Last Installed</a></th>
<th><a href='?<?php echo scm_h($base_query); ?>&amp;catalog_sort=workshop_id'>Sort ID</a></th>
<th>Action</th>
</tr>
<?php if (empty($catalog_rows)): ?>
<tr><td colspan='6' class='info'>No known Workshop items have been installed for this app yet.</td></tr>
<tr><td colspan='10' class='info'>No known Workshop items have been installed for this app yet.</td></tr>
<?php else: ?>
<?php foreach ((array)$catalog_rows as $catalog_row): ?>
<tr>
<td><?php echo scm_h($catalog_row['workshop_id']); ?></td>
<td><?php echo scm_h($catalog_row['title']); ?></td>
<td><?php echo scm_h(isset($catalog_row['author']) ? $catalog_row['author'] : ''); ?></td>
<td>
<?php if (!empty($catalog_row['thumbnail_url'])): ?>
<img src='<?php echo scm_h($catalog_row['thumbnail_url']); ?>' alt='' style='max-width:72px;max-height:48px;' />
<?php endif; ?>
</td>
<td><?php echo scm_h($catalog_row['install_count']); ?></td>
<td><?php echo scm_h($catalog_row['published_date']); ?></td>
<td><?php echo scm_h($catalog_row['last_updated']); ?></td>
<td><?php echo scm_h($catalog_row['last_installed']); ?></td>
<td><?php echo scm_h($catalog_row['workshop_id']); ?></td>
<td>
<form method='post' action='' style='margin:0;'>
<input type='hidden' name='m' value='addonsmanager' />
<input type='hidden' name='p' value='workshop_content' />
<input type='hidden' name='home_id' value='<?php echo (int)$home_id; ?>' />
<input type='hidden' name='mod_id' value='<?php echo (int)$mod_id; ?>' />
<input type='hidden' name='ip' value='<?php echo scm_h($ip); ?>' />
<input type='hidden' name='port' value='<?php echo scm_h($port); ?>' />
<input type='hidden' name='addon_id' value='<?php echo (int)$addon_id; ?>' />
<input type='hidden' name='workshop_csrf' value='<?php echo scm_h($csrf_token); ?>' />
<input type='hidden' name='workshop_ids' value='<?php echo scm_h($catalog_row['workshop_id']); ?>' />
<button type='submit' name='workshop_action' value='install_new'>Install</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>

View file

@ -335,8 +335,7 @@
<xs:element name="mod_prefix" type="xs:string" minOccurs="0" />
<xs:element name="mod_folder_format" type="xs:string" minOccurs="0" />
<xs:element name="copy_keys" type="workshop_copy_keys_type" minOccurs="0" />
<xs:element name="script_linux" type="xs:string" minOccurs="0" />
<xs:element name="script_windows" type="xs:string" minOccurs="0" />
<xs:element name="post_install_action" type="xs:string" minOccurs="0" />
</xs:sequence>
</xs:complexType>

View file

@ -54,7 +54,7 @@ The agent executes the generated script with the manifest path by using the exis
Script/job rules:
1. Server Content Manager always generates the primary Workshop job script per action.
2. Old XML/static script settings are logged as deprecated and ignored by the primary path.
2. Game XML does not define static agent script paths.
3. The default script filename must never be treated as a pre-existing agent path.
4. The agent does not require `generic_steam_workshop_linux.sh` or `generic_steam_workshop_windows_cygwin.sh` to exist on disk.
@ -88,9 +88,9 @@ Default install paths:
App ID rules:
- `workshop_app_id` must come from a Server Content template, Steam Workshop profile, or game XML.
- Game XML should declare Workshop support in the canonical `workshop_support` block.
- Do not silently use the dedicated server Steam app ID as the Workshop app ID unless a legacy profile explicitly does so.
- `workshop_app_id` must come from the selected game's canonical `workshop_support` XML block.
- Server Content admin forms must not ask for Workshop app IDs, target paths, launch params, or Workshop script paths.
- Do not silently use the dedicated server Steam app ID as the Workshop app ID.
- Arma 3 XML declares Workshop app ID `107410`; its dedicated server Steam app ID remains `233780`.
Canonical XML:
@ -111,10 +111,11 @@ Canonical XML:
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
<post_install_action></post_install_action>
</workshop_support>
```
The Panel helper parser reads `workshop_support` first. Older direct tags are tolerated only as a compatibility fallback in helper code; they are not the canonical XML format.
The Panel helper parser reads `workshop_support` as the source of truth. New game XML must not use loose top-level Workshop tags, and the schema no longer accepts per-game static agent script tags such as `script_linux` or `script_windows`.
## Database State
@ -150,6 +151,8 @@ Current install states used by Phase 1:
- `workshop_id`
- `app_id`
- `title`
- `author`
- `thumbnail_url`
- `install_count`
- `first_seen`
- `last_installed`
@ -159,7 +162,7 @@ Current install states used by Phase 1:
- `game_key`
- `local_cache_path`
The catalog is Panel-side and does not require Steam Web API metadata. Metadata can be added later.
The catalog is Panel-side and does not require Steam Web API metadata. It grows from real installs and can be searched by Workshop ID, Steam URL, keyword/title, author, tag, or game key. Metadata can be enriched later through Steam Web API or SteamCMD output parsing.
## What Exists Today
@ -169,9 +172,9 @@ The current direction already supports:
- Workshop item IDs
- installation metadata
- install history tables
- game compatibility fields
- launch parameter additions
- post-install behavior fields
- XML-owned game compatibility fields
- XML-owned launch parameter format
- XML-owned post-install action placeholder
## Main Limitations
@ -181,6 +184,7 @@ The current direction already supports:
- update/remove are synchronous and should become background jobs.
- caching and cleanup policy need product-level design, not just ad hoc scripts.
- `-mod=` / `-serverMod=` generation still needs a safe structured implementation.
- Steam keyword/tag search currently searches the local Panel catalog and links to Steam's app-scoped Workshop search; direct Steam Web API search is Phase 2.
## Scheduler Integration
@ -214,8 +218,9 @@ Per-item update policy values stored on `server_content_workshop.update_policy`:
| Symptom | Meaning | Fix |
|---|---|---|
| `Configured workshop script not found on agent host: generic_steam_workshop_windows_cygwin.sh` | Old Panel logic treated the default script filename as an agent path. | Update the Panel. Current logic generates a per-job script under `gsp_server_content/jobs/workshop/`. |
| `SteamCMD is missing on the agent host.` | The handler could not find SteamCMD at the configured path, `STEAMCMD_PATH`, or common locations. | Install SteamCMD on the agent and/or set the SteamCMD path in the Workshop profile/template. |
| `Workshop App ID is missing` | No template/profile/XML provided an app ID. | Add `workshop_app_id` to the Server Content template or game XML. |
| `SteamCMD is missing on the agent host.` | The generated job could not find SteamCMD at `STEAMCMD_PATH` or common locations. | Install SteamCMD on the agent or set `STEAMCMD_PATH` for the agent environment. |
| `Workshop App ID is missing` | The selected game XML does not provide `workshop_support/workshop_app_id`. | Add a canonical `workshop_support` block to the game XML and validate it. |
| `This game XML does not enable Steam Workshop support` | The user opened Workshop for a game whose XML lacks enabled Workshop capability. | Add `workshop_support` with `enabled` and `workshop_app_id`, or do not expose Workshop for that game. |
| Download succeeds but mod does not load | Startup parameters are not yet regenerated from installed Workshop rows. | Manually add the installed `@...` folders to the game startup params until Phase 2 startup integration is complete. |
## Recommended Mental Model

View file

@ -94,7 +94,7 @@ XML definitions also feed:
## Workshop / Server Content Capability
Workshop-enabled games must use the canonical `workshop_support` block. Loose top-level tags such as `workshop_app_id` are compatibility parser fallbacks only and should not be used in new game XML because schema validation is intentionally strict.
Workshop-enabled games must use the canonical `workshop_support` block. The game XML is the source of truth for Steam Workshop capability and runtime behavior. Loose top-level tags such as `workshop_app_id` must not be used in new game XML because schema validation is intentionally strict.
The `workshop_support` block is a capability declaration only. It does not install mods by itself and it does not create an agent-side Workshop subsystem. Server Content Manager reads these values, writes a per-server manifest, writes a generated per-job script, and calls the agent's existing generic execution primitives.
@ -116,6 +116,7 @@ Example:
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
<post_install_action></post_install_action>
</workshop_support>
```
@ -132,10 +133,13 @@ Supported `install_strategy` values:
`workshop_app_id` is the Steam Workshop app ID used by `steamcmd +workshop_download_item`. It is not automatically the same as a dedicated server installer app ID. For Arma 3, Workshop content uses `107410` while the dedicated server installer remains defined on the normal mod installer entry.
`post_install_action` is reserved for a safe admin-owned post-install action identifier or template name. It is not customer input and must not be treated as an arbitrary shell command.
Ordering rule:
- `workshop_support` belongs after `game_name` and before `server_exec_name` in the current schema sequence.
- New XML files should not add top-level Workshop tags.
- New XML files should not define static agent script paths. Server Content Manager stages generated per-job scripts under the server home and invokes them through generic agent execution.
- If `install_path` is omitted, Server Content Manager defaults to `{SERVER_ROOT}/workshop/{MOD_FOLDER}` or `{SERVER_ROOT}/{MOD_FOLDER}` for DayZ/Arma strategies.
The current XML schema is validated by:

View file

@ -0,0 +1,53 @@
# Config Games
Workspace reference: [`GSP-WORKSPACE.md`](../../../GSP-WORKSPACE.md)
This file is the prompt-facing entry point for the Config Games module. The historical lower-case document remains at [`config_games.md`](config_games.md).
## Purpose
Config Games owns game XML definitions, XML schema validation, startup parameter templates, mod definitions, port metadata, and game capability declarations used by Panel modules.
## Workshop XML Source Of Truth
Steam Workshop support is configured by game XML, not Server Content admin rows.
Canonical structure:
```xml
<workshop_support>
<enabled>1</enabled>
<provider>steam</provider>
<steam_app_id>107410</steam_app_id>
<workshop_app_id>107410</workshop_app_id>
<download_method>steamcmd</download_method>
<install_strategy>arma_mod_folder</install_strategy>
<install_path>{SERVER_ROOT}/{MOD_FOLDER}</install_path>
<startup_param_format>-mod={MOD_LIST}</startup_param_format>
<mod_separator>;</mod_separator>
<mod_prefix>@</mod_prefix>
<copy_keys enabled="1">
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
<post_install_action></post_install_action>
</workshop_support>
```
Rules:
- `workshop_support` belongs after `game_name` and before `server_exec_name`.
- `workshop_app_id` is required for customer Workshop installs.
- `install_path` defaults to `{SERVER_ROOT}/workshop/{MOD_FOLDER}` unless the install strategy is Arma/DayZ-style, where `{SERVER_ROOT}/{MOD_FOLDER}` is used.
- `post_install_action` is an admin-owned action/template identifier, not customer shell input.
- Static Workshop script paths are not part of the XML contract.
- Server Content Manager generates per-job scripts and calls generic agent execution.
## Validation
Validate all game XML definitions with:
```bash
php Panel/modules/config_games/tests/validate_server_configs.php
```

View file

@ -42,24 +42,24 @@ For Workshop items, the current flow lets users enter Workshop IDs or full Steam
## Workshop Phase 1 Flow
1. Admin creates a Server Content template with install method `steam_workshop`.
2. Admin configures the Workshop App ID on the template or relies on the game XML/profile fallback.
1. Admin enables Workshop by adding a canonical `workshop_support` block to the game XML.
2. Server Content Manager detects that capability from the selected server's XML.
3. User opens `Server Content` from the game monitor.
4. User selects the Steam Workshop Mods category.
5. User enters one or more Workshop URLs or numeric IDs.
4. User selects the Steam Workshop Mods category. A Server Content template is no longer required just to expose Workshop for XML-enabled games.
5. User enters one or more Workshop URLs or numeric IDs, searches the local catalog, or opens Steam's app-scoped Workshop search from the page.
6. Panel parses IDs, rejects invalid entries, and records rows in `server_content_workshop`.
7. Panel writes a manifest to `{SERVER_HOME}/gsp_server_content/workshop_manifest.json`.
8. Panel writes a generated per-job shell script into `{SERVER_HOME}/gsp_server_content/jobs/workshop/`.
9. The generated job script creates a temporary SteamCMD runscript containing `workshop_download_item <appid> <workshop_id> validate`.
10. Agent executes the generated script with the manifest path through the existing authenticated `exec` RPC.
11. Script runs SteamCMD with `+runscript`, copies Workshop content into the configured target path, copies DayZ/Arma `.bikey` files when applicable, and writes a log under `gsp_server_content`.
11. Script runs SteamCMD with `+runscript`, copies Workshop content into the XML-configured target path, copies DayZ/Arma `.bikey` files when applicable, and writes a log under `gsp_server_content`.
The agents are intentionally generic executors in this design. New Workshop business logic should not be added to `Agent-Windows` or `Agent_Linux`; use `remote_writefile`, `exec`, log reads, and normal start/stop/restart primitives instead.
Current job-script behavior:
- Server Content Manager generates a new job script for each Workshop action.
- Admin-defined static Workshop script paths are deprecated and ignored by the primary path.
- Game XML does not define static Workshop script paths.
- Default script names such as `generic_steam_workshop_windows_cygwin.sh` must not be checked as bare files on the agent.
- Agents need only the generic `writefile` and `exec` primitives.
@ -68,7 +68,7 @@ Current default install paths:
- Generic Steam Workshop content: `{SERVER_ROOT}/workshop/{MOD_FOLDER}`
- DayZ / Arma strategy content: `{SERVER_ROOT}/{MOD_FOLDER}` for root `@<workshop_id>` folder compatibility
Game XML fallback should use the canonical `workshop_support` block:
Game XML must use the canonical `workshop_support` block:
```xml
<workshop_support>
@ -86,10 +86,11 @@ Game XML fallback should use the canonical `workshop_support` block:
<source_pattern>{MOD_PATH}/keys/*.bikey</source_pattern>
<target_path>{SERVER_ROOT}/keys</target_path>
</copy_keys>
<post_install_action></post_install_action>
</workshop_support>
```
The Panel helper parser reads this block first and only tolerates old direct tags as an internal compatibility fallback.
The Panel helper parser reads this block as the source of truth. Server Content admin forms do not collect Workshop app IDs, install paths, launch parameter additions, optional mod folder names, or static Workshop scripts.
SteamCMD requirements:
@ -104,6 +105,9 @@ The legacy `steam_workshop` monitor button is intentionally suppressed so users
The `workshop_content.php` page supports:
- direct install by numeric ID or Steam Workshop URL
- keyword/tag search against the local catalog
- app-scoped Steam Workshop search links for the selected game
- a list of Workshop-enabled games matching the current search terms
- installed item list
- enable/disable selected items
- update selected items
@ -111,7 +115,7 @@ The `workshop_content.php` page supports:
- download selected items without installing immediately
- update all saved Workshop items
- per-item update policy storage
- known/common item catalog sorted by name, install count, published date, last updated, or last installed
- known/common item catalog sorted by name, Workshop ID, install count, published date, last updated, or last installed
Update policies are stored as data for Scheduler/automation:
@ -131,6 +135,7 @@ Update policies are stored as data for Scheduler/automation:
- Install strategies are still being broadened and need consistent game-specific rules.
- DayZ/Arma style key-copy is implemented for Phase 1; startup-param behavior still needs a stronger canonical implementation.
- Cache and cleanup policy need a clearer product design.
- Direct Steam Web API keyword/tag search is not implemented yet; the current search page uses local catalog records and links out to Steam's Workshop search.
## Where To Start Reading

View file

@ -74,7 +74,9 @@ Key fields:
- `startup_param_format`
- `mod_separator`
- `mod_prefix`
- `mod_folder_format`
- `copy_keys`
- `post_install_action`
Validate changes with:
@ -85,7 +87,9 @@ php Panel/modules/config_games/tests/validate_server_configs.php
Important rules:
- Place `workshop_support` after `game_name` and before `server_exec_name`.
- Do not add loose top-level tags such as `workshop_app_id`; helper code may tolerate them for old configs, but schema-valid XML should use the canonical block.
- Do not add loose top-level tags such as `workshop_app_id`; schema-valid XML must use the canonical block.
- Do not add static Workshop script tags. Server Content Manager generates per-job scripts and uses generic agent execution.
- Do not configure Workshop App ID, target path, mod folder, or launch parameter fields in Server Content admin rows. These values come from this XML block.
- XML declares capability only. Server Content Manager owns the Panel-side install orchestration and uses agents only for generic file/command execution.
## Suggested Future Improvements