doc changes and reference folder

This commit is contained in:
Frank Harris 2026-06-06 17:18:49 -05:00
parent 11691a5876
commit 82cbc206eb
33 changed files with 1514 additions and 2855 deletions

View file

@ -223,6 +223,12 @@ function scm_get_workshop_manifest_path(array $home_info)
function scm_extract_workshop_app_id($server_xml)
{
if (isset($server_xml->workshop_support->workshop_app_id)) {
$value = trim((string)$server_xml->workshop_support->workshop_app_id);
if ($value !== '' && preg_match('/^[0-9]+$/', $value)) {
return $value;
}
}
$candidates = array(
'workshop_app_id',
'workshop_appid',
@ -240,9 +246,53 @@ function scm_extract_workshop_app_id($server_xml)
return "";
}
function scm_extract_workshop_steam_app_id($server_xml)
{
if (isset($server_xml->workshop_support->steam_app_id)) {
$value = trim((string)$server_xml->workshop_support->steam_app_id);
if ($value !== '' && preg_match('/^[0-9]+$/', $value)) {
return $value;
}
}
return '';
}
function scm_extract_workshop_install_path($server_xml)
{
if (isset($server_xml->workshop_support->install_path)) {
$value = trim((string)$server_xml->workshop_support->install_path);
if ($value !== '' && preg_match('/^[^\\r\\n\\0]+$/', $value)) {
return $value;
}
}
return '';
}
function scm_workshop_xml_bool($value, $default = false)
{
$value = strtolower(trim((string)$value));
if ($value === '') {
return (bool)$default;
}
if (in_array($value, array('1', 'yes', 'true', 'on'), true)) {
return true;
}
if (in_array($value, array('0', 'no', 'false', 'off'), true)) {
return false;
}
return (bool)$default;
}
function scm_get_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)) {
@ -255,14 +305,20 @@ function scm_get_workshop_script_path(array $home_info, $server_xml)
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 '';
$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;
}
}
$xml_path = trim((string)$server_xml->$key);
if ($xml_path === '' || !preg_match('/^[^\\r\\n\\0]+$/', $xml_path)) {
return '';
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 $xml_path;
return '';
}
function scm_get_bundled_workshop_script_source(array $home_info)
@ -658,13 +714,16 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
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'] : '';
$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 === '') {
$effective_template = (is_array($fallback_profile) && !empty($fallback_profile['install_path_template']))
? (string)$fallback_profile['install_path_template']
: scm_get_default_workshop_target_template($install_strategy);
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);
}
}
$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,
@ -695,6 +754,12 @@ function scm_detect_workshop_install_strategy(array $home_info, $server_xml, arr
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);
@ -718,15 +783,33 @@ function scm_detect_workshop_install_strategy(array $home_info, $server_xml, arr
function scm_workshop_should_copy_keys($server_xml, $install_strategy)
{
if (isset($server_xml->workshop_support->copy_keys)) {
$attrs = $server_xml->workshop_support->copy_keys->attributes();
if (isset($attrs['enabled'])) {
return scm_workshop_xml_bool((string)$attrs['enabled'], false);
}
}
foreach (array('workshop_copy_keys', 'copy_workshop_keys') as $tag) {
if (isset($server_xml->$tag)) {
$value = strtolower(trim((string)$server_xml->$tag));
return in_array($value, array('1', 'yes', 'true', 'on'), true);
return scm_workshop_xml_bool((string)$server_xml->$tag, false);
}
}
return in_array((string)$install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true);
}
function scm_workshop_keys_target_path($server_xml, array $home_info)
{
$template = '';
if (isset($server_xml->workshop_support->copy_keys->target_path)) {
$template = trim((string)$server_xml->workshop_support->copy_keys->target_path);
}
if ($template === '') {
$template = '{SERVER_ROOT}/keys';
}
$map = scm_build_placeholder_map($home_info);
return scm_apply_placeholders($template, $map);
}
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);

View file

