steam workshotp

This commit is contained in:
Frank Harris 2026-06-06 12:50:14 -05:00
parent d8c66c4c49
commit e921a49d5b
14 changed files with 285 additions and 26 deletions

View file

@ -215,8 +215,9 @@ Apply this as `$install_queries[2]` (db_version 3) in `module.php` when ready.
- [x] `addons_installer.php` and `user_addons.php` use category map for validation. - [x] `addons_installer.php` and `user_addons.php` use category map for validation.
- [x] Full TODO/comment blocks added to installer for next phase work. - [x] Full TODO/comment blocks added to installer for next phase work.
- [x] Module folder, table names, URL routes, function names unchanged. - [x] Module folder, table names, URL routes, function names unchanged.
- [x] Workshop Content Phase 1: manual Workshop ID entry, per-server manifest, - [x] Workshop Content Phase 1: manual Workshop ID/URL entry, per-server
agent script runner, install/update/remove actions (`db_version 3`). manifest, bundled Linux and Windows/Cygwin SteamCMD handlers, generic
handler fallback, install/update/remove actions (`db_version 3`).
### Phase 2 — Schema & install_method support ### Phase 2 — Schema & install_method support
- [x] Apply Phase 2 schema: `install_method`, `content_version`, `requires_stop`, - [x] Apply Phase 2 schema: `install_method`, `content_version`, `requires_stop`,

View file

@ -6,7 +6,8 @@ Phase 1 adds manual Workshop ID support inside the existing `addonsmanager` modu
- No Steam Workshop browser/search UI yet. - No Steam Workshop browser/search UI yet.
- No Steam scraping. - No Steam scraping.
- User enters comma-separated numeric Workshop IDs. - User enters Steam Workshop numeric IDs or full Workshop URLs.
- Input may be comma-separated, newline-separated, or whitespace-separated.
- Panel validates IDs, removes duplicates, and stores them per server home. - Panel validates IDs, removes duplicates, and stores them per server home.
- Panel lists saved IDs and supports: - Panel lists saved IDs and supports:
- Install New - Install New
@ -15,7 +16,14 @@ Phase 1 adds manual Workshop ID support inside the existing `addonsmanager` modu
- Update All - Update All
- Panel generates a per-server manifest at: - Panel generates a per-server manifest at:
- `%home_path%/gsp_server_content/workshop_manifest.json` - `%home_path%/gsp_server_content/workshop_manifest.json`
- Panel runs an approved script path (safe default or game-specific config), never user-supplied command/path. - Panel runs an approved handler only, never a user-supplied command/path.
- If a game does not define a custom Workshop script, the panel stages the
bundled generic handler for the agent OS:
- Linux: `generic_steam_workshop_linux.sh`
- Windows/Cygwin: `generic_steam_workshop_windows_cygwin.sh`
- If a custom configured script is missing on the agent, the panel falls back
to the bundled generic handler and logs a warning instead of failing with
"script not found."
## Security model ## Security model
@ -24,6 +32,9 @@ Phase 1 adds manual Workshop ID support inside the existing `addonsmanager` modu
- IDs must be numeric only. - IDs must be numeric only.
- Script path is not user-editable. - Script path is not user-editable.
- Manifest path is validated to remain under server home. - Manifest path is validated to remain under server home.
- Default generic installs stay under `{SERVER_ROOT}/workshop/@<workshop_id>`.
- Arma/DayZ-style strategies install under `{SERVER_ROOT}/@<workshop_id>` so
startup parameters can later use normal `-mod=@...` values.
- Remove is non-destructive in the generic scripts (preserve/move behavior for Phase 1). - Remove is non-destructive in the generic scripts (preserve/move behavior for Phase 1).
- All actions are logged through panel logging. - All actions are logged through panel logging.
@ -40,11 +51,21 @@ and keeps `OGP_DB_PREFIXaddons.addon_type` at `VARCHAR(32)` so `workshop` is val
Each game should define and document: Each game should define and document:
- `workshop_app_id` - `workshop_app_id`
- Linux workshop script path - optional Linux workshop script path only when the bundled generic handler is
- Windows/Cygwin workshop script path not sufficient
- optional Windows/Cygwin workshop script path only when the bundled generic
handler is not sufficient
- target install location - target install location
- restart/update behavior - restart/update behavior
If `workshop_app_id` is not defined in the content profile, server content
template, or game XML, Phase 1 returns a clear error rather than guessing.
Known Phase 1 app IDs:
- Arma 3 Workshop app ID: `107410`
- Arma 3 dedicated server installer app ID remains `233780`
## Phase 2 (not included here) ## Phase 2 (not included here)
- Workshop browsing/search/select UI - Workshop browsing/search/select UI

View file

@ -176,7 +176,7 @@ try:
'FOLDER_NAME': folder_name, 'FOLDER_NAME': folder_name,
'MOD_FOLDER': folder_name, 'MOD_FOLDER': folder_name,
} }
target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}') target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/workshop/{MOD_FOLDER}')
target_path = str(detail.get('target_path_resolved') or extra.get('target_path_resolved') or '').strip() target_path = str(detail.get('target_path_resolved') or extra.get('target_path_resolved') or '').strip()
if len(items) != 1 or not target_path: if len(items) != 1 or not target_path:
target_path = render_template(target_template, template_values) target_path = render_template(target_template, template_values)

View file

@ -177,7 +177,7 @@ try:
'FOLDER_NAME': folder_name, 'FOLDER_NAME': folder_name,
'MOD_FOLDER': folder_name, 'MOD_FOLDER': folder_name,
} }
target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}') target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/workshop/{MOD_FOLDER}')
target_path = str(detail.get('target_path_resolved') or extra.get('target_path_resolved') or '').strip() target_path = str(detail.get('target_path_resolved') or extra.get('target_path_resolved') or '').strip()
if len(items) != 1 or not target_path: if len(items) != 1 or not target_path:
target_path = render_template(target_template, template_values) target_path = render_template(target_template, template_values)

View file

@ -11,6 +11,12 @@ if (!defined('SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT')) {
if (!defined('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT')) { if (!defined('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT')) {
define('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT', 'generic_steam_workshop_windows_cygwin.sh'); define('SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT', 'generic_steam_workshop_windows_cygwin.sh');
} }
if (!defined('SCM_WORKSHOP_TARGET_GENERIC_DEFAULT')) {
define('SCM_WORKSHOP_TARGET_GENERIC_DEFAULT', '{SERVER_ROOT}/workshop/{MOD_FOLDER}');
}
if (!defined('SCM_WORKSHOP_TARGET_MOD_ROOT_DEFAULT')) {
define('SCM_WORKSHOP_TARGET_MOD_ROOT_DEFAULT', '{SERVER_ROOT}/{MOD_FOLDER}');
}
function scm_ensure_workshop_schema($db) function scm_ensure_workshop_schema($db)
{ {
@ -246,12 +252,31 @@ function scm_get_workshop_script_path(array $home_info, $server_xml)
return scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT; 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';
if (!isset($server_xml->$key)) {
return '';
}
$xml_path = trim((string)$server_xml->$key);
if ($xml_path === '' || !preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return '';
}
return $xml_path;
}
function scm_get_bundled_workshop_script_source(array $home_info) function scm_get_bundled_workshop_script_source(array $home_info)
{ {
$filename = scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT; $filename = scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT;
return dirname(__FILE__) . '/scripts/workshop/' . $filename; 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) function scm_is_legacy_panel_workshop_script_path($script_path)
{ {
$script_path = trim((string)$script_path); $script_path = trim((string)$script_path);
@ -276,13 +301,17 @@ 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 = '') function scm_prepare_workshop_script_for_agent($remote, array $home_info, $server_xml, &$error = '')
{ {
$error = ''; $error = '';
$configured_path = trim((string)scm_get_workshop_script_path($home_info, $server_xml)); $configured_path = scm_get_configured_workshop_script_path($home_info, $server_xml);
if ($configured_path !== '' && !scm_is_legacy_panel_workshop_script_path($configured_path) && preg_match('/^[^\\r\\n\\0]+$/', $configured_path)) { if ($configured_path !== '' && !scm_is_default_workshop_script_name($configured_path) && !scm_is_legacy_panel_workshop_script_path($configured_path)) {
if ((int)$remote->rfile_exists($configured_path) === 1) { if ((int)$remote->rfile_exists($configured_path) === 1) {
return $configured_path; return $configured_path;
} }
$error = 'Configured workshop script not found on agent host: ' . $configured_path; scm_log_content_install_action(array(
return false; 'type' => 'workshop_script_fallback',
'home_id' => isset($home_info['home_id']) ? (int)$home_info['home_id'] : 0,
'configured_path' => $configured_path,
'message' => 'Configured workshop script was not found on the agent; falling back to bundled generic handler.',
));
} }
$source_path = scm_get_bundled_workshop_script_source($home_info); $source_path = scm_get_bundled_workshop_script_source($home_info);
@ -313,6 +342,15 @@ function scm_prepare_workshop_script_for_agent($remote, array $home_info, $serve
return $remote_path; return $remote_path;
} }
function scm_get_default_workshop_target_template($install_strategy = '')
{
$install_strategy = strtolower(trim((string)$install_strategy));
if (in_array($install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true)) {
return SCM_WORKSHOP_TARGET_MOD_ROOT_DEFAULT;
}
return SCM_WORKSHOP_TARGET_GENERIC_DEFAULT;
}
function scm_get_csrf_token() function scm_get_csrf_token()
{ {
if (empty($_SESSION['addonsmanager_workshop_csrf'])) { if (empty($_SESSION['addonsmanager_workshop_csrf'])) {
@ -583,9 +621,10 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
} }
$workshop_item_id = trim((string)(isset($payload['workshop_item_id']) ? $payload['workshop_item_id'] : '')); $workshop_item_id = trim((string)(isset($payload['workshop_item_id']) ? $payload['workshop_item_id'] : ''));
$target_path_template = trim((string)$payload['target_path_template']); $target_path_template = trim((string)(isset($payload['target_path_template']) ? $payload['target_path_template'] : ''));
$optional_folder_name = trim((string)$payload['optional_folder_name']); $optional_folder_name = trim((string)(isset($payload['optional_folder_name']) ? $payload['optional_folder_name'] : ''));
$workshop_app_id_override = trim((string)$payload['workshop_app_id']); $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; $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') $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( ? steam_workshop_install_item_to_home($db, $home_info, $workshop_item_id, $target_path_template, array(
@ -625,7 +664,7 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
if ($effective_template === '') { if ($effective_template === '') {
$effective_template = (is_array($fallback_profile) && !empty($fallback_profile['install_path_template'])) $effective_template = (is_array($fallback_profile) && !empty($fallback_profile['install_path_template']))
? (string)$fallback_profile['install_path_template'] ? (string)$fallback_profile['install_path_template']
: '{SERVER_ROOT}/{MOD_FOLDER}'; : 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( $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_ID' => $workshop_item_id,

View file

@ -0,0 +1,116 @@
<?php
if (!function_exists('clean_path')) {
function clean_path($path)
{
$path = str_replace('\\', '/', (string)$path);
$path = preg_replace('#/+#', '/', $path);
return rtrim($path, '/');
}
}
require_once(dirname(__DIR__) . '/server_content_helpers.php');
class ScmWorkshopFakeRemote
{
public $files = array();
public $commands = array();
public $existing = array();
public function rfile_exists($path)
{
return in_array($path, $this->existing, true) || isset($this->files[$path]) ? 1 : 0;
}
public function remote_writefile($path, $contents)
{
$this->files[$path] = $contents;
return 1;
}
public function exec($command)
{
$this->commands[] = $command;
return '';
}
}
function scm_workshop_test_assert($condition, $message)
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
echo "PASS: {$message}\n";
}
$invalid = array();
$ids = scm_parse_workshop_ids("https://steamcommunity.com/sharedfiles/filedetails/?id=450814997\n450814997, 463939057 463939057", $invalid);
scm_workshop_test_assert($ids === array('450814997', '463939057'), 'parses URLs, whitespace, commas, and duplicates');
scm_workshop_test_assert(empty($invalid), 'valid mixed Workshop input has no invalid entries');
$ids = scm_parse_workshop_ids("abc, https://steamcommunity.com/sharedfiles/filedetails/?id=0, 123", $invalid);
scm_workshop_test_assert($ids === array('123'), 'keeps valid IDs when invalid values are present');
scm_workshop_test_assert(count($invalid) === 2, 'reports invalid Workshop entries');
$linuxHome = array(
'home_id' => 10,
'home_path' => '/srv/games/arma3',
'home_cfg_file' => 'arma3_linux64.xml',
'game_key' => 'arma3_linux64',
'game_name' => 'Arma 3',
);
$windowsHome = array(
'home_id' => 11,
'home_path' => '/cygdrive/c/OGP_User_Files/11',
'home_cfg_file' => 'arma3_win64.xml',
'game_key' => 'arma3_win64',
'game_name' => 'Arma 3',
);
$emptyXml = simplexml_load_string('<game_config></game_config>');
$linuxRemote = new ScmWorkshopFakeRemote();
$error = '';
$script = scm_prepare_workshop_script_for_agent($linuxRemote, $linuxHome, $emptyXml, $error);
scm_workshop_test_assert($script === '/srv/games/arma3/gsp_server_content/scripts/workshop/generic_steam_workshop_linux.sh', 'stages default Linux script under server home');
scm_workshop_test_assert(isset($linuxRemote->files[$script]), 'writes Linux bundled script to fake agent');
scm_workshop_test_assert($error === '', 'default Linux script staging does not report missing script');
$windowsRemote = new ScmWorkshopFakeRemote();
$script = scm_prepare_workshop_script_for_agent($windowsRemote, $windowsHome, $emptyXml, $error);
scm_workshop_test_assert($script === '/cygdrive/c/OGP_User_Files/11/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh', 'stages default Windows/Cygwin script under server home');
scm_workshop_test_assert(isset($windowsRemote->files[$script]), 'writes Windows/Cygwin bundled script to fake agent');
scm_workshop_test_assert($error === '', 'default Windows/Cygwin script staging does not report missing script');
$configuredXml = simplexml_load_string('<game_config><workshop_script_linux>/agent/custom/workshop.sh</workshop_script_linux></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($script === '/agent/custom/workshop.sh', 'uses existing configured custom script');
$missingCustomRemote = new ScmWorkshopFakeRemote();
$script = scm_prepare_workshop_script_for_agent($missingCustomRemote, $linuxHome, $configuredXml, $error);
scm_workshop_test_assert($script === '/srv/games/arma3/gsp_server_content/scripts/workshop/generic_steam_workshop_linux.sh', 'falls back to bundled script when configured custom script is missing');
scm_workshop_test_assert($error === '', 'missing custom script fallback does not expose script-not-found error');
@unlink(dirname(__DIR__) . '/logs/content_install.log');
@rmdir(dirname(__DIR__) . '/logs');
$genericHome = array(
'home_id' => 12,
'home_path' => '/srv/games/generic',
'home_cfg_file' => 'generic_linux.xml',
'game_key' => 'generic_linux',
'game_name' => 'Generic Game',
);
$runtime = scm_build_workshop_runtime_context(new stdClass(), $genericHome, $emptyXml, array('workshop_item_id' => '12345'), $message);
scm_workshop_test_assert($runtime['target_path_template'] === '{SERVER_ROOT}/workshop/{MOD_FOLDER}', 'generic fallback target path uses server_root/workshop');
scm_workshop_test_assert($runtime['target_path_resolved'] === '/srv/games/generic/workshop/@12345', 'generic fallback target path resolves under workshop folder');
$runtime = scm_build_workshop_runtime_context(new stdClass(), $linuxHome, $emptyXml, array('workshop_item_id' => '450814997', 'install_strategy' => 'arma_mod_folder'), $message);
scm_workshop_test_assert($runtime['target_path_template'] === '{SERVER_ROOT}/{MOD_FOLDER}', 'Arma strategy keeps @mod folder at server root');
scm_workshop_test_assert($runtime['target_path_resolved'] === '/srv/games/arma3/@450814997', 'Arma target path resolves to root @WorkshopID folder');
$appXml = simplexml_load_string('<game_config><workshop_app_id>107410</workshop_app_id></game_config>');
scm_workshop_test_assert(scm_extract_workshop_app_id($appXml) === '107410', 'extracts explicit Workshop app ID from game XML');
echo "All Workshop helper smoke tests passed.\n";

View file

@ -100,6 +100,7 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
foreach ($item_ids as $item_id) { foreach ($item_ids as $item_id) {
$payload = $template_payload; $payload = $template_payload;
$payload['workshop_item_id'] = (string)$item_id; $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 // A fixed optional folder name is safe only when installing one item. For
// multi-item installs, use @<workshop_id> so items cannot overwrite each // multi-item installs, use @<workshop_id> so items cannot overwrite each
// other by sharing the same target folder. // other by sharing the same target folder.
@ -122,7 +123,7 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'workshop_item_id' => (string)$item_id, 'workshop_item_id' => (string)$item_id,
'title' => '', 'title' => '',
'folder_name' => isset($runtime['folder_name']) && $runtime['folder_name'] !== '' ? (string)$runtime['folder_name'] : '@' . $item_id, '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_template' => isset($runtime['target_path_template']) ? (string)$runtime['target_path_template'] : scm_get_default_workshop_target_template($install_strategy),
'target_path_resolved' => isset($runtime['target_path_resolved']) ? (string)$runtime['target_path_resolved'] : '', 'target_path_resolved' => isset($runtime['target_path_resolved']) ? (string)$runtime['target_path_resolved'] : '',
'install_strategy' => $install_strategy, 'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0, 'copy_keys' => $copy_keys ? 1 : 0,
@ -140,7 +141,7 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'server_root' => rtrim((string)$home_info['home_path'], '/'), 'server_root' => rtrim((string)$home_info['home_path'], '/'),
'install_strategy' => $install_strategy, 'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0, '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}', 'target_path_template' => isset($template['target_path_template']) && trim((string)$template['target_path_template']) !== '' ? trim((string)$template['target_path_template']) : scm_get_default_workshop_target_template($install_strategy),
'post_install_script' => isset($template['post_script']) ? trim((string)$template['post_script']) : '', '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']) : '', '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_id' => isset($template['addon_id']) ? (int)$template['addon_id'] : 0,
@ -183,7 +184,7 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml,
'items' => array_values($item_ids), 'items' => array_values($item_ids),
'item_details' => !empty($extra_manifest['item_details']) && is_array($extra_manifest['item_details']) ? $extra_manifest['item_details'] : array(), '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'] : '', '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}', 'target_path' => !empty($extra_manifest['target_path_template']) ? (string)$extra_manifest['target_path_template'] : scm_get_default_workshop_target_template(!empty($extra_manifest['install_strategy']) ? (string)$extra_manifest['install_strategy'] : ''),
'generated_at' => date('Y-m-d H:i:s'), 'generated_at' => date('Y-m-d H:i:s'),
); );
if (!empty($extra_manifest)) { if (!empty($extra_manifest)) {

View file

@ -2,6 +2,7 @@
<game_key>arma3_linux64</game_key> <game_key>arma3_linux64</game_key>
<installer>steamcmd</installer> <installer>steamcmd</installer>
<game_name>Arma 3</game_name> <game_name>Arma 3</game_name>
<workshop_app_id>107410</workshop_app_id>
<server_exec_name>arma3server_x64</server_exec_name> <server_exec_name>arma3server_x64</server_exec_name>
<cli_template>%CONFIG% %CFG% %PROFILES% %NAME% %IP% %PORT% %PLAYERS% %MODLIST% %SERVERMODLIST% %AUTOINIT%</cli_template> <cli_template>%CONFIG% %CFG% %PROFILES% %NAME% %IP% %PORT% %PLAYERS% %MODLIST% %SERVERMODLIST% %AUTOINIT%</cli_template>
<cli_params> <cli_params>

View file

@ -2,6 +2,7 @@
<game_key>arma3_win64</game_key> <game_key>arma3_win64</game_key>
<installer>steamcmd</installer> <installer>steamcmd</installer>
<game_name>Arma 3</game_name> <game_name>Arma 3</game_name>
<workshop_app_id>107410</workshop_app_id>
<server_exec_name>arma3server.exe</server_exec_name> <server_exec_name>arma3server.exe</server_exec_name>
<cli_template>-profiles=profile -name=player -config=profile\server.cfg -cfg=profile\basic.cfg %PORT% %PLAYERS% %RANKING% %AUTOINIT% %DEBUG% %MODS% %SERVERMODS%</cli_template> <cli_template>-profiles=profile -name=player -config=profile\server.cfg -cfg=profile\basic.cfg %PORT% %PLAYERS% %RANKING% %AUTOINIT% %DEBUG% %MODS% %SERVERMODS%</cli_template>
<cli_params> <cli_params>

View file

@ -518,22 +518,37 @@ function sw_detect_profile_defaults_from_xml($configName)
} }
} }
$xmlBlob = $matched->asXML();
$workshopAppId = ''; $workshopAppId = '';
if ($xmlBlob !== false && preg_match('/steamapps\/workshop\/content\/(\d+)/i', $xmlBlob, $m)) { foreach (array('workshop_app_id', 'workshop_appid', 'steam_workshop_app_id', 'steam_workshop_appid') as $tag) {
if (isset($matched->$tag)) {
$candidate = trim((string)$matched->$tag);
if ($candidate !== '' && preg_match('/^\d+$/', $candidate)) {
$workshopAppId = $candidate;
break;
}
}
}
$xmlBlob = $matched->asXML();
if ($workshopAppId === '' && $xmlBlob !== false && preg_match('/steamapps\/workshop\/content\/(\d+)/i', $xmlBlob, $m)) {
$workshopAppId = $m[1]; $workshopAppId = $m[1];
} }
if ($workshopAppId === '') { if ($workshopAppId === '') {
$workshopAppId = $steamAppId; $workshopAppId = $steamAppId;
} }
$gameKey = strtolower(trim((string)$matched->game_key));
$gameName = strtolower(trim((string)$matched->game_name));
$installPathTemplate = (strpos($gameKey . ' ' . $gameName, 'arma') !== false || strpos($gameKey . ' ' . $gameName, 'dayz') !== false)
? '{SERVER_ROOT}/{MOD_FOLDER}'
: '{SERVER_ROOT}/workshop/{MOD_FOLDER}';
return array( return array(
'steam_app_id' => $steamAppId, 'steam_app_id' => $steamAppId,
'workshop_app_id' => $workshopAppId, 'workshop_app_id' => $workshopAppId,
'steamcmd_path' => '/home/gameserver/steamcmd/steamcmd.sh', 'steamcmd_path' => '/home/gameserver/steamcmd/steamcmd.sh',
'server_root_template' => '{SERVER_ROOT}', 'server_root_template' => '{SERVER_ROOT}',
'workshop_download_dir_template' => '{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}', 'workshop_download_dir_template' => '{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}',
'install_path_template' => '{SERVER_ROOT}/{MOD_FOLDER}', 'install_path_template' => $installPathTemplate,
); );
} }
@ -608,7 +623,7 @@ function steam_workshop_resolve_paths($db, array $home_info, $workshop_id, $targ
); );
$target_template = trim((string)$target_path_template); $target_template = trim((string)$target_path_template);
if ($target_template === '') { if ($target_template === '') {
$target_template = !empty($profile['install_path_template']) ? (string)$profile['install_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}'; $target_template = !empty($profile['install_path_template']) ? (string)$profile['install_path_template'] : '{SERVER_ROOT}/workshop/{MOD_FOLDER}';
} }
$resolved_target = sw_apply_template($target_template, $vars); $resolved_target = sw_apply_template($target_template, $vars);
return array( return array(

View file

@ -31,5 +31,8 @@ Phase 1 implements this decision by routing the user-facing Workshop install flo
- Workshop input accepts numeric IDs or Steam URLs, then stores numeric IDs only. - Workshop input accepts numeric IDs or Steam URLs, then stores numeric IDs only.
- Manifests are written under the server home in `gsp_server_content`. - 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. - Bundled Linux/Cygwin scripts are copied from the Panel module to an agent-managed folder under the server home before execution.
- Default script names are treated as bundled handlers, not as existing agent paths.
- Missing custom scripts fall back to bundled handlers and log the fallback.
- Generic content installs under `{SERVER_ROOT}/workshop/{MOD_FOLDER}` by default.
- DayZ/Arma-style installs default to `@<workshop_id>` folders and copy `.bikey` files into `keys` when present. - DayZ/Arma-style installs default to `@<workshop_id>` folders and copy `.bikey` files into `keys` when present.
- Startup parameter generation remains a later phase. - Startup parameter generation remains a later phase.

View file

@ -59,6 +59,13 @@ The admin template defines capability and policy. The customer supplies only Wor
- `bash <script> <manifest>` - `bash <script> <manifest>`
7. The script runs SteamCMD, copies downloaded content into the target mod folder, logs output, and copies `.bikey` files for DayZ/Arma-style strategies. 7. The script runs SteamCMD, copies downloaded content into the target mod folder, logs output, and copies `.bikey` files for DayZ/Arma-style strategies.
Current repair:
- The default script filename is no longer treated as an agent-host path.
- Missing custom scripts fall back to the bundled generic handler and log a warning.
- Generic installs default to `{SERVER_ROOT}/workshop/{MOD_FOLDER}`.
- DayZ/Arma installs keep `{SERVER_ROOT}/{MOD_FOLDER}` for `@<workshop_id>` compatibility.
## Manifest Fields ## Manifest Fields
Phase 1 manifests include: Phase 1 manifests include:
@ -95,6 +102,11 @@ Default install folder:
- `@<workshop_id>` - `@<workshop_id>`
Arma 3 game XML now declares:
- Workshop app ID: `107410`
- Dedicated server app ID: `233780` through the existing `installer_name`
Key-copy behavior: Key-copy behavior:
- `.bikey` files found anywhere in the installed mod folder are copied to `{SERVER_HOME}/keys`. - `.bikey` files found anywhere in the installed mod folder are copied to `{SERVER_HOME}/keys`.
@ -149,10 +161,16 @@ Phase 2 should generate structured mod lists from enabled `server_content_worksh
- full Steam URL accepted - full Steam URL accepted
- invalid text rejected - invalid text rejected
- Temporary manifest test with fake SteamCMD confirmed: - Temporary manifest test with fake SteamCMD confirmed:
- Linux script installs into `@<workshop_id>` - Linux script installs generic content into `workshop/@<workshop_id>`
- Windows/Cygwin script installs into `@<workshop_id>` - Windows/Cygwin script installs generic content into `workshop/@<workshop_id>`
- Arma/DayZ strategies resolve to root `@<workshop_id>` folders
- `.bikey` files are copied to `keys` - `.bikey` files are copied to `keys`
- useful logs are written under `gsp_server_content` - useful logs are written under `gsp_server_content`
- Current smoke tests also confirm:
- default Linux/Windows script staging no longer reports `script not found`
- full URLs, numeric IDs, whitespace, comma, newline, duplicates, and invalid values parse correctly
- generic no-template installs use `server_root/workshop`
- missing SteamCMD returns a clear failure
## Phase 2 Work ## Phase 2 Work

View file

@ -41,6 +41,13 @@ The Panel syncs the bundled install script to:
The agent executes the synced script with the manifest path. Customers do not need to place scripts manually on the agent. The agent executes the synced script with the manifest path. Customers do not need to place scripts manually on the agent.
Script selection rules:
1. If game XML defines a custom `workshop_script_linux` or `workshop_script_windows` and that script exists on the agent, use it.
2. If the custom script is missing, log a fallback warning and stage the bundled generic handler.
3. If no custom script is defined, stage the bundled generic handler for the server OS.
4. The default script filename must never be treated as a pre-existing agent path. The Panel must copy the bundled script first.
The manifest includes: The manifest includes:
- `home_id` - `home_id`
@ -52,7 +59,17 @@ The manifest includes:
- key-copy settings - key-copy settings
- content template metadata - content template metadata
DayZ/Arma-style installs default to `dayz_mod_folder` or `arma_mod_folder` based on the game key/name/config file. Those strategies install to `@<workshop_id>` by default and copy `.bikey` files into the server `keys` folder when found. Missing key files are logged but do not fail the install. Default install paths:
- Generic Workshop installs default to `{SERVER_ROOT}/workshop/{MOD_FOLDER}`.
- DayZ/Arma-style installs default to `dayz_mod_folder` or `arma_mod_folder` based on the game key/name/config file. Those strategies install to `{SERVER_ROOT}/{MOD_FOLDER}` so `@<workshop_id>` folders remain compatible with existing `-mod=` workflows.
- DayZ/Arma key-copy behavior copies `.bikey` files into the server `keys` folder when found. Missing key files are logged but do not fail the install.
App ID rules:
- `workshop_app_id` must come from a Server Content template, Steam Workshop profile, or game XML.
- Do not silently use the dedicated server Steam app ID as the Workshop app ID unless a legacy profile explicitly does so.
- Arma 3 XML declares Workshop app ID `107410`; its dedicated server Steam app ID remains `233780`.
## Database State ## Database State
@ -101,6 +118,15 @@ The current direction already supports:
- caching and cleanup policy need product-level design, not just ad hoc scripts. - caching and cleanup policy need product-level design, not just ad hoc scripts.
- `-mod=` / `-serverMod=` generation still needs a safe structured implementation. - `-mod=` / `-serverMod=` generation still needs a safe structured implementation.
## Troubleshooting
| 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 stages the bundled handler under `gsp_server_content/scripts/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. |
| 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 ## Recommended Mental Model
Use `addonsmanager` as the main future home for: Use `addonsmanager` as the main future home for:

View file

@ -50,6 +50,23 @@ For Workshop items, the current flow lets users enter Workshop IDs or full Steam
9. Agent executes the script with the manifest path. 9. Agent executes the script with the manifest path.
10. Script runs SteamCMD, copies Workshop content into the configured target path, copies DayZ/Arma `.bikey` files when applicable, and writes a log under `gsp_server_content`. 10. Script runs SteamCMD, copies Workshop content into the configured target path, copies DayZ/Arma `.bikey` files when applicable, and writes a log under `gsp_server_content`.
Current script fallback behavior:
- Admin-defined custom scripts are supported when they exist on the agent.
- Missing custom scripts fall back to the bundled generic handler and are logged.
- Default script names such as `generic_steam_workshop_windows_cygwin.sh` are copied from the Panel module source and must not be checked as bare files on the agent.
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
SteamCMD requirements:
- Linux agents need SteamCMD available at the configured profile/template path, `STEAMCMD_PATH`, `/home/gameserver/steamcmd/steamcmd.sh`, or in `PATH`.
- Windows agents currently use the existing Cygwin agent model and run the bundled Cygwin-compatible shell handler. SteamCMD may be provided as `steamcmd.exe`, `steamcmd.sh`, an explicit configured path, or via `STEAMCMD_PATH`.
- Missing SteamCMD should return a clear error, not a generic script failure.
The legacy `steam_workshop` monitor button is intentionally suppressed so users are not sent to the deprecated standalone module. The legacy `steam_workshop` monitor button is intentionally suppressed so users are not sent to the deprecated standalone module.
## Current Limitations ## Current Limitations