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] Full TODO/comment blocks added to installer for next phase work.
- [x] Module folder, table names, URL routes, function names unchanged.
- [x] Workshop Content Phase 1: manual Workshop ID entry, per-server manifest,
agent script runner, install/update/remove actions (`db_version 3`).
- [x] Workshop Content Phase 1: manual Workshop ID/URL entry, per-server
manifest, bundled Linux and Windows/Cygwin SteamCMD handlers, generic
handler fallback, install/update/remove actions (`db_version 3`).
### Phase 2 — Schema & install_method support
- [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 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 lists saved IDs and supports:
- Install New
@ -15,7 +16,14 @@ Phase 1 adds manual Workshop ID support inside the existing `addonsmanager` modu
- Update All
- Panel generates a per-server manifest at:
- `%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
@ -24,6 +32,9 @@ Phase 1 adds manual Workshop ID support inside the existing `addonsmanager` modu
- IDs must be numeric only.
- Script path is not user-editable.
- 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).
- 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:
- `workshop_app_id`
- Linux workshop script path
- Windows/Cygwin workshop script path
- optional Linux workshop script path only when the bundled generic handler is
not sufficient
- optional Windows/Cygwin workshop script path only when the bundled generic
handler is not sufficient
- target install location
- 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)
- Workshop browsing/search/select UI

View file

@ -176,7 +176,7 @@ try:
'FOLDER_NAME': 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()
if len(items) != 1 or not target_path:
target_path = render_template(target_template, template_values)

View file

@ -177,7 +177,7 @@ try:
'FOLDER_NAME': 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()
if len(items) != 1 or not target_path:
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')) {
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)
{
@ -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;
}
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)
{
$filename = scm_is_windows_home($home_info) ? SCM_WORKSHOP_SCRIPT_WINDOWS_DEFAULT : SCM_WORKSHOP_SCRIPT_LINUX_DEFAULT;
return dirname(__FILE__) . '/scripts/workshop/' . $filename;
}
function scm_is_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);
@ -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 = '')
{
$error = '';
$configured_path = trim((string)scm_get_workshop_script_path($home_info, $server_xml));
if ($configured_path !== '' && !scm_is_legacy_panel_workshop_script_path($configured_path) && preg_match('/^[^\\r\\n\\0]+$/', $configured_path)) {
$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)) {
if ((int)$remote->rfile_exists($configured_path) === 1) {
return $configured_path;
}
$error = 'Configured workshop script not found on agent host: ' . $configured_path;
return false;
scm_log_content_install_action(array(
'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);
@ -313,6 +342,15 @@ function scm_prepare_workshop_script_for_agent($remote, array $home_info, $serve
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()
{
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'] : ''));
$target_path_template = trim((string)$payload['target_path_template']);
$optional_folder_name = trim((string)$payload['optional_folder_name']);
$workshop_app_id_override = trim((string)$payload['workshop_app_id']);
$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(
@ -625,7 +664,7 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
if ($effective_template === '') {
$effective_template = (is_array($fallback_profile) && !empty($fallback_profile['install_path_template']))
? (string)$fallback_profile['install_path_template']
: '{SERVER_ROOT}/{MOD_FOLDER}';
: 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,

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) {
$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.
@ -122,7 +123,7 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'workshop_item_id' => (string)$item_id,
'title' => '',
'folder_name' => isset($runtime['folder_name']) && $runtime['folder_name'] !== '' ? (string)$runtime['folder_name'] : '@' . $item_id,
'target_path_template' => isset($runtime['target_path_template']) ? (string)$runtime['target_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}',
'target_path_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'] : '',
'install_strategy' => $install_strategy,
'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'], '/'),
'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0,
'target_path_template' => isset($template['target_path_template']) && trim((string)$template['target_path_template']) !== '' ? trim((string)$template['target_path_template']) : '{SERVER_ROOT}/{MOD_FOLDER}',
'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']) : '',
'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,
@ -183,7 +184,7 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml,
'items' => array_values($item_ids),
'item_details' => !empty($extra_manifest['item_details']) && is_array($extra_manifest['item_details']) ? $extra_manifest['item_details'] : array(),
'install_strategy' => !empty($extra_manifest['install_strategy']) ? (string)$extra_manifest['install_strategy'] : '',
'target_path' => !empty($extra_manifest['target_path_template']) ? (string)$extra_manifest['target_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}',
'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'),
);
if (!empty($extra_manifest)) {

View file

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

View file

@ -2,6 +2,7 @@
<game_key>arma3_win64</game_key>
<installer>steamcmd</installer>
<game_name>Arma 3</game_name>
<workshop_app_id>107410</workshop_app_id>
<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_params>

View file

@ -518,22 +518,37 @@ function sw_detect_profile_defaults_from_xml($configName)
}
}
$xmlBlob = $matched->asXML();
$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];
}
if ($workshopAppId === '') {
$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(
'steam_app_id' => $steamAppId,
'workshop_app_id' => $workshopAppId,
'steamcmd_path' => '/home/gameserver/steamcmd/steamcmd.sh',
'server_root_template' => '{SERVER_ROOT}',
'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);
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);
return array(