@ -82,7 +82,7 @@ scm_workshop_test_assert($script === '/cygdrive/c/OGP_User_Files/11/gsp_server_c
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>');
$configuredXml = simplexml_load_string('<game_config><workshop_support><script_linux>/agent/custom/workshop.sh</script_linux></workshop_support></game_config>');
$customRemote = new ScmWorkshopFakeRemote();
$customRemote->existing[] = '/agent/custom/workshop.sh';
$script = scm_prepare_workshop_script_for_agent($customRemote, $linuxHome, $configuredXml, $error);
@ -110,7 +110,13 @@ $runtime = scm_build_workshop_runtime_context(new stdClass(), $linuxHome, $empty
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');
$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_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');
scm_workshop_test_assert($runtime['target_path_resolved'] === '/srv/games/arma3/@450814997', 'canonical Workshop install_path resolves under server root');
scm_workshop_test_assert(scm_workshop_should_copy_keys($appXml, 'copy_to_mod_folder') === true, 'canonical copy_keys enabled flag is honored');
echo "All Workshop helper smoke tests passed.\n";

View file

@ -88,6 +88,8 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
{
$install_strategy = scm_detect_workshop_install_strategy($home_info, $server_xml, $template);
$copy_keys = scm_workshop_should_copy_keys($server_xml, $install_strategy);
$xml_install_path = scm_extract_workshop_install_path($server_xml);
$keys_target_path = scm_workshop_keys_target_path($server_xml, $home_info);
$item_details = array();
$resolved_app_id = '';
$steam_app_id = '';
@ -127,13 +129,16 @@ function scm_workshop_build_manifest_context($db, array $home_info, $server_xml,
'target_path_resolved' => isset($runtime['target_path_resolved']) ? (string)$runtime['target_path_resolved'] : '',
'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0,
'keys_target_path' => rtrim((string)$home_info['home_path'], '/') . '/keys',
'keys_target_path' => $keys_target_path,
);
}
if ($resolved_app_id === '') {
$resolved_app_id = scm_extract_workshop_app_id($server_xml);
}
if ($steam_app_id === '') {
$steam_app_id = scm_extract_workshop_steam_app_id($server_xml);
}
return array(
'workshop_app_id' => $resolved_app_id,
@ -141,7 +146,8 @@ 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']) : scm_get_default_workshop_target_template($install_strategy),
'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)),
'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']) : '',
'content_template_id' => isset($template['addon_id']) ? (int)$template['addon_id'] : 0,

View file

@ -268,6 +268,77 @@
<xs:element name="file" type="files_type" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>
<!-- Canonical Workshop / Server Content capability declaration.
The Server Content Manager (addonsmanager) reads this block to build
safe Workshop manifests for the agents. Keep customer-supplied Workshop
item IDs out of XML; users enter those through the Panel UI. -->
<xs:simpleType name="boolean_flag_type">
<xs:restriction base="xs:string">
<xs:enumeration value="0" />
<xs:enumeration value="1" />
<xs:enumeration value="false" />
<xs:enumeration value="true" />
<xs:enumeration value="no" />
<xs:enumeration value="yes" />
<xs:enumeration value="off" />
<xs:enumeration value="on" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="workshop_provider_type">
<xs:restriction base="xs:string">
<xs:enumeration value="steam" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="workshop_download_method_type">
<xs:restriction base="xs:string">
<xs:enumeration value="steamcmd" />
<xs:enumeration value="game_managed_workshop" />
<xs:enumeration value="manual" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="workshop_install_strategy_type">
<xs:restriction base="xs:string">
<xs:enumeration value="game_managed_workshop" />
<xs:enumeration value="steamcmd_download_only" />
<xs:enumeration value="copy_to_game_root" />
<xs:enumeration value="copy_to_mod_folder" />
<xs:enumeration value="dayz_mod_folder" />
<xs:enumeration value="arma_mod_folder" />
<xs:enumeration value="config_only" />
<xs:enumeration value="custom_scripted_install" />
</xs:restriction>
</xs:simpleType>
<xs:complexType name="workshop_copy_keys_type">
<xs:sequence>
<xs:element name="source_pattern" type="xs:string" minOccurs="0" />
<xs:element name="target_path" type="xs:string" minOccurs="0" />
</xs:sequence>
<xs:attribute name="enabled" type="boolean_flag_type" use="optional" />
</xs:complexType>
<xs:complexType name="workshop_support_type">
<xs:sequence>
<xs:element name="enabled" type="boolean_flag_type" minOccurs="0" />
<xs:element name="provider" type="workshop_provider_type" minOccurs="0" />
<xs:element name="steam_app_id" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="workshop_app_id" type="xs:positiveInteger" minOccurs="0" />
<xs:element name="download_method" type="workshop_download_method_type" minOccurs="0" />
<xs:element name="install_strategy" type="workshop_install_strategy_type" minOccurs="0" />
<xs:element name="install_path" type="xs:string" minOccurs="0" />
<xs:element name="startup_param_format" type="xs:string" minOccurs="0" />
<xs:element name="mod_separator" type="xs:string" minOccurs="0" />
<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:sequence>
</xs:complexType>
<!-- The main of the template -->
<xs:complexType name="server_config_type">
@ -278,6 +349,7 @@
<xs:element name="gameq_query_name" type="nonEmptyString" minOccurs="0" />
<xs:element name="installer" type="nonEmptyString" minOccurs="0" />
<xs:element name="game_name" type="nonEmptyString" />
<xs:element name="workshop_support" type="workshop_support_type" minOccurs="0" />
<xs:element name="server_exec_name" type="nonEmptyString" />
<xs:element name="query_port" type="query_port_type" minOccurs="0" />
<xs:element name="cli_template" type="nonEmptyString" minOccurs="0" />

View file

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

@ -0,0 +1,60 @@
<?php
/*
* Validates all game server XML configuration files against the bundled
* schema. This is a developer smoke test; it does not load the full Panel.
*/
$baseDir = dirname(__DIR__);
$schema = $baseDir . '/schema_server_config.xml';
$configDir = $baseDir . '/server_configs';
if (!is_file($schema)) {
fwrite(STDERR, "Schema not found: {$schema}\n");
exit(1);
}
$files = glob($configDir . '/*.xml');
sort($files);
if (empty($files)) {
fwrite(STDERR, "No XML files found under: {$configDir}\n");
exit(1);
}
libxml_use_internal_errors(true);
$failed = 0;
foreach ($files as $file) {
$doc = new DOMDocument();
$doc->preserveWhiteSpace = false;
$doc->formatOutput = false;
if (!$doc->load($file)) {
$failed++;
echo "FAIL " . basename($file) . "\n";
foreach (libxml_get_errors() as $error) {
echo " XML parse: " . trim($error->message) . "\n";
}
libxml_clear_errors();
continue;
}
if (!$doc->schemaValidate($schema)) {
$failed++;
echo "FAIL " . basename($file) . "\n";
foreach (libxml_get_errors() as $error) {
echo " Schema: " . trim($error->message) . "\n";
}
libxml_clear_errors();
continue;
}
echo "PASS " . basename($file) . "\n";
}
if ($failed > 0) {
echo "XML validation failed for {$failed} file(s).\n";
exit(1);
}
echo "All server config XML files validated successfully.\n";

View file

@ -1,364 +0,0 @@
<?php
/*
* GSP Steam Workshop: Admin profile management
* Copyright (C) 2025 WDS / GameServerPanel
*/
require_once __DIR__ . '/includes/functions.php';
if (!defined('SERVER_CONFIG_LOCATION')) {
require_once __DIR__ . '/../../config_games/server_config_parser.php';
}
function exec_ogp_module()
{
global $db;
echo '<h2>Steam Workshop &ndash; Admin</h2>';
sw_admin_print_styles();
$action = $_GET['action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_profile'])) {
sw_admin_save_profile($db);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['sync_profiles'])) {
$n = sw_sync_profiles($db);
sw_success("Sync complete. $n new profile(s) created.");
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['detect_defaults'])) {
$profile = sw_get_profile_by_id($db, (int)($_POST['id'] ?? 0));
if (!$profile) {
sw_error('Profile not found.');
sw_admin_list($db);
return;
}
$detected = sw_detect_profile_defaults_from_xml($profile['config_name']);
if (empty($detected)) {
sw_error('No Steam defaults were detected in this game XML. You can still enter values manually.');
} else {
sw_success('Detected XML defaults. Review and apply when ready.');
}
sw_admin_edit_form($profile, $detected, true);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['apply_detected_defaults'])) {
$profile = sw_get_profile_by_id($db, (int)($_POST['id'] ?? 0));
if (!$profile) {
sw_error('Profile not found.');
sw_admin_list($db);
return;
}
$detected = sw_detect_profile_defaults_from_xml($profile['config_name']);
if (empty($detected)) {
sw_error('No Steam defaults were detected in this game XML.');
sw_admin_edit_form($profile);
return;
}
$overwrite = isset($_POST['overwrite_existing']) && $_POST['overwrite_existing'] === '1';
$updated = sw_apply_detected_profile_defaults($db, $profile, $detected, $overwrite);
if ($updated > 0) {
$overwriteMessage = $overwrite
? ' Existing values were allowed to be overwritten.'
: ' Existing non-empty values were kept.';
sw_success("Applied $updated detected default value(s)." . $overwriteMessage);
} else {
sw_success('No profile values needed updating based on current overwrite setting.');
}
$profile = sw_get_profile_by_id($db, (int)$profile['id']);
sw_admin_edit_form($profile, $detected, true);
return;
}
if ($action === 'edit' && isset($_GET['id'])) {
$profile = sw_get_profile_by_id($db, (int)$_GET['id']);
if ($profile) {
sw_admin_edit_form($profile);
} else {
sw_error('Profile not found.');
sw_admin_list($db);
}
return;
}
sw_admin_list($db);
}
function sw_admin_save_profile($db)
{
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
if (!$id) {
sw_error('Invalid profile ID.');
sw_admin_list($db);
return;
}
$profile = sw_get_profile_by_id($db, $id);
if (!$profile) {
sw_error('Profile not found.');
sw_admin_list($db);
return;
}
$fields = array(
'enabled' => isset($_POST['enabled']) ? 1 : 0,
'steam_app_id' => trim($_POST['steam_app_id'] ?? ''),
'workshop_app_id' => trim($_POST['workshop_app_id'] ?? ''),
'steam_login_required' => isset($_POST['steam_login_required']) ? 1 : 0,
'steamcmd_login_mode' => (($_POST['steamcmd_login_mode'] ?? 'anonymous') === 'account') ? 'account' : 'anonymous',
'steamcmd_path' => trim($_POST['steamcmd_path'] ?? ''),
'workshop_download_dir_template' => trim($_POST['workshop_download_dir_template'] ?? ''),
'server_root_template' => trim($_POST['server_root_template'] ?? ''),
'install_path_template' => trim($_POST['install_path_template'] ?? ''),
'folder_naming_format' => trim($_POST['folder_naming_format'] ?? ''),
'mod_launch_param_template' => trim($_POST['mod_launch_param_template'] ?? '-mod='),
'servermod_launch_param_template' => trim($_POST['servermod_launch_param_template'] ?? '-serverMod='),
'install_script_template' => trim($_POST['install_script_template'] ?? ''),
'update_script_template' => trim($_POST['update_script_template'] ?? ''),
'copy_bikeys_enabled' => isset($_POST['copy_bikeys_enabled']) ? 1 : 0,
'notes' => trim($_POST['notes'] ?? ''),
);
// Per-profile default behavior fields
$valid_update_modes = array('manual', 'on_restart', 'before_start');
$valid_restart_behaviors = array('none', 'if_stopped');
$posted_um = $_POST['default_update_mode'] ?? 'manual';
$posted_rb = $_POST['default_restart_behavior'] ?? 'none';
$fields['default_update_mode'] = in_array($posted_um, $valid_update_modes, true) ? $posted_um : 'manual';
$fields['default_restart_behavior'] = in_array($posted_rb, $valid_restart_behaviors, true) ? $posted_rb : 'none';
$setParts = array();
foreach ($fields as $col => $val) {
$setParts[] = "`$col` = '" . $db->realEscapeSingle($val) . "'";
}
$setParts[] = "`updated_at` = NOW()";
$ok = $db->query(
"UPDATE " . sw_table('steam_workshop_game_profiles') . "
SET " . implode(', ', $setParts) . "
WHERE `id` = $id LIMIT 1"
);
if ($ok) {
sw_success('Profile saved.');
} else {
sw_error('Failed to save profile.');
}
$profile = sw_get_profile_by_id($db, $id);
if ($profile) {
sw_admin_edit_form($profile);
} else {
sw_admin_list($db);
}
}
function sw_admin_list($db)
{
$profiles = sw_get_profiles($db);
?>
<div class="sw-admin-panel">
<p class="sw-muted">
Profiles map game XML configs to Steam Workshop defaults. Sync to create missing profiles, then edit each profile for game-specific paths and launch templates.
</p>
<form method="post" style="margin-bottom:12px;">
<button type="submit" name="sync_profiles" value="1" class="button"
onclick="return confirm('Sync workshop profiles from all game config XMLs?');">Sync Profiles from XML Configs</button>
</form>
<?php if (empty($profiles)): ?>
<p>No profiles yet. Click <em>Sync Profiles</em> to create them from installed game configs.</p>
<?php else: ?>
<table class="sw-admin-table" width="100%">
<thead>
<tr>
<th>Config Name</th>
<th>Game Name</th>
<th style="text-align:center;">Steam App ID</th>
<th style="text-align:center;">Workshop App ID</th>
<th style="text-align:center;">Enabled</th>
<th style="text-align:center;">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($profiles as $p): ?>
<tr>
<td><code><?= sw_h($p['config_name']) ?></code></td>
<td><?= sw_h($p['game_name']) ?></td>
<td style="text-align:center;"><?= sw_h($p['steam_app_id']) ?></td>
<td style="text-align:center;"><?= sw_h($p['workshop_app_id']) ?></td>
<td style="text-align:center;"><?= $p['enabled'] ? '<span class="sw-state-on">Yes</span>' : '<span class="sw-state-off">No</span>' ?></td>
<td style="text-align:center;"><a class="button small" href="home.php?m=steam_workshop&p=admin&action=edit&id=<?= (int)$p['id'] ?>">Edit</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
function sw_admin_edit_form(array $profile, array $detected = array(), $showDetectedBox = false)
{
$id = (int)$profile['id'];
?>
<p><a href="home.php?m=steam_workshop&p=admin">&laquo; Back to profile list</a></p>
<h3>Edit Profile: <?= sw_h($profile['config_name']) ?> &ndash; <?= sw_h($profile['game_name']) ?></h3>
<div class="sw-admin-panel">
<div class="sw-note">
<strong>Placeholder tokens:</strong>
<code>{SERVER_ROOT}</code> <code>{HOME_ID}</code> <code>{STEAM_APP_ID}</code> <code>{WORKSHOP_APP_ID}</code> <code>{MOD_FOLDER}</code>
</div>
<div class="sw-section">
<h4>XML-Assisted Defaults</h4>
<p class="sw-muted">Use values detected from this game XML. Existing values are not overwritten unless you explicitly allow it.</p>
<form method="post" action="home.php?m=steam_workshop&p=admin&action=edit&id=<?= $id ?>" style="display:inline-block; margin-right:8px;">
<input type="hidden" name="id" value="<?= $id ?>">
<button type="submit" name="detect_defaults" value="1" class="button">Detect from XML</button>
</form>
<?php if ($showDetectedBox && !empty($detected)): ?>
<div class="sw-detected-box">
<strong>Detected values:</strong>
<ul>
<li>Steam App ID: <code><?= sw_h($detected['steam_app_id'] ?? '') ?></code></li>
<li>Workshop App ID: <code><?= sw_h($detected['workshop_app_id'] ?? '') ?></code></li>
<li>SteamCMD path: <code><?= sw_h($detected['steamcmd_path'] ?? '') ?></code></li>
<li>Workshop download dir: <code><?= sw_h($detected['workshop_download_dir_template'] ?? '') ?></code></li>
<li>Server root: <code><?= sw_h($detected['server_root_template'] ?? '') ?></code></li>
<li>Mod install path: <code><?= sw_h($detected['install_path_template'] ?? '') ?></code></li>
</ul>
<form method="post" action="home.php?m=steam_workshop&p=admin&action=edit&id=<?= $id ?>">
<input type="hidden" name="id" value="<?= $id ?>">
<label style="display:block;margin-bottom:8px;">
<input type="checkbox" name="overwrite_existing" value="1">
Allow overwrite of existing non-empty values.
</label>
<button type="submit" name="apply_detected_defaults" value="1" class="button">Refresh defaults from XML</button>
</form>
</div>
<?php endif; ?>
</div>
<form method="post" action="home.php?m=steam_workshop&p=admin&action=edit&id=<?= $id ?>">
<input type="hidden" name="id" value="<?= $id ?>">
<div class="sw-section">
<h4>Global Profile Defaults</h4>
<div class="sw-grid">
<label><span>Enabled</span><input type="checkbox" name="enabled" value="1" <?= $profile['enabled'] ? 'checked' : '' ?>></label>
<label><span>Steam App ID</span><input type="text" name="steam_app_id" value="<?= sw_h($profile['steam_app_id']) ?>" placeholder="Detected from XML when available"></label>
<label><span>Workshop App ID</span><input type="text" name="workshop_app_id" value="<?= sw_h($profile['workshop_app_id']) ?>" placeholder="Detected from XML when available"></label>
<label><span>SteamCMD Path</span><input type="text" name="steamcmd_path" value="<?= sw_h($profile['steamcmd_path']) ?>" placeholder="/home/gameserver/steamcmd/steamcmd.sh"></label>
<label><span>Steam Login Required</span><input type="checkbox" name="steam_login_required" value="1" <?= $profile['steam_login_required'] ? 'checked' : '' ?>></label>
<label><span>SteamCMD Login Mode</span>
<select name="steamcmd_login_mode">
<option value="anonymous" <?= $profile['steamcmd_login_mode'] === 'anonymous' ? 'selected' : '' ?>>anonymous</option>
<option value="account" <?= $profile['steamcmd_login_mode'] === 'account' ? 'selected' : '' ?>>account</option>
</select>
</label>
</div>
</div>
<div class="sw-section">
<h4>Path Templates</h4>
<p class="sw-muted">Use placeholders so paths stay portable between server homes.</p>
<div class="sw-grid">
<label><span>Workshop Download Directory</span><input type="text" name="workshop_download_dir_template" value="<?= sw_h($profile['workshop_download_dir_template']) ?>" placeholder="{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}"></label>
<label><span>Server Root</span><input type="text" name="server_root_template" value="<?= sw_h($profile['server_root_template']) ?>" placeholder="{SERVER_ROOT}"></label>
<label><span>Mod Install Path</span><input type="text" name="install_path_template" value="<?= sw_h($profile['install_path_template']) ?>" placeholder="{SERVER_ROOT}/{MOD_FOLDER}"></label>
</div>
</div>
<div class="sw-section">
<h4>Per-Game Runtime Values</h4>
<div class="sw-grid">
<label><span>Folder Naming Format</span><input type="text" name="folder_naming_format" value="<?= sw_h($profile['folder_naming_format']) ?>" placeholder="@{MOD_NAME}"></label>
<label><span>Client Launch Param</span><input type="text" name="mod_launch_param_template" value="<?= sw_h($profile['mod_launch_param_template']) ?>" placeholder="-mod="></label>
<label><span>Server Launch Param</span><input type="text" name="servermod_launch_param_template" value="<?= sw_h($profile['servermod_launch_param_template']) ?>" placeholder="-serverMod="></label>
<label><span>Copy .bikey files</span><input type="checkbox" name="copy_bikeys_enabled" value="1" <?= $profile['copy_bikeys_enabled'] ? 'checked' : '' ?>></label>
</div>
</div>
<div class="sw-section">
<h4>Optional Script Templates</h4>
<label><span>Install Script Template</span><textarea name="install_script_template" rows="6"><?= sw_h($profile['install_script_template']) ?></textarea></label>
<label><span>Update Script Template</span><textarea name="update_script_template" rows="6"><?= sw_h($profile['update_script_template']) ?></textarea></label>
</div>
<div class="sw-section">
<h4>Notes</h4>
<label><textarea name="notes" rows="4"><?= sw_h($profile['notes']) ?></textarea></label>
</div>
<div class="sw-section">
<h4>Default Workshop Behavior for New Servers</h4>
<p class="sw-muted">
These defaults are applied when a user enables Workshop on a server that has no saved behavior settings yet.
Users can always override them on their own server pages.
All defaults are intentionally set to the safest option (manual / no automatic restart).
</p>
<div class="sw-grid">
<label>
<span>Default Install / Update Mode</span>
<select name="default_update_mode">
<option value="manual" <?= (($profile['default_update_mode'] ?? 'manual') === 'manual') ? 'selected' : '' ?>>Manual only (safe default)</option>
<option value="on_restart" <?= (($profile['default_update_mode'] ?? 'manual') === 'on_restart') ? 'selected' : '' ?>>On next restart</option>
<option value="before_start" <?= (($profile['default_update_mode'] ?? 'manual') === 'before_start') ? 'selected' : '' ?>>Before every server start</option>
</select>
</label>
<label>
<span>Default Restart Behavior</span>
<select name="default_restart_behavior">
<option value="none" <?= (($profile['default_restart_behavior'] ?? 'none') === 'none') ? 'selected' : '' ?>>Do not restart automatically (safe default)</option>
<option value="if_stopped" <?= (($profile['default_restart_behavior'] ?? 'none') === 'if_stopped') ? 'selected' : '' ?>>Restart only if server is stopped</option>
</select>
</label>
</div>
</div>
<p>
<button type="submit" name="save_profile" value="1" class="button">Save Profile</button>
<a href="home.php?m=steam_workshop&p=admin" class="button">Cancel</a>
</p>
</form>
</div>
<?php
}
function sw_admin_print_styles()
{
static $printed = false;
if ($printed) {
return;
}
$printed = true;
echo '<style>
.sw-admin-panel{background:#171717;border:1px solid #2d2d2d;border-radius:6px;padding:14px;margin:10px 0;color:#e7e7e7}
.sw-admin-table{border-collapse:collapse;background:#121212}
.sw-admin-table th,.sw-admin-table td{border:1px solid #2c2c2c;padding:8px}
.sw-admin-table thead th{background:#232323;color:#fff}
.sw-state-on{color:#78d978;font-weight:700}
.sw-state-off{color:#9a9a9a}
.sw-section{margin-top:14px;padding:12px;border:1px solid #2f2f2f;border-radius:4px;background:#111}
.sw-section h4{margin:0 0 8px 0;color:#f6f6f6}
.sw-note{margin-bottom:10px;background:#202020;border-left:3px solid #3f80d0;padding:10px}
.sw-muted{color:#b3b3b3}
.sw-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
.sw-grid label, .sw-section > label{display:block}
.sw-grid span, .sw-section span{display:block;font-size:12px;color:#bdbdbd;margin-bottom:4px}
.sw-grid input[type=text], .sw-grid select, .sw-section textarea{width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #3a3a3a;color:#eee;padding:7px;border-radius:4px}
.sw-grid input[type=checkbox]{transform:scale(1.1);margin-top:4px}
.sw-detected-box{margin-top:10px;padding:10px;background:#1d2a1d;border:1px solid #335933;border-radius:4px}
.sw-detected-box ul{margin:8px 0 10px 18px}
.sw-detected-box code,.sw-note code{background:#0b0b0b;padding:1px 4px;border-radius:3px;color:#9fd4ff}
</style>';
}

View file

@ -1,569 +0,0 @@
<?php
/*
* GSP Steam Workshop: Agent CLI update script
* Copyright (C) 2025 WDS / GameServerPanel
*
* This file must only be run from the command line (CLI).
* Do NOT expose it through a web server.
*
* Usage:
* php agent_update_workshop.php --home-id=123
* php agent_update_workshop.php --all
*
* The script connects to the panel database, reads the list of enabled mods
* for the specified server(s), downloads/updates each mod via SteamCMD,
* copies mod folders into the server root, copies .bikey files into the
* server keys/ directory, and updates the install status in the database.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
// ── Safety: CLI only ──────────────────────────────────────────────────────
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("This script may only be run from the command line.\n");
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
$panel_root = realpath(__DIR__ . '/../../..');
if (!$panel_root || !is_dir($panel_root)) {
fwrite(STDERR, "ERROR: Cannot locate panel root from " . __DIR__ . "\n");
exit(1);
}
$config_file = $panel_root . '/includes/config.inc.php';
if (!is_file($config_file)) {
fwrite(STDERR, "ERROR: Panel config not found: $config_file\n");
fwrite(STDERR, " Copy includes/config.inc.php.example to includes/config.inc.php and set credentials.\n");
exit(1);
}
require_once $config_file;
require_once $panel_root . '/includes/helpers.php';
require_once $panel_root . '/includes/database_mysqli.php';
require_once __DIR__ . '/includes/functions.php';
// ── Database connection ───────────────────────────────────────────────────
// Variables $db_host, $db_user, $db_pass, $db_name, $table_prefix, $db_type,
// $db_port come from config.inc.php (loaded above).
$db = createDatabaseConnection(
$db_type,
$db_host,
$db_user,
$db_pass,
$db_name,
$table_prefix,
isset($db_port) ? $db_port : null
);
if (!is_object($db)) {
$error_text = '';
get_db_error_text($db, $error_text);
fwrite(STDERR, "ERROR: Database connection failed: $error_text\n");
exit(1);
}
// ── Argument parsing ──────────────────────────────────────────────────────
$opts = getopt('', array('home-id:', 'all', 'dry-run'));
$do_all = array_key_exists('all', $opts);
$dry_run = array_key_exists('dry-run', $opts);
$target_id = isset($opts['home-id']) ? (int)$opts['home-id'] : 0;
if (!$do_all && !$target_id) {
fwrite(STDERR, "Usage:\n");
fwrite(STDERR, " php agent_update_workshop.php --home-id=123\n");
fwrite(STDERR, " php agent_update_workshop.php --all\n");
fwrite(STDERR, " Add --dry-run to simulate without running SteamCMD or copying files.\n");
exit(1);
}
if ($dry_run) {
echo "[DRY RUN] No files will be modified and SteamCMD will not be called.\n";
}
// ── Collect home IDs to process ───────────────────────────────────────────
if ($do_all) {
// Find all home_ids that have at least one enabled queued mod with an enabled profile.
$rows = $db->resultQuery(
"SELECT DISTINCT m.home_id
FROM " . sw_table('steam_workshop_server_mods') . " m
JOIN " . sw_table('steam_workshop_game_profiles') . " p ON p.id = m.profile_id
WHERE m.enabled = 1 AND p.enabled = 1 AND m.install_status = 'queued'"
);
$home_ids = $rows ? array_column($rows, 'home_id') : array();
} else {
$home_ids = array($target_id);
}
if (empty($home_ids)) {
echo "No servers with enabled Workshop mods found.\n";
exit(0);
}
$overall_success = true;
foreach ($home_ids as $home_id) {
$home_id = (int)$home_id;
echo "\n=== Processing home_id=$home_id ===\n";
$ok = sw_agent_process_home($db, $home_id, $dry_run);
if (!$ok) {
$overall_success = false;
echo " [WARN] One or more errors occurred for home_id=$home_id.\n";
}
}
exit($overall_success ? 0 : 1);
// ─────────────────────────────────────────────────────────────────────────
// Core logic
// ─────────────────────────────────────────────────────────────────────────
/**
* Process all enabled mods for one server home.
*
* @param OGPDatabase $db
* @param int $home_id
* @param bool $dry_run
* @return bool true if all mods processed without errors
*/
function sw_agent_process_home($db, $home_id, $dry_run)
{
// Load server info
$home = sw_get_home_info($db, $home_id);
if (!$home) {
echo " [ERROR] Server home $home_id not found in database.\n";
return false;
}
echo " Server: " . $home['home_name'] . " (game: " . $home['game_name'] . ")\n";
echo " Path: " . $home['home_path'] . "\n";
// Resolve Workshop profile via game config key
$profile = sw_get_profile_for_home($db, $home_id);
if (!$profile) {
echo " [SKIP] No enabled Workshop profile for this game type.\n";
return true;
}
echo " Profile: " . $profile['config_name'] . " (workshop_app_id=" . $profile['workshop_app_id'] . ")\n";
// Build common template variables
$server_root = sw_apply_template(
$profile['server_root_template'] ?: $home['home_path'],
sw_agent_tpl_vars($home, $profile)
);
$server_root = rtrim($server_root, '/');
// Load queued+enabled mods, sorted by sort_order
$mods = sw_agent_get_queued_mods($db, $home_id);
if (empty($mods)) {
echo " No queued Workshop updates for this server.\n";
return true;
}
$keys_dir = $server_root . '/keys';
if (!$dry_run && !is_dir($keys_dir)) {
@mkdir($keys_dir, 0755, true);
}
$all_ok = true;
foreach ($mods as $mod) {
$mod_id = (int)$mod['id'];
$workshop_id = $mod['workshop_id'];
echo "\n Mod: " . ($mod['mod_name'] ?: $workshop_id) . " [ID=$workshop_id]\n";
// Mark as updating
if (!$dry_run) {
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'updating', `last_error` = NULL, `updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
// Build template vars for this mod
$folder_name = $mod['folder_name'] ?: ('@' . $workshop_id);
$tpl_vars = array_merge(
sw_agent_tpl_vars($home, $profile),
array(
'WORKSHOP_ID' => $workshop_id,
'MOD_NAME' => $mod['mod_name'] ?: $workshop_id,
'FOLDER_NAME' => $folder_name,
'MOD_FOLDER' => $folder_name,
'SERVER_ROOT' => $server_root,
'WORKSHOP_DOWNLOAD_DIR' => sw_apply_template(
$profile['workshop_download_dir_template']
?: ($server_root . '/steamapps/workshop/content/' . $profile['workshop_app_id']),
array(
'SERVER_ROOT' => $server_root,
'WORKSHOP_APP_ID' => $profile['workshop_app_id'],
'HOME_ID' => $home['home_id'],
)
),
)
);
$download_dir = $tpl_vars['WORKSHOP_DOWNLOAD_DIR'];
$mod_cache = rtrim($download_dir, '/') . '/' . $workshop_id;
// 1. Download / update via SteamCMD
$cmd_result = sw_agent_steamcmd_download($mod, $profile, $tpl_vars, $dry_run);
if (!$cmd_result['ok']) {
$err = $cmd_result['error'];
echo " [ERROR] SteamCMD failed: $err\n";
if (!$dry_run) {
$safe_err = $db->realEscapeSingle($err);
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'failed', `last_error` = '$safe_err', `updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
$all_ok = false;
continue;
}
// 2. Copy / sync mod folder to server root
$install_path = sw_apply_template(
$profile['install_path_template'] ?: ($server_root . '/{MOD_FOLDER}'),
$tpl_vars
);
$copy_ok = sw_agent_copy_mod($mod_cache, $install_path, $dry_run);
if (!$copy_ok) {
$err = "Failed to copy mod from $mod_cache to $install_path";
echo " [ERROR] $err\n";
if (!$dry_run) {
$safe_err = $db->realEscapeSingle($err);
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'failed', `last_error` = '$safe_err', `updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
$all_ok = false;
continue;
}
// 3. Copy .bikey files to server keys/ directory
if (!empty($profile['copy_bikeys_enabled'])) {
sw_agent_copy_bikeys($install_path, $keys_dir, $dry_run);
}
// 4. Mark as installed
if (!$dry_run) {
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'installed',
`last_installed_at` = NOW(),
`last_updated_at` = NOW(),
`last_error` = NULL,
`updated_at` = NOW()
WHERE `id` = $mod_id LIMIT 1"
);
}
echo " [OK] Installed → $install_path\n";
}
return $all_ok;
}
function sw_agent_get_queued_mods($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id
AND `enabled` = 1
AND `install_status` = 'queued'
ORDER BY `sort_order` ASC, `id` ASC"
);
return $rows ? $rows : array();
}
/**
* Build the standard template variable map for a given home + profile.
*
* @param array $home
* @param array $profile
* @return array
*/
function sw_agent_tpl_vars(array $home, array $profile)
{
return array(
'HOME_ID' => $home['home_id'],
'SERVER_ID' => $home['home_id'],
'REMOTE_SERVER_ID' => $home['remote_server_id'],
'GAME_NAME' => $home['game_name'],
'CONFIG_NAME' => $home['game_key'],
'STEAM_APP_ID' => $profile['steam_app_id'],
'WORKSHOP_APP_ID' => $profile['workshop_app_id'],
'STEAMCMD_PATH' => $profile['steamcmd_path'],
'SERVER_ROOT' => $home['home_path'],
'INSTALL_PATH' => $home['home_path'],
);
}
/**
* Run SteamCMD to download / update a single Workshop item.
*
* Uses the profile's update_script_template if set; otherwise falls back to
* a standard anonymous or authenticated +workshop_download_item invocation.
*
* @param array $mod
* @param array $profile
* @param array $tpl_vars
* @param bool $dry_run
* @return array ['ok' => bool, 'error' => string]
*/
function sw_agent_steamcmd_download(array $mod, array $profile, array $tpl_vars, $dry_run)
{
$steamcmd = $profile['steamcmd_path'] ?: '/home/gameserver/steamcmd/steamcmd.sh';
$workshop_id = $mod['workshop_id'];
$app_id = $profile['workshop_app_id'];
$dl_dir = $tpl_vars['WORKSHOP_DOWNLOAD_DIR'];
if (!empty($profile['update_script_template'])) {
// Admin has provided a custom update script.
$script_body = sw_apply_template($profile['update_script_template'], $tpl_vars);
return sw_agent_run_script($script_body, $dry_run);
}
// Build default SteamCMD command.
// +force_install_dir is set to the parent of the workshop content so that
// SteamCMD places files in <dl_dir>/<workshop_id>/.
$parent_dir = dirname($dl_dir); // .../steamapps/workshop/content
if ($profile['steamcmd_login_mode'] === 'account') {
// When account login is required the operator must supply credentials
// in the update_script_template. We cannot safely store a password here.
return array(
'ok' => false,
'error' => "Account login is required for this profile but no update_script_template is set. "
. "Add a custom update_script_template in the admin profile that includes SteamCMD login credentials.",
);
}
// Validate that steamcmd exists
if (!$dry_run) {
if (!is_file($steamcmd)) {
return array('ok' => false, 'error' => "SteamCMD not found: $steamcmd");
}
if (!is_executable($steamcmd)) {
return array('ok' => false, 'error' => "SteamCMD is not executable: $steamcmd");
}
}
// Build argument list; escape each argument individually.
$args = array(
escapeshellarg($steamcmd),
'+force_install_dir', escapeshellarg($parent_dir),
'+login', 'anonymous',
'+workshop_download_item', escapeshellarg($app_id), escapeshellarg($workshop_id),
'+quit',
);
$cmd = implode(' ', $args);
echo " SteamCMD: $cmd\n";
if ($dry_run) {
echo " [DRY RUN] Skipping SteamCMD execution.\n";
return array('ok' => true, 'error' => '');
}
$output = array();
$return_var = 0;
exec($cmd . ' 2>&1', $output, $return_var);
foreach ($output as $line) {
echo " $line\n";
}
if ($return_var !== 0) {
return array(
'ok' => false,
'error' => "SteamCMD exited with code $return_var. " . implode(' ', array_slice($output, -3)),
);
}
return array('ok' => true, 'error' => '');
}
/**
* Execute a shell script body.
* The script is written to a temporary file and executed with /bin/sh.
*
* @param string $script_body
* @param bool $dry_run
* @return array ['ok' => bool, 'error' => string]
*/
function sw_agent_run_script($script_body, $dry_run)
{
if ($dry_run) {
echo " [DRY RUN] Would execute script:\n";
foreach (explode("\n", $script_body) as $line) {
echo " $line\n";
}
return array('ok' => true, 'error' => '');
}
$tmp = tempnam(sys_get_temp_dir(), 'sw_agent_');
if (!$tmp) {
return array('ok' => false, 'error' => 'Could not create temporary file for script.');
}
file_put_contents($tmp, "#!/bin/sh\nset -e\n" . $script_body);
chmod($tmp, 0700);
$output = array();
$return_var = 0;
exec('/bin/sh ' . escapeshellarg($tmp) . ' 2>&1', $output, $return_var);
@unlink($tmp);
foreach ($output as $line) {
echo " $line\n";
}
if ($return_var !== 0) {
return array(
'ok' => false,
'error' => "Script exited with code $return_var. " . implode(' ', array_slice($output, -3)),
);
}
return array('ok' => true, 'error' => '');
}
/**
* Copy (rsync-style) the downloaded mod folder into the server root.
* Uses rsync when available, falls back to recursive PHP copy.
*
* @param string $src Downloaded mod folder (e.g. .../content/221100/2863534533)
* @param string $dst Target path in server root (e.g. /servers/123/@CF)
* @param bool $dry_run
* @return bool
*/
function sw_agent_copy_mod($src, $dst, $dry_run)
{
echo " Copy: $src$dst\n";
if (!$dry_run && !is_dir($src)) {
echo " [WARN] Source directory not found: $src\n";
return false;
}
if ($dry_run) {
return true;
}
if (!is_dir($dst)) {
if (!@mkdir($dst, 0755, true)) {
return false;
}
}
// Try rsync first (preserves permissions, handles deletes cleanly).
if (sw_agent_cmd_exists('rsync')) {
$cmd = 'rsync -a --delete '
. escapeshellarg(rtrim($src, '/') . '/') . ' '
. escapeshellarg(rtrim($dst, '/') . '/') . ' 2>&1';
exec($cmd, $out, $ret);
if ($ret === 0) {
return true;
}
echo " [WARN] rsync failed (exit $ret); falling back to PHP copy.\n";
}
// PHP recursive copy fallback.
return sw_agent_recursive_copy($src, $dst);
}
/**
* Copy all .bikey files found recursively under $mod_dir/keys/ into $keys_dir.
*
* @param string $mod_dir Installed mod directory
* @param string $keys_dir Server keys directory
* @param bool $dry_run
* @return void
*/
function sw_agent_copy_bikeys($mod_dir, $keys_dir, $dry_run)
{
// Search in common key locations within the mod folder.
$search_dirs = array(
$mod_dir . '/keys',
$mod_dir . '/Keys',
$mod_dir . '/key',
$mod_dir . '/Key',
);
$found = 0;
foreach ($search_dirs as $kdir) {
if (!is_dir($kdir)) {
continue;
}
foreach (glob($kdir . '/*.bikey') as $bikey) {
$target = $keys_dir . '/' . basename($bikey);
echo " .bikey: " . basename($bikey) . "$keys_dir/\n";
if (!$dry_run) {
@copy($bikey, $target);
}
$found++;
}
}
if ($found === 0) {
echo " (no .bikey files found in mod keys/ folder)\n";
}
}
/**
* Return true if a command is available in PATH.
*
* @param string $cmd
* @return bool
*/
function sw_agent_cmd_exists($cmd)
{
$which = trim((string)shell_exec('which ' . escapeshellarg($cmd) . ' 2>/dev/null'));
return !empty($which);
}
/**
* Recursively copy directory $src into $dst (creating $dst if needed).
*
* @param string $src
* @param string $dst
* @return bool
*/
function sw_agent_recursive_copy($src, $dst)
{
$dir = @opendir($src);
if (!$dir) {
return false;
}
if (!is_dir($dst)) {
@mkdir($dst, 0755, true);
}
while (false !== ($file = readdir($dir))) {
if ($file === '.' || $file === '..') {
continue;
}
$s = $src . '/' . $file;
$d = $dst . '/' . $file;
if (is_dir($s)) {
sw_agent_recursive_copy($s, $d);
} else {
copy($s, $d);
}
}
closedir($dir);
return true;
}

View file

@ -1,652 +0,0 @@
<?php
/*
* GSP Steam Workshop: shared helper functions
* Copyright (C) 2025 WDS / GameServerPanel
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
function sw_db_prefix()
{
if (defined('DB_PREFIX') && DB_PREFIX !== '') {
return DB_PREFIX;
}
if (isset($GLOBALS['db_prefix']) && $GLOBALS['db_prefix'] !== '') {
return $GLOBALS['db_prefix'];
}
if (isset($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
return $GLOBALS['table_prefix'];
}
return 'gsp_';
}
function sw_table($tableName)
{
return '`' . sw_db_prefix() . $tableName . '`';
}
// ── Profile helpers ───────────────────────────────────────────────────────
/**
* Return all rows from steam_workshop_game_profiles ordered by game_name.
*
* @param OGPDatabase $db
* @return array|false
*/
function sw_get_profiles($db)
{
return $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_game_profiles') . "
ORDER BY `game_name` ASC, `config_name` ASC"
);
}
/**
* Return a single profile row by primary key.
*
* @param OGPDatabase $db
* @param int $id
* @return array|false
*/
function sw_get_profile_by_id($db, $id)
{
$id = (int)$id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_game_profiles') . "
WHERE `id` = $id LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
/**
* Return a single profile row by config_name (= game_key from XML).
*
* @param OGPDatabase $db
* @param string $config_name
* @return array|false
*/
function sw_get_profile_by_config_name($db, $config_name)
{
$safe = $db->realEscapeSingle($config_name);
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_game_profiles') . "
WHERE `config_name` = '$safe' LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
/**
* Return the Workshop profile that applies to a server home.
* Resolves: home_id config_homes.game_key workshop profile.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array|false profile row or false when none found / not enabled
*/
function sw_get_profile_for_home($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT p.*
FROM " . sw_table('steam_workshop_game_profiles') . " p
JOIN " . sw_table('config_homes') . " c
ON c.`game_key` = p.`config_name`
JOIN " . sw_table('server_homes') . " s
ON s.`home_cfg_id` = c.`home_cfg_id`
WHERE s.`home_id` = $home_id
AND p.`enabled` = 1
LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
// ── Mod helpers ───────────────────────────────────────────────────────────
/**
* Return all mods for a server, sorted by sort_order ASC.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array|false
*/
function sw_get_server_mods($db, $home_id)
{
$home_id = (int)$home_id;
return $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id
ORDER BY `sort_order` ASC, `id` ASC"
);
}
/**
* Return a single mod row by primary key.
*
* @param OGPDatabase $db
* @param int $id
* @return array|false
*/
function sw_get_mod_by_id($db, $id)
{
$id = (int)$id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `id` = $id LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
// ── Server / ownership helpers ────────────────────────────────────────────
/**
* Return server_homes row joined with config_homes and remote_servers
* for the given home_id, or false when not found.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array|false
*/
function sw_get_home_info($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT s.*, c.`game_key`, c.`game_name`, r.`agent_ip`, r.`agent_port`
FROM " . sw_table('server_homes') . " s
JOIN " . sw_table('config_homes') . " c ON c.`home_cfg_id` = s.`home_cfg_id`
JOIN " . sw_table('remote_servers') . " r ON r.`remote_server_id` = s.`remote_server_id`
WHERE s.`home_id` = $home_id LIMIT 1"
);
return ($rows && isset($rows[0])) ? $rows[0] : false;
}
/**
* Verify that the current session user is allowed to manage this home.
* Admins always pass. Regular users/subusers must have an entry in
* user_homes (or be the user_id_main).
*
* @param OGPDatabase $db
* @param int $user_id
* @param int $home_id
* @return bool
*/
function sw_user_owns_home($db, $user_id, $home_id)
{
if (!isset($_SESSION['users_group'])) {
return false;
}
if ($_SESSION['users_group'] === 'admin') {
return true;
}
$user_id = (int)$user_id;
$home_id = (int)$home_id;
// Direct owner
$rows = $db->resultQuery(
"SELECT 1 FROM " . sw_table('server_homes') . "
WHERE `home_id` = $home_id AND `user_id_main` = $user_id LIMIT 1"
);
if ($rows) {
return true;
}
// Assigned via user_homes
$rows = $db->resultQuery(
"SELECT 1 FROM " . sw_table('user_homes') . "
WHERE `home_id` = $home_id AND `user_id` = $user_id LIMIT 1"
);
if ($rows) {
return true;
}
// Assigned via group
$rows = $db->resultQuery(
"SELECT 1 FROM " . sw_table('user_group_homes') . " ugh
JOIN " . sw_table('user_groups') . " ug ON ug.`group_id` = ugh.`group_id`
WHERE ugh.`home_id` = $home_id AND ug.`user_id` = $user_id LIMIT 1"
);
return (bool)$rows;
}
// ── Game-config helpers ───────────────────────────────────────────────────
/**
* Return an array of all game configs from the XML files.
* Each element is a SimpleXMLElement (game_config root node).
*
* @return SimpleXMLElement[]
*/
function sw_get_all_game_configs()
{
if (!defined('SERVER_CONFIG_LOCATION')) {
// server_config_parser.php defines this; load it if not already done.
if (file_exists(__DIR__ . '/../../config_games/server_config_parser.php')) {
require_once __DIR__ . '/../../config_games/server_config_parser.php';
} else {
return array();
}
}
$configs = array();
foreach (glob(SERVER_CONFIG_LOCATION . '*.xml') as $file) {
$xml = read_server_config($file);
if ($xml !== false) {
$configs[] = $xml;
}
}
return $configs;
}
/**
* Ensure every game config has a matching row in steam_workshop_game_profiles.
* Only creates rows that are missing; never overwrites existing data.
*
* @param OGPDatabase $db
* @return int number of new rows inserted
*/
function sw_sync_profiles($db)
{
$configs = sw_get_all_game_configs();
$created = 0;
foreach ($configs as $xml) {
$config_name = (string)$xml->game_key;
$game_name = (string)$xml->game_name;
if (empty($config_name)) {
continue;
}
$existing = sw_get_profile_by_config_name($db, $config_name);
if ($existing) {
continue; // already have a profile for this game config
}
$safe_config = $db->realEscapeSingle($config_name);
$safe_name = $db->realEscapeSingle($game_name);
$ok = $db->query("INSERT IGNORE INTO " . sw_table('steam_workshop_game_profiles') . " (`config_name`, `game_name`, `enabled`) VALUES ('$safe_config', '$safe_name', 0)");
if ($ok) {
$created++;
}
}
return $created;
}
// ── Template / launch-param helpers ─────────────────────────────────────
/**
* Replace {PLACEHOLDER} tokens in $template with values from $vars.
* Unknown tokens are left intact so admins can spot missing values.
*
* @param string $template
* @param array $vars associative: 'PLACEHOLDER' => 'value'
* @return string
*/
function sw_apply_template($template, array $vars)
{
$search = array();
$replace = array();
foreach ($vars as $key => $value) {
$search[] = '{' . $key . '}';
$replace[] = (string)$value;
}
return str_replace($search, $replace, $template);
}
/**
* Build the -mod= and -serverMod= launch parameter strings from an ordered
* list of enabled mods and the game profile.
*
* Returns an associative array:
* 'mod' => '-mod=@Mod1;@Mod2' (client mods)
* 'servermod' => '-serverMod=@ServerOnly' (server-side mods)
* 'combined' => '-mod=... -serverMod=...' (ready-to-paste)
*
* @param array $mods rows from steam_workshop_server_mods (must be pre-filtered
* for enabled = 1 and sorted by sort_order)
* @param array $profile row from steam_workshop_game_profiles
* @return array
*/
function sw_generate_launch_params(array $mods, array $profile)
{
$mod_param = trim($profile['mod_launch_param_template'] ?? '-mod=');
$servermod_param = trim($profile['servermod_launch_param_template'] ?? '-serverMod=');
$client_folders = array();
$server_folders = array();
foreach ($mods as $mod) {
if (empty($mod['enabled'])) {
continue;
}
$folder = !empty($mod['folder_name']) ? $mod['folder_name'] : ('@' . $mod['workshop_id']);
if ($mod['mod_type'] === 'server') {
$server_folders[] = $folder;
} else {
$client_folders[] = $folder;
}
}
$mod_str = $client_folders ? ($mod_param . implode(';', $client_folders)) : '';
$servermod_str = $server_folders ? ($servermod_param . implode(';', $server_folders)) : '';
$combined = trim($mod_str . ' ' . $servermod_str);
return array(
'mod' => $mod_str,
'servermod' => $servermod_str,
'combined' => $combined,
);
}
// ── Server behavior settings helpers ─────────────────────────────────────
/**
* Return the workshop behavior settings row for a home, or an array of
* safe defaults when no row exists yet.
*
* @param OGPDatabase $db
* @param int $home_id
* @return array
*/
function sw_get_server_settings($db, $home_id)
{
$home_id = (int)$home_id;
$rows = $db->resultQuery(
"SELECT * FROM " . sw_table('steam_workshop_server_settings') . "
WHERE `home_id` = $home_id LIMIT 1"
);
if ($rows && isset($rows[0]) && is_array($rows[0])) {
$settings = $rows[0];
// Runtime normalization is kept as a fallback for legacy/manual rows that
// were not updated via module migrations.
$legacyUpdateMap = array(
'scheduled' => 'manual',
);
$legacyRestartMap = array(
'if_empty' => 'if_stopped',
'next_restart' => 'if_stopped',
'immediate' => 'none',
);
$legacyScheduleMap = array(
'hourly' => 'daily',
);
if (isset($legacyUpdateMap[$settings['update_mode'] ?? ''])) {
$settings['update_mode'] = $legacyUpdateMap[$settings['update_mode']];
}
if (isset($legacyRestartMap[$settings['restart_behavior'] ?? ''])) {
$settings['restart_behavior'] = $legacyRestartMap[$settings['restart_behavior']];
}
if (isset($legacyScheduleMap[$settings['schedule_interval'] ?? ''])) {
$settings['schedule_interval'] = $legacyScheduleMap[$settings['schedule_interval']];
}
$validUpdateModes = array('manual', 'on_restart', 'before_start');
$validRestartBehaviors = array('none', 'if_stopped');
$validIntervals = array('disabled', 'daily', 'weekly');
if (!in_array($settings['update_mode'] ?? '', $validUpdateModes, true)) {
$settings['update_mode'] = 'manual';
}
if (!in_array($settings['restart_behavior'] ?? '', $validRestartBehaviors, true)) {
$settings['restart_behavior'] = 'none';
}
if (!in_array($settings['schedule_interval'] ?? '', $validIntervals, true)) {
$settings['schedule_interval'] = 'disabled';
}
return $settings;
}
// Safe defaults manual only, no automatic restarts, schedule disabled
return array(
'home_id' => $home_id,
'update_mode' => 'manual',
'restart_behavior' => 'none',
'schedule_interval' => 'disabled',
);
}
/**
* Upsert the workshop behavior settings for a server home.
*
* @param OGPDatabase $db
* @param int $home_id
* @param array $data keys: update_mode, restart_behavior, schedule_interval
* @return bool
*/
function sw_save_server_settings($db, $home_id, array $data)
{
$home_id = (int)$home_id;
$valid_update_modes = array('manual', 'on_restart', 'before_start');
$valid_restart_behaviors = array('none', 'if_stopped');
$valid_intervals = array('disabled', 'daily', 'weekly');
$update_mode = in_array($data['update_mode'] ?? '', $valid_update_modes, true) ? $data['update_mode'] : 'manual';
$restart_behavior = in_array($data['restart_behavior'] ?? '', $valid_restart_behaviors, true) ? $data['restart_behavior'] : 'none';
$schedule_interval = in_array($data['schedule_interval'] ?? '', $valid_intervals, true) ? $data['schedule_interval'] : 'disabled';
$safe_um = $db->realEscapeSingle($update_mode);
$safe_rb = $db->realEscapeSingle($restart_behavior);
$safe_si = $db->realEscapeSingle($schedule_interval);
return (bool)$db->query(
"INSERT INTO " . sw_table('steam_workshop_server_settings') . "
(`home_id`, `update_mode`, `restart_behavior`, `hot_load`,
`warning_minutes`, `schedule_interval`, `created_at`, `updated_at`)
VALUES ($home_id, '$safe_um', '$safe_rb', 'disabled',
0, '$safe_si', NOW(), NOW())
ON DUPLICATE KEY UPDATE
`update_mode` = '$safe_um',
`restart_behavior` = '$safe_rb',
`hot_load` = 'disabled',
`warning_minutes` = 0,
`schedule_interval` = '$safe_si',
`updated_at` = NOW()"
);
}
// ── Output helpers ────────────────────────────────────────────────────────
/**
* Render a short inline success banner.
*
* @param string $msg
* @return void
*/
function sw_success($msg)
{
echo '<div style="background:#d4edda;border:1px solid #c3e6cb;color:#155724;padding:8px 12px;margin:8px 0;border-radius:4px;">'
. htmlspecialchars($msg, ENT_QUOTES, 'UTF-8') . '</div>';
}
/**
* Render a short inline error banner.
*
* @param string $msg
* @return void
*/
function sw_error($msg)
{
echo '<div style="background:#f8d7da;border:1px solid #f5c6cb;color:#721c24;padding:8px 12px;margin:8px 0;border-radius:4px;">'
. htmlspecialchars($msg, ENT_QUOTES, 'UTF-8') . '</div>';
}
/**
* Escape a value for HTML output.
*
* @param mixed $v
* @return string
*/
function sw_h($v)
{
return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
}
function sw_detect_profile_defaults_from_xml($configName)
{
$configName = trim((string)$configName);
if ($configName === '') {
return array();
}
$matched = null;
foreach (sw_get_all_game_configs() as $xml) {
if ((string)$xml->game_key === $configName) {
$matched = $xml;
break;
}
}
if (!$matched) {
return array();
}
$steamAppId = '';
if (isset($matched->mods->mod)) {
foreach ($matched->mods->mod as $mod) {
$candidate = trim((string)$mod->installer_name);
if ($candidate !== '' && preg_match('/^\d+$/', $candidate)) {
$steamAppId = $candidate;
break;
}
}
}
$workshopAppId = '';
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' => $installPathTemplate,
);
}
function sw_apply_detected_profile_defaults($db, array $profile, array $detected, $overwriteExisting = false)
{
$columns = array(
'steam_app_id',
'workshop_app_id',
'steamcmd_path',
'workshop_download_dir_template',
'server_root_template',
'install_path_template',
);
$setParts = array();
$updated = 0;
foreach ($columns as $column) {
if (!array_key_exists($column, $detected) || $detected[$column] === '') {
continue;
}
$current = trim((string)($profile[$column] ?? ''));
if (!$overwriteExisting && $current !== '') {
continue;
}
if ($current === $detected[$column]) {
continue;
}
$setParts[] = "`$column` = '" . $db->realEscapeSingle($detected[$column]) . "'";
$updated++;
}
if (empty($setParts)) {
return 0;
}
$setParts[] = "`updated_at` = NOW()";
$db->query(
"UPDATE " . sw_table('steam_workshop_game_profiles') . "
SET " . implode(', ', $setParts) . "
WHERE `id` = " . (int)$profile['id'] . " LIMIT 1"
);
return $updated;
}
function steam_workshop_resolve_paths($db, array $home_info, $workshop_id, $target_path_template = '', $optional_folder_name = '', $workshop_app_id_override = '')
{
$profile = sw_get_profile_for_home($db, (int)$home_info['home_id']);
if (!$profile || !is_array($profile)) {
return array('ok' => false, 'error' => 'No enabled Steam Workshop profile for this server home.');
}
$workshop_id = trim((string)$workshop_id);
if ($workshop_id === '' || !preg_match('/^[0-9]+$/', $workshop_id)) {
return array('ok' => false, 'error' => 'Invalid Workshop ID.');
}
$workshop_app_id = trim((string)$workshop_app_id_override);
if ($workshop_app_id === '') {
$workshop_app_id = (string)$profile['workshop_app_id'];
}
$folder_name = trim((string)$optional_folder_name);
if ($folder_name === '') {
$folder_name = '@' . $workshop_id;
}
$vars = array(
'HOME_ID' => (int)$home_info['home_id'],
'SERVER_ROOT' => rtrim((string)$home_info['home_path'], '/'),
'GAME_ROOT' => rtrim((string)$home_info['home_path'], '/'),
'WORKSHOP_ID' => $workshop_id,
'WORKSHOP_APP_ID' => $workshop_app_id,
'STEAM_APP_ID' => (string)$profile['steam_app_id'],
'FOLDER_NAME' => $folder_name,
'MOD_FOLDER' => $folder_name,
);
$target_template = trim((string)$target_path_template);
if ($target_template === '') {
$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(
'ok' => true,
'profile' => $profile,
'workshop_id' => $workshop_id,
'workshop_app_id' => $workshop_app_id,
'steam_app_id' => (string)$profile['steam_app_id'],
'folder_name' => $folder_name,
'target_path_template' => $target_template,
'target_path_resolved' => $resolved_target,
'vars' => $vars,
);
}
function steam_workshop_download_item($db, array $home_info, $workshop_id, $target_path_template = '', array $options = array())
{
$optional_folder_name = isset($options['optional_folder_name']) ? $options['optional_folder_name'] : '';
$workshop_app_id = isset($options['workshop_app_id']) ? $options['workshop_app_id'] : '';
return steam_workshop_resolve_paths($db, $home_info, $workshop_id, $target_path_template, $optional_folder_name, $workshop_app_id);
}
function steam_workshop_install_item_to_home($db, array $home_info, $workshop_id, $target_path_template = '', array $options = array())
{
return steam_workshop_download_item($db, $home_info, $workshop_id, $target_path_template, $options);
}

View file

@ -1,89 +0,0 @@
-- GSP Steam Workshop Manual SQL Reference
-- =========================================
-- Replace PREFIX_ with your actual table prefix (e.g. gsp_).
-- Compatible with MySQL 5.7 and MySQL 8.0.
-- Do NOT hardcode any database name here.
-- Run in the panel database.
-- ── Drop legacy tables (if upgrading from the old adapter-based implementation) ──
DROP TABLE IF EXISTS `PREFIX_workshop_game_profiles`;
DROP TABLE IF EXISTS `PREFIX_workshop_cache`;
DROP TABLE IF EXISTS `PREFIX_server_workshop_mods`;
DROP TABLE IF EXISTS `PREFIX_server_workshop_settings`;
-- ── Create new tables ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `PREFIX_steam_workshop_game_profiles` (
`id` INT NOT NULL AUTO_INCREMENT,
`config_name` VARCHAR(100) NOT NULL,
`game_name` VARCHAR(255) NOT NULL DEFAULT '',
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`steam_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`workshop_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`steam_login_required` TINYINT(1) NOT NULL DEFAULT 0,
`steamcmd_login_mode` ENUM('anonymous','account') NOT NULL DEFAULT 'anonymous',
`steamcmd_path` VARCHAR(512) NOT NULL DEFAULT '/home/gameserver/steamcmd/steamcmd.sh',
`workshop_download_dir_template` TEXT NULL,
`server_root_template` TEXT NULL,
`install_path_template` TEXT NULL,
`folder_naming_format` VARCHAR(64) NOT NULL DEFAULT '@{MOD_NAME}',
`mod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-mod=',
`servermod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-serverMod=',
`install_script_template` TEXT NULL,
`update_script_template` TEXT NULL,
`copy_bikeys_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_config_name` (`config_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `PREFIX_steam_workshop_server_mods` (
`id` INT NOT NULL AUTO_INCREMENT,
`home_id` INT NOT NULL,
`profile_id` INT NOT NULL,
`workshop_id` VARCHAR(64) NOT NULL,
`mod_name` VARCHAR(255) NOT NULL DEFAULT '',
`folder_name` VARCHAR(255) NOT NULL DEFAULT '',
`mod_type` ENUM('client','server') NOT NULL DEFAULT 'client',
`sort_order` INT NOT NULL DEFAULT 0,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`install_status` VARCHAR(32) NOT NULL DEFAULT '',
`last_installed_at` DATETIME NULL,
`last_updated_at` DATETIME NULL,
`last_error` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_home_workshop` (`home_id`, `workshop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── Example: DayZ profile ────────────────────────────────────────────────
-- After running the above, insert an example DayZ profile.
-- Adjust config_name to match your actual DayZ game_key from config_homes.
-- (Run `SELECT game_key, game_name FROM PREFIX_config_homes WHERE game_name LIKE '%DayZ%';`
-- to find the right config_name.)
--
-- INSERT INTO `PREFIX_steam_workshop_game_profiles`
-- (`config_name`, `game_name`, `enabled`,
-- `steam_app_id`, `workshop_app_id`,
-- `steamcmd_path`,
-- `workshop_download_dir_template`,
-- `server_root_template`,
-- `install_path_template`,
-- `folder_naming_format`,
-- `mod_launch_param_template`,
-- `servermod_launch_param_template`,
-- `copy_bikeys_enabled`)
-- VALUES
-- ('dayz_win64', 'DayZ', 1,
-- '223350', '221100',
-- '/home/gameserver/steamcmd/steamcmd.sh',
-- '{SERVER_ROOT}/steamapps/workshop/content/{WORKSHOP_APP_ID}',
-- '/home/gameserver/servers/{HOME_ID}',
-- '{SERVER_ROOT}/{MOD_FOLDER}',
-- '@{MOD_NAME}',
-- '-mod=',
-- '-serverMod=',
-- 1);

View file

@ -1,248 +0,0 @@
<?php
/*
* GSP Steam Workshop module
* Copyright (C) 2025 WDS / GameServerPanel
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
$module_title = "Steam Workshop";
$module_version = "3.3";
$db_version = 5;
$module_required = FALSE;
// DEPRECATED: The Steam Workshop standalone module has been superseded by
// Server Content Manager (addonsmanager). Navigation access is removed so
// users are directed to the new unified workshop workflow. The DB tables
// and helper functions are preserved for backward compatibility.
// See Panel/modules/addonsmanager/ for the replacement implementation.
$module_menus = array();
if (!function_exists('sw_module_db_prefix')) {
function sw_module_db_prefix()
{
if (defined('DB_PREFIX') && DB_PREFIX !== '') {
return DB_PREFIX;
}
if (isset($GLOBALS['db_prefix']) && $GLOBALS['db_prefix'] !== '') {
return $GLOBALS['db_prefix'];
}
if (isset($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
return $GLOBALS['table_prefix'];
}
return 'gsp_';
}
}
if (!function_exists('sw_module_table_name')) {
function sw_module_table_name($table)
{
$prefix = preg_replace('/[^a-zA-Z0-9_]/', '', (string)sw_module_db_prefix());
$name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$table);
if ($prefix === '') {
$prefix = 'gsp_';
}
return $prefix . $name;
}
}
if (!function_exists('sw_module_table')) {
function sw_module_table($table)
{
return '`' . sw_module_table_name($table) . '`';
}
}
$install_queries = array();
$legacyDrops = array(
"DROP TABLE IF EXISTS " . sw_module_table('workshop_game_profiles'),
"DROP TABLE IF EXISTS " . sw_module_table('workshop_cache'),
"DROP TABLE IF EXISTS " . sw_module_table('server_workshop_mods'),
"DROP TABLE IF EXISTS " . sw_module_table('server_workshop_settings'),
);
$schemaCreate = array(
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_game_profiles') . " (
`id` INT NOT NULL AUTO_INCREMENT,
`config_name` VARCHAR(100) NOT NULL,
`game_name` VARCHAR(255) NOT NULL DEFAULT '',
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`steam_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`workshop_app_id` VARCHAR(32) NOT NULL DEFAULT '',
`steam_login_required` TINYINT(1) NOT NULL DEFAULT 0,
`steamcmd_login_mode` ENUM('anonymous','account') NOT NULL DEFAULT 'anonymous',
`steamcmd_path` VARCHAR(512) NOT NULL DEFAULT '/home/gameserver/steamcmd/steamcmd.sh',
`workshop_download_dir_template` TEXT NULL,
`server_root_template` TEXT NULL,
`install_path_template` TEXT NULL,
`folder_naming_format` VARCHAR(64) NOT NULL DEFAULT '@{MOD_NAME}',
`mod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-mod=',
`servermod_launch_param_template` VARCHAR(255) NOT NULL DEFAULT '-serverMod=',
`install_script_template` TEXT NULL,
`update_script_template` TEXT NULL,
`copy_bikeys_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`default_update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual',
`default_restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none',
`default_hot_load` ENUM('disabled','attempt') NOT NULL DEFAULT 'disabled',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_config_name` (`config_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_server_mods') . " (
`id` INT NOT NULL AUTO_INCREMENT,
`home_id` INT NOT NULL,
`profile_id` INT NOT NULL,
`workshop_id` VARCHAR(64) NOT NULL,
`mod_name` VARCHAR(255) NOT NULL DEFAULT '',
`folder_name` VARCHAR(255) NOT NULL DEFAULT '',
`mod_type` ENUM('client','server') NOT NULL DEFAULT 'client',
`sort_order` INT NOT NULL DEFAULT 0,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`install_status` VARCHAR(32) NOT NULL DEFAULT '',
`last_installed_at` DATETIME NULL,
`last_updated_at` DATETIME NULL,
`last_error` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_home_workshop` (`home_id`, `workshop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_server_settings') . " (
`home_id` INT NOT NULL,
`update_mode` ENUM('manual','on_restart','before_start')
NOT NULL DEFAULT 'manual',
`restart_behavior` ENUM('none','if_stopped')
NOT NULL DEFAULT 'none',
`hot_load` ENUM('disabled','attempt')
NOT NULL DEFAULT 'disabled',
`warning_minutes` INT NOT NULL DEFAULT 0,
`schedule_interval` ENUM('disabled','daily','weekly') NOT NULL DEFAULT 'disabled',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`home_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
);
$install_queries[0] = array_merge($legacyDrops, $schemaCreate);
$install_queries[3] = array_merge($legacyDrops, $schemaCreate);
$install_queries[4] = array();
$install_queries[5] = array(
function ($db) {
$table = sw_module_table_name('steam_workshop_server_settings');
$exists = $db->resultQuery(
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . $db->realEscapeSingle($table) . "'"
);
if (!$exists || (int)($exists[0]['cnt'] ?? 0) === 0) {
return (bool)$db->query(
"CREATE TABLE IF NOT EXISTS " . sw_module_table('steam_workshop_server_settings') . " (
`home_id` INT NOT NULL,
`update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual',
`restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none',
`hot_load` ENUM('disabled','attempt') NOT NULL DEFAULT 'disabled',
`warning_minutes` INT NOT NULL DEFAULT 0,
`schedule_interval` ENUM('disabled','daily','weekly') NOT NULL DEFAULT 'disabled',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL,
PRIMARY KEY (`home_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
$ok = true;
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `update_mode` = CASE
WHEN `update_mode` = 'scheduled' THEN 'manual'
ELSE `update_mode`
END"
);
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `restart_behavior` = CASE
WHEN `restart_behavior` IN ('if_empty','next_restart') THEN 'if_stopped'
WHEN `restart_behavior` = 'immediate' THEN 'none'
ELSE `restart_behavior`
END"
);
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `schedule_interval` = CASE
WHEN `schedule_interval` = 'hourly' THEN 'daily'
WHEN `schedule_interval` IS NULL OR `schedule_interval` = '' THEN 'disabled'
ELSE `schedule_interval`
END"
);
// The simplified workflow intentionally hard-disables these legacy fields
// for every row so old unsupported behaviors cannot be re-enabled.
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_server_settings') . "
SET `hot_load` = 'disabled', `warning_minutes` = 0"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_server_settings') . "
MODIFY `update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual'"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_server_settings') . "
MODIFY `restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none'"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_server_settings') . "
MODIFY `schedule_interval` ENUM('disabled','daily','weekly') NOT NULL DEFAULT 'disabled'"
);
return $ok;
},
function ($db) {
$profileTable = sw_module_table_name('steam_workshop_game_profiles');
$exists = $db->resultQuery(
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . $db->realEscapeSingle($profileTable) . "'"
);
if (!$exists || (int)($exists[0]['cnt'] ?? 0) === 0) {
return true;
}
$ok = true;
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_game_profiles') . "
SET `default_update_mode` = CASE
WHEN `default_update_mode` = 'scheduled' THEN 'manual'
ELSE `default_update_mode`
END"
);
$ok = $ok && (bool)$db->query(
"UPDATE " . sw_module_table('steam_workshop_game_profiles') . "
SET `default_restart_behavior` = CASE
WHEN `default_restart_behavior` IN ('if_empty','next_restart') THEN 'if_stopped'
WHEN `default_restart_behavior` = 'immediate' THEN 'none'
ELSE `default_restart_behavior`
END"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_game_profiles') . "
MODIFY `default_update_mode` ENUM('manual','on_restart','before_start') NOT NULL DEFAULT 'manual'"
);
$ok = $ok && (bool)$db->query(
"ALTER TABLE " . sw_module_table('steam_workshop_game_profiles') . "
MODIFY `default_restart_behavior` ENUM('none','if_stopped') NOT NULL DEFAULT 'none'"
);
return $ok;
},
);
$uninstall_queries = array(
"DROP TABLE IF EXISTS " . sw_module_table('steam_workshop_server_settings'),
"DROP TABLE IF EXISTS " . sw_module_table('steam_workshop_server_mods'),
"DROP TABLE IF EXISTS " . sw_module_table('steam_workshop_game_profiles'),
);

View file

@ -1,44 +0,0 @@
<?php
/*
* GSP Steam Workshop: monitor page button
* Copyright (C) 2025 WDS / GameServerPanel
*
* Adds a "Steam Workshop" button to the game/server monitor page when:
* - the game's Workshop profile is enabled in steam_workshop_game_profiles, AND
* - the current user owns the server or is an admin.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
$module_buttons = array();
if (!function_exists('sw_monitor_db_prefix')) {
function sw_monitor_db_prefix()
{
if (defined('DB_PREFIX') && DB_PREFIX !== '') {
return DB_PREFIX;
}
if (isset($GLOBALS['db_prefix']) && $GLOBALS['db_prefix'] !== '') {
return $GLOBALS['db_prefix'];
}
if (isset($GLOBALS['table_prefix']) && $GLOBALS['table_prefix'] !== '') {
return $GLOBALS['table_prefix'];
}
return 'gsp_';
}
}
if (!function_exists('sw_monitor_table')) {
function sw_monitor_table($name)
{
return '`' . sw_monitor_db_prefix() . $name . '`';
}
}
// Deprecated: the standalone steam_workshop workflow is no longer the primary
// user path. Server Content Manager (addonsmanager) now owns Workshop installs
// and exposes its own monitor button when content templates exist.
?>

View file

@ -1,6 +0,0 @@
<navigation>
<!-- Admin: manage Steam Workshop game profiles -->
<page key="admin" file="admin.php" access="admin" />
<!-- User: manage per-server mods -->
<page key="user" file="user.php" access="admin,user,subuser" />
</navigation>

View file

@ -1,558 +0,0 @@
<?php
/*
* GSP Steam Workshop: User mod management
* Copyright (C) 2025 WDS / GameServerPanel
*
* Accessible via: home.php?m=steam_workshop&p=user&home_id=123
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
require_once __DIR__ . '/includes/functions.php';
function exec_ogp_module()
{
global $db;
echo '<h2>Steam Workshop Mod Manager</h2>';
$home_id = isset($_REQUEST['home_id']) ? (int)$_REQUEST['home_id'] : 0;
if (!$home_id) {
sw_error('No server selected. Please access this page from your game server manager.');
return;
}
// Ownership check
if (!sw_user_owns_home($db, (int)$_SESSION['user_id'], $home_id)) {
sw_error('Access denied. You do not own this server.');
return;
}
// Load server info
$home = sw_get_home_info($db, $home_id);
if (!$home) {
sw_error('Server not found.');
return;
}
// Find matching Workshop profile
$profile = sw_get_profile_for_home($db, $home_id);
if (!$profile) {
echo '<p>Steam Workshop is not enabled for this game.</p>';
echo '<p>An administrator must enable Workshop support for this game under '
. '<em>Steam Workshop &rsaquo; Admin</em>.</p>';
return;
}
$action = $_POST['action'] ?? ($_GET['action'] ?? '');
// ── POST handlers ─────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
switch ($action) {
case 'add_mod':
sw_user_add_mod($db, $home_id, $profile);
break;
case 'save_mod':
sw_user_save_mod($db, $home_id);
break;
case 'delete_mod':
sw_user_delete_mod($db, $home_id);
break;
case 'toggle_mod':
sw_user_toggle_mod($db, $home_id);
break;
case 'move_up':
case 'move_down':
sw_user_reorder_mod($db, $home_id, $action);
break;
case 'queue_update':
sw_user_queue_update($db, $home_id);
break;
case 'save_settings':
sw_user_save_settings($db, $home_id);
break;
}
}
// ── Render page ───────────────────────────────────────────────────
sw_user_render($db, $home_id, $home, $profile);
}
// ─────────────────────────────────────────────────────────────────────────
// POST action handlers
// ─────────────────────────────────────────────────────────────────────────
function sw_user_add_mod($db, $home_id, array $profile)
{
$workshop_id = trim($_POST['workshop_id'] ?? '');
if (!preg_match('/^\d{1,20}$/', $workshop_id)) {
sw_error('Invalid Workshop ID must be a numeric Steam Workshop item ID.');
return;
}
// Prevent duplicates
$safe_wid = $db->realEscapeSingle($workshop_id);
$exists = $db->resultQuery(
"SELECT id FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id AND `workshop_id` = '$safe_wid' LIMIT 1"
);
if ($exists) {
sw_error("Workshop ID $workshop_id is already in the list.");
return;
}
// Determine next sort_order
$last = $db->resultQuery(
"SELECT MAX(`sort_order`) AS m FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `home_id` = $home_id"
);
$sort = ($last && isset($last[0]['m'])) ? ((int)$last[0]['m'] + 1) : 0;
$mod_name = $db->realEscapeSingle(trim($_POST['mod_name'] ?? ''));
$mod_type = (($_POST['mod_type'] ?? 'client') === 'server') ? 'server' : 'client';
$profile_id = (int)$profile['id'];
// Auto-generate folder name from naming format template
$folder_name = sw_apply_template(
$profile['folder_naming_format'],
array(
'MOD_NAME' => !empty($mod_name) ? $mod_name : $workshop_id,
'WORKSHOP_ID' => $workshop_id,
'WORKSHOP_APP_ID'=> $profile['workshop_app_id'],
)
);
$safe_fname = $db->realEscapeSingle($folder_name);
$safe_mname = $mod_name; // already escaped above via realEscapeSingle
$ok = $db->query(
"INSERT INTO " . sw_table('steam_workshop_server_mods') . "
(`home_id`, `profile_id`, `workshop_id`, `mod_name`, `folder_name`,
`mod_type`, `sort_order`, `enabled`, `install_status`, `created_at`)
VALUES ($home_id, $profile_id, '$safe_wid', '$safe_mname', '$safe_fname',
'$mod_type', $sort, 1, '', NOW())"
);
if ($ok) {
sw_success("Workshop mod $workshop_id added.");
} else {
sw_error('Failed to add mod.');
}
}
function sw_user_save_mod($db, $home_id)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
sw_error('Mod not found or access denied.');
return;
}
$mod_name = $db->realEscapeSingle(trim($_POST['mod_name'] ?? ''));
$folder_name = $db->realEscapeSingle(trim($_POST['folder_name'] ?? ''));
$mod_type = (($_POST['mod_type'] ?? 'client') === 'server') ? 'server' : 'client';
if (empty($folder_name)) {
sw_error('Folder name cannot be empty.');
return;
}
$ok = $db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `mod_name` = '$mod_name',
`folder_name` = '$folder_name',
`mod_type` = '$mod_type',
`updated_at` = NOW()
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
if ($ok) {
sw_success('Mod updated.');
} else {
sw_error('Failed to update mod.');
}
}
function sw_user_delete_mod($db, $home_id)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
sw_error('Mod not found or access denied.');
return;
}
$db->query(
"DELETE FROM " . sw_table('steam_workshop_server_mods') . "
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
sw_success('Mod removed from list.');
}
function sw_user_toggle_mod($db, $home_id)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
sw_error('Mod not found or access denied.');
return;
}
$new_state = $mod['enabled'] ? 0 : 1;
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `enabled` = $new_state, `updated_at` = NOW()
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
}
function sw_user_reorder_mod($db, $home_id, $direction)
{
$mod_id = (int)($_POST['mod_id'] ?? 0);
if (!$mod_id) {
return;
}
$mod = sw_get_mod_by_id($db, $mod_id);
if (!$mod || (int)$mod['home_id'] !== $home_id) {
return;
}
$mods = sw_get_server_mods($db, $home_id);
if (!$mods) {
return;
}
// Normalise sort_order to 0-based sequential integers
$sorted = array_values($mods);
foreach ($sorted as $idx => $m) {
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `sort_order` = $idx
WHERE `id` = " . (int)$m['id'] . " AND `home_id` = $home_id LIMIT 1"
);
}
// Find the position of the target mod
$pos = -1;
foreach ($sorted as $idx => $m) {
if ((int)$m['id'] === $mod_id) {
$pos = $idx;
break;
}
}
if ($pos < 0) {
return;
}
if ($direction === 'move_up' && $pos > 0) {
$swap_pos = $pos - 1;
} elseif ($direction === 'move_down' && $pos < (count($sorted) - 1)) {
$swap_pos = $pos + 1;
} else {
return; // already at boundary
}
$swap_id = (int)$sorted[$swap_pos]['id'];
// Swap sort_order values
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `sort_order` = $swap_pos
WHERE `id` = $mod_id AND `home_id` = $home_id LIMIT 1"
);
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `sort_order` = $pos
WHERE `id` = $swap_id AND `home_id` = $home_id LIMIT 1"
);
}
function sw_user_queue_update($db, $home_id)
{
// Mark all enabled mods as 'queued' so the agent picks them up.
$db->query(
"UPDATE " . sw_table('steam_workshop_server_mods') . "
SET `install_status` = 'queued', `updated_at` = NOW()
WHERE `home_id` = $home_id AND `enabled` = 1"
);
sw_success('All enabled mods were queued. Updates are processed automatically by the server agent.');
}
function sw_user_save_settings($db, $home_id)
{
$ok = sw_save_server_settings($db, $home_id, array(
'update_mode' => $_POST['update_mode'] ?? 'manual',
'restart_behavior' => $_POST['restart_behavior'] ?? 'none',
'schedule_interval' => $_POST['schedule_interval'] ?? 'disabled',
));
if ($ok) {
sw_success('Workshop behavior settings saved.');
} else {
sw_error('Failed to save settings.');
}
}
// ─────────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────────
function sw_user_render($db, $home_id, array $home, array $profile)
{
$mods = sw_get_server_mods($db, $home_id) ?: array();
$settings = sw_get_server_settings($db, $home_id);
$queuedCount = 0;
$failedCount = 0;
$installedCount = 0;
$latestUpdateAt = '';
$latestError = '';
foreach ($mods as $mod) {
if (($mod['install_status'] ?? '') === 'queued') {
$queuedCount++;
} elseif (($mod['install_status'] ?? '') === 'failed') {
$failedCount++;
} elseif (($mod['install_status'] ?? '') === 'installed') {
$installedCount++;
}
if (!empty($mod['last_updated_at']) && $mod['last_updated_at'] > $latestUpdateAt) {
$latestUpdateAt = $mod['last_updated_at'];
}
if (($mod['install_status'] ?? '') === 'failed' && !empty($mod['last_error']) && $latestError === '') {
$latestError = $mod['last_error'];
}
}
$base_url = 'home.php?m=steam_workshop&p=user&home_id=' . $home_id;
?>
<style>
.sw-user-panel{background:#161616;border:1px solid #2f2f2f;border-radius:6px;padding:14px;margin:10px 0;color:#ececec}
.sw-user-panel h3{margin:0 0 10px 0;color:#fff}
.sw-user-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.sw-user-grid label{display:block}
.sw-user-grid span{display:block;font-size:12px;color:#bebebe;margin-bottom:4px}
.sw-user-grid input[type=text],.sw-user-grid select{width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #3a3a3a;color:#f0f0f0;padding:7px;border-radius:4px}
.sw-user-table-wrap{overflow-x:auto}
.sw-user-table{width:100%;border-collapse:collapse;min-width:860px}
.sw-user-table th,.sw-user-table td{border:1px solid #353535;padding:8px;vertical-align:middle}
.sw-user-table th{background:#202020;text-align:left;color:#fff}
.sw-status-ok{color:#7cdc7c;font-weight:700}
.sw-status-queued{color:#ffca63;font-weight:700}
.sw-status-failed{color:#ff8484;font-weight:700}
.sw-status-progress{color:#8cc7ff;font-weight:700}
.sw-muted{color:#b4b4b4}
</style>
<div class="sw-user-panel">
<p>
<strong>Server:</strong> <?= sw_h($home['home_name']) ?>
&nbsp;&nbsp;<strong>Game:</strong> <?= sw_h($home['game_name']) ?>
&nbsp;&nbsp;<strong>Workshop Profile:</strong> <?= sw_h($profile['config_name']) ?>
</p>
<p class="sw-muted" style="margin-bottom:0;">
Queue updates from this page. The server agent applies queued updates automatically.
</p>
</div>
<div class="sw-user-panel">
<h3>Add Workshop Mod</h3>
<form method="post" action="<?= sw_h($base_url) ?>">
<input type="hidden" name="action" value="add_mod">
<div class="sw-user-grid">
<label>
<span>Workshop ID</span>
<input type="text" id="workshop_id" name="workshop_id" placeholder="e.g. 2863534533" required>
</label>
<label>
<span>Display Name (optional)</span>
<input type="text" id="add_mod_name" name="mod_name" placeholder="e.g. CF">
</label>
<label>
<span>Mod Type</span>
<select id="add_mod_type" name="mod_type">
<option value="client">Client mod</option>
<option value="server">Server-side only</option>
</select>
</label>
</div>
<p style="margin:12px 0 0;">
<button type="submit" class="button">Add Mod</button>
</p>
</form>
</div>
<div class="sw-user-panel">
<h3>Update Queue &amp; Last Result</h3>
<p>
<strong>Enabled mods:</strong> <?= count(array_filter($mods, function ($m) { return !empty($m['enabled']); })) ?>
&nbsp;&nbsp;<strong>Queued:</strong> <?= $queuedCount ?>
&nbsp;&nbsp;<strong>Installed:</strong> <?= $installedCount ?>
&nbsp;&nbsp;<strong>Failed:</strong> <?= $failedCount ?>
</p>
<p>
<strong>Last update time:</strong> <?= $latestUpdateAt ? sw_h($latestUpdateAt) : 'Never' ?>
</p>
<?php if ($latestError !== ''): ?>
<p><strong>Last error:</strong> <?= sw_h($latestError) ?></p>
<?php endif; ?>
<form method="post" action="<?= sw_h($base_url) ?>">
<input type="hidden" name="action" value="queue_update">
<button type="submit" class="button" onclick="return confirm('Queue all enabled mods for update?');">Queue Update for All Enabled Mods</button>
</form>
</div>
<div class="sw-user-panel">
<h3>Workshop Behavior Settings</h3>
<form method="post" action="<?= sw_h($base_url) ?>">
<input type="hidden" name="action" value="save_settings">
<div class="sw-user-grid">
<label>
<span>Update Mode</span>
<select name="update_mode">
<option value="manual" <?= ($settings['update_mode'] === 'manual') ? 'selected' : '' ?>>Manual only</option>
<option value="on_restart" <?= ($settings['update_mode'] === 'on_restart') ? 'selected' : '' ?>>On next restart</option>
<option value="before_start" <?= ($settings['update_mode'] === 'before_start') ? 'selected' : '' ?>>Before server start</option>
</select>
</label>
<label>
<span>Restart Behavior</span>
<select name="restart_behavior">
<option value="none" <?= ($settings['restart_behavior'] === 'none') ? 'selected' : '' ?>>Never restart automatically</option>
<option value="if_stopped" <?= ($settings['restart_behavior'] === 'if_stopped') ? 'selected' : '' ?>>Restart only if server is stopped</option>
</select>
</label>
<label>
<span>Scheduled Checks</span>
<select name="schedule_interval">
<option value="disabled" <?= ($settings['schedule_interval'] === 'disabled') ? 'selected' : '' ?>>Disabled</option>
<option value="daily" <?= ($settings['schedule_interval'] === 'daily') ? 'selected' : '' ?>>Daily</option>
<option value="weekly" <?= ($settings['schedule_interval'] === 'weekly') ? 'selected' : '' ?>>Weekly</option>
</select>
</label>
</div>
<p style="margin:12px 0 0;">
<button type="submit" class="button">Save Behavior Settings</button>
</p>
</form>
</div>
<div class="sw-user-panel">
<h3>Installed Mods (<?= count($mods) ?>)</h3>
<?php if (empty($mods)): ?>
<p>No mods added yet. Use the form above to add Workshop IDs.</p>
<?php else: ?>
<div class="sw-user-table-wrap">
<table class="sw-user-table">
<thead>
<tr>
<th>#</th>
<th>Workshop ID</th>
<th>Mod Name</th>
<th>Folder Name</th>
<th>Type</th>
<th>Enabled</th>
<th>Status</th>
<th>Last Update</th>
<th>Last Error</th>
<th>Order</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($mods as $idx => $mod): ?>
<?php
$shortError = '';
if (!empty($mod['last_error'])) {
$shortError = (strlen($mod['last_error']) > 70)
? (substr($mod['last_error'], 0, 67) . '...')
: $mod['last_error'];
}
?>
<tr style="<?= !$mod['enabled'] ? 'opacity:0.55;' : '' ?>">
<td><?= $idx + 1 ?></td>
<td style="font-family:monospace;"><?= sw_h($mod['workshop_id']) ?></td>
<td>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<input type="hidden" name="action" value="save_mod">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<input type="text" name="mod_name" value="<?= sw_h($mod['mod_name']) ?>" style="width:120px;">
</td>
<td><input type="text" name="folder_name" value="<?= sw_h($mod['folder_name']) ?>" style="width:120px;"></td>
<td>
<select name="mod_type" style="width:120px;">
<option value="client" <?= $mod['mod_type'] === 'client' ? 'selected' : '' ?>>Client</option>
<option value="server" <?= $mod['mod_type'] === 'server' ? 'selected' : '' ?>>Server</option>
</select>
<button type="submit" class="button small">Save</button>
</form>
</td>
<td>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="toggle_mod">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small" style="<?= $mod['enabled'] ? 'background:#5cb85c;color:#fff;' : '' ?>"><?= $mod['enabled'] ? 'On' : 'Off' ?></button>
</form>
</td>
<td>
<?php
$s = $mod['install_status'];
if ($s === 'installed') {
echo '<span class="sw-status-ok">Installed</span>';
} elseif ($s === 'queued') {
echo '<span class="sw-status-queued">Queued</span>';
} elseif ($s === 'failed') {
echo '<span class="sw-status-failed">Failed</span>';
} elseif ($s === 'updating') {
echo '<span class="sw-status-progress">Updating</span>';
} else {
echo '<span class="sw-muted">Not installed</span>';
}
?>
</td>
<td><?= !empty($mod['last_updated_at']) ? sw_h($mod['last_updated_at']) : '-' ?></td>
<td title="<?= sw_h($mod['last_error'] ?? '') ?>"><?= $shortError !== '' ? sw_h($shortError) : '-' ?></td>
<td style="white-space:nowrap;">
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="move_up">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small" <?= $idx === 0 ? 'disabled' : '' ?>>&#9650;</button>
</form>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="move_down">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small" <?= $idx === (count($mods) - 1) ? 'disabled' : '' ?>>&#9660;</button>
</form>
</td>
<td>
<form method="post" action="<?= sw_h($base_url) ?>" style="display:inline;">
<input type="hidden" name="action" value="delete_mod">
<input type="hidden" name="mod_id" value="<?= (int)$mod['id'] ?>">
<button type="submit" class="button small danger" onclick="return confirm('Remove this mod from the list?');" style="background:#d9534f;color:#fff;">Remove</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php
